mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
feat(capture): extract gradient washes, glass panels, and nav CTAs (#1879)
The design-style extractor now captures a site's signature color grounds and materials that a flat background-color misses: - Capture gradient background-image + backdrop-filter on buttons/cards/nav. - backgrounds[]: dominant gradient / mesh washes ranked by on-screen area (includes ::before/::after glow orbs), chroma-weighted so a small vivid brand wash outranks a large neutral scrim. - glass[]: frosted-glass panels (backdrop-filter blur) with their raw translucent fill, border, radius, shadow — ranked by area. - nav CTA capture: keep filled buttons inside <nav> (a page's primary "Sign up" / "Start for free" CTA that the old nav-drop lost), including gradient-filled CTAs whose background-COLOR is transparent. - Dedup keys for buttons/cards now include gradient + glass so a gradient/frosted variant is not collapsed into its flat sibling. - Fix: a fully-transparent fill rgba(...,0) now reports "transparent" instead of #000000 — the old bug turned every transparent wrapper into a phantom black button/card. types: ComponentStyle gains backgroundImage/backdropFilter; DesignStyles gains backgrounds[] and glass[]. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3b67918242
commit
dd774b3692
@@ -21,7 +21,8 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
|||||||
function rgbToHex(color) {
|
function rgbToHex(color) {
|
||||||
if (!color) return "";
|
if (!color) return "";
|
||||||
if (color.startsWith('#')) return color.toUpperCase();
|
if (color.startsWith('#')) return color.toUpperCase();
|
||||||
var m = color.match(/rgba?\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*(?:,\\s*([\\d.]+))?/);
|
// capture optional alpha (group 4), allowing both comma and modern slash (rgb r g b / a) syntax.
|
||||||
|
var m = color.match(/rgba?\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)(?:\\s*[,/]\\s*([\\d.]+))?/);
|
||||||
if (!m) return color;
|
if (!m) return color;
|
||||||
// fully-transparent fill (rgba(...,0)) → sentinel, NOT #000000 — otherwise a transparent
|
// fully-transparent fill (rgba(...,0)) → sentinel, NOT #000000 — otherwise a transparent
|
||||||
// chip/tab/stat ground reads as solid black on a light-ground site.
|
// chip/tab/stat ground reads as solid black on a light-ground site.
|
||||||
@@ -33,8 +34,15 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
|||||||
return f.split(",")[0].replace(/['"]/g, "").trim();
|
return f.split(",")[0].replace(/['"]/g, "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// keep only gradient background-images (drop url() sprites + "none"); gradients are a core
|
||||||
|
// brand signal (Stripe/ElevenLabs/Snowflake mesh washes) that a flat background-color misses.
|
||||||
|
function gradientOf(v) {
|
||||||
|
return v && v.indexOf("gradient") >= 0 ? v.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
function getStyles(el) {
|
function getStyles(el) {
|
||||||
var s = getComputedStyle(el);
|
var s = getComputedStyle(el);
|
||||||
|
var bf = s.backdropFilter || s.webkitBackdropFilter || "";
|
||||||
return {
|
return {
|
||||||
fontFamily: cleanFont(s.fontFamily),
|
fontFamily: cleanFont(s.fontFamily),
|
||||||
fontSize: s.fontSize,
|
fontSize: s.fontSize,
|
||||||
@@ -43,6 +51,8 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
|||||||
letterSpacing: s.letterSpacing,
|
letterSpacing: s.letterSpacing,
|
||||||
color: rgbToHex(s.color),
|
color: rgbToHex(s.color),
|
||||||
background: rgbToHex(s.backgroundColor),
|
background: rgbToHex(s.backgroundColor),
|
||||||
|
backgroundImage: gradientOf(s.backgroundImage),
|
||||||
|
backdropFilter: bf === "none" ? "" : bf,
|
||||||
padding: s.padding,
|
padding: s.padding,
|
||||||
borderRadius: s.borderRadius,
|
borderRadius: s.borderRadius,
|
||||||
border: s.border,
|
border: s.border,
|
||||||
@@ -111,23 +121,37 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Buttons ──
|
// ── 2. Buttons ──
|
||||||
|
// a page's primary CTA is very often a FILLED pill in the nav/header ("Sign up", "Start for free").
|
||||||
|
// Old code dropped everything under <nav>, losing that CTA; keep nav elements that carry a solid
|
||||||
|
// fill (a real button), still dropping plain nav text links.
|
||||||
|
var isFilledEl = (el) => {
|
||||||
|
var cs = getComputedStyle(el);
|
||||||
|
var bg = cs.backgroundColor;
|
||||||
|
var solid = !!bg && bg !== "transparent" && !/rgba?\\([^)]*,\\s*0\\s*\\)/.test(bg);
|
||||||
|
// a gradient-filled CTA (e.g. Snowflake's blue pill = background: var(--ui-background-03)) has a
|
||||||
|
// transparent background-COLOR but a gradient background-IMAGE — count it as filled too.
|
||||||
|
return solid || (cs.backgroundImage || "").indexOf("gradient") >= 0;
|
||||||
|
};
|
||||||
var buttonEls = Array.from(document.querySelectorAll(
|
var buttonEls = Array.from(document.querySelectorAll(
|
||||||
'button, a[class*="btn"], a[class*="button"], a[role="button"], ' +
|
'button, a[class*="btn"], a[class*="button"], a[role="button"], ' +
|
||||||
'[class*="btn-"], [class*="button-"], [class*="cta"]'
|
'[class*="btn-"], [class*="button-"], [class*="cta"]'
|
||||||
)).filter(function(el) {
|
)).filter(function(el) {
|
||||||
return isVisible(el) && !el.closest('nav, [role="navigation"]');
|
if (!isVisible(el)) return false;
|
||||||
}).slice(0, 10);
|
return el.closest('nav, [role="navigation"]') ? isFilledEl(el) : true;
|
||||||
|
}).slice(0, 16);
|
||||||
|
|
||||||
var buttonMap = {};
|
var buttonMap = {};
|
||||||
for (var bi = 0; bi < buttonEls.length; bi++) {
|
for (var bi = 0; bi < buttonEls.length; bi++) {
|
||||||
var bs = getStyles(buttonEls[bi]);
|
var bs = getStyles(buttonEls[bi]);
|
||||||
// Deduplicate by visual appearance
|
// Deduplicate by visual appearance (gradient fill kept distinct so a gradient CTA survives)
|
||||||
var bKey = bs.background + "|" + bs.borderRadius + "|" + bs.border;
|
var bKey = bs.background + "|" + bs.backgroundImage + "|" + bs.borderRadius + "|" + bs.border;
|
||||||
if (!buttonMap[bKey]) {
|
if (!buttonMap[bKey]) {
|
||||||
var btnText = (buttonEls[bi].textContent || "").trim().slice(0, 40);
|
var btnText = (buttonEls[bi].textContent || "").trim().slice(0, 40);
|
||||||
buttonMap[bKey] = {
|
buttonMap[bKey] = {
|
||||||
label: btnText || "button",
|
label: btnText || "button",
|
||||||
background: bs.background,
|
background: bs.background,
|
||||||
|
backgroundImage: bs.backgroundImage,
|
||||||
|
backdropFilter: bs.backdropFilter,
|
||||||
color: bs.color,
|
color: bs.color,
|
||||||
padding: bs.padding,
|
padding: bs.padding,
|
||||||
borderRadius: bs.borderRadius,
|
borderRadius: bs.borderRadius,
|
||||||
@@ -139,7 +163,7 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var buttons = Object.values(buttonMap).slice(0, 4);
|
var buttons = Object.values(buttonMap).slice(0, 6);
|
||||||
|
|
||||||
// ── 3. Cards / containers ──
|
// ── 3. Cards / containers ──
|
||||||
var cardEls = Array.from(document.querySelectorAll(
|
var cardEls = Array.from(document.querySelectorAll(
|
||||||
@@ -154,11 +178,14 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
|||||||
var cardMap = {};
|
var cardMap = {};
|
||||||
for (var ci = 0; ci < cardEls.length; ci++) {
|
for (var ci = 0; ci < cardEls.length; ci++) {
|
||||||
var cs = getStyles(cardEls[ci]);
|
var cs = getStyles(cardEls[ci]);
|
||||||
var cKey = cs.background + "|" + cs.borderRadius + "|" + cs.border;
|
// gradient fill + glass blur kept in the key so a gradient/frosted card is a distinct variant
|
||||||
|
var cKey = cs.background + "|" + cs.backgroundImage + "|" + cs.backdropFilter + "|" + cs.borderRadius + "|" + cs.border;
|
||||||
if (!cardMap[cKey]) {
|
if (!cardMap[cKey]) {
|
||||||
cardMap[cKey] = {
|
cardMap[cKey] = {
|
||||||
label: "card",
|
label: "card",
|
||||||
background: cs.background,
|
background: cs.background,
|
||||||
|
backgroundImage: cs.backgroundImage,
|
||||||
|
backdropFilter: cs.backdropFilter,
|
||||||
color: cs.color,
|
color: cs.color,
|
||||||
padding: cs.padding,
|
padding: cs.padding,
|
||||||
borderRadius: cs.borderRadius,
|
borderRadius: cs.borderRadius,
|
||||||
@@ -180,6 +207,8 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
|||||||
nav = {
|
nav = {
|
||||||
label: "navigation",
|
label: "navigation",
|
||||||
background: ns.background,
|
background: ns.background,
|
||||||
|
backgroundImage: ns.backgroundImage,
|
||||||
|
backdropFilter: ns.backdropFilter,
|
||||||
color: ns.color,
|
color: ns.color,
|
||||||
padding: ns.padding,
|
padding: ns.padding,
|
||||||
borderRadius: ns.borderRadius,
|
borderRadius: ns.borderRadius,
|
||||||
@@ -363,6 +392,90 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
|||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map(function(e) { return { value: e[0], count: e[1] }; });
|
.map(function(e) { return { value: e[0], count: e[1] }; });
|
||||||
|
|
||||||
|
// ── 8. Dominant gradient / mesh backgrounds ──
|
||||||
|
// A site's signature color wash (Stripe/ElevenLabs/Snowflake) lives in gradient background-images
|
||||||
|
// on large blocks — often on a pseudo-element (::before glow orbs) rather than the block itself.
|
||||||
|
// Weight each distinct gradient by the total on-screen area it covers; return the top few.
|
||||||
|
var gradientArea = {};
|
||||||
|
var gradSamples = Array.from(document.querySelectorAll(
|
||||||
|
"body, main, section, header, div, [class*='hero'], [class*='gradient'], [class*='bg'], [class*='background']"
|
||||||
|
)).slice(0, 400);
|
||||||
|
|
||||||
|
// max stop chroma (max−min RGB) of a gradient: a vivid brand wash scores high, a neutral
|
||||||
|
// white/cream scrim ~0. Used to rank washes so a small vivid gradient beats a big grey scrim.
|
||||||
|
function gradientChroma(g) {
|
||||||
|
var max = 0, re = /rgba?\\(\\s*(\\d+)[,\\s]+(\\d+)[,\\s]+(\\d+)/g, m;
|
||||||
|
while ((m = re.exec(g))) {
|
||||||
|
var r = +m[1], gr = +m[2], b = +m[3];
|
||||||
|
var c = Math.max(r, gr, b) - Math.min(r, gr, b);
|
||||||
|
if (c > max) max = c;
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGradient(val, area) {
|
||||||
|
var g = gradientOf(val);
|
||||||
|
if (!g || area < 20000) return; // ignore tiny decorative gradients
|
||||||
|
var norm = g.replace(/\\s+/g, " ");
|
||||||
|
gradientArea[norm] = (gradientArea[norm] || 0) + area;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var gi = 0; gi < gradSamples.length; gi++) {
|
||||||
|
var gel = gradSamples[gi];
|
||||||
|
if (!isVisible(gel)) continue;
|
||||||
|
var grect = gel.getBoundingClientRect();
|
||||||
|
var garea = grect.width * grect.height;
|
||||||
|
if (garea < 20000) continue;
|
||||||
|
addGradient(getComputedStyle(gel).backgroundImage, garea);
|
||||||
|
addGradient(getComputedStyle(gel, "::before").backgroundImage, garea);
|
||||||
|
addGradient(getComputedStyle(gel, "::after").backgroundImage, garea);
|
||||||
|
}
|
||||||
|
|
||||||
|
// rank by chroma-weighted area so the brand's signature color wash outranks a larger neutral scrim
|
||||||
|
var backgrounds = Object.entries(gradientArea)
|
||||||
|
.map(function(e) { return { value: e[0], area: Math.round(e[1]), score: e[1] * (1 + 2 * gradientChroma(e[0]) / 255) }; })
|
||||||
|
.sort(function(a, b) { return b.score - a.score; })
|
||||||
|
.slice(0, 6)
|
||||||
|
.map(function(e) { return { value: e.value, area: e.area }; });
|
||||||
|
|
||||||
|
// ── 9. Frosted-glass panels (backdrop-filter) ──
|
||||||
|
// A defining material on modern hero UIs (HeyGen's prompt box, Stripe's floating chrome): a
|
||||||
|
// translucent surface with a backdrop blur. Capture the RAW fill (rgba/gradient — alpha preserved,
|
||||||
|
// unlike rgbToHex) + the blur, ranked by area. This is what lets a frame render a real frosted card.
|
||||||
|
var glassSamples = Array.from(document.querySelectorAll(
|
||||||
|
"div, section, header, nav, aside, [class*='card'], [class*='panel'], [class*='glass'], [class*='blur'], [class*='modal'], [class*='overlay'], [class*='input']"
|
||||||
|
)).slice(0, 400);
|
||||||
|
var glassByKey = {};
|
||||||
|
for (var qi = 0; qi < glassSamples.length; qi++) {
|
||||||
|
var qel = glassSamples[qi];
|
||||||
|
if (!isVisible(qel)) continue;
|
||||||
|
var qs = getComputedStyle(qel);
|
||||||
|
var bf = qs.backdropFilter || qs.webkitBackdropFilter || "";
|
||||||
|
if (!bf || bf === "none" || bf.indexOf("blur") < 0) continue;
|
||||||
|
var qrect = qel.getBoundingClientRect();
|
||||||
|
var qarea = qrect.width * qrect.height;
|
||||||
|
if (qarea < 8000) continue; // ignore tiny blurred chips
|
||||||
|
// raw fill with alpha intact: prefer a translucent gradient, else the rgba background-color
|
||||||
|
var gi2 = qs.backgroundImage;
|
||||||
|
var rawFill = gi2 && gi2.indexOf("gradient") >= 0 ? gi2.replace(/\\s+/g, " ").trim() : qs.backgroundColor;
|
||||||
|
var key = bf + "|" + rawFill + "|" + qs.borderRadius;
|
||||||
|
if (!glassByKey[key]) {
|
||||||
|
glassByKey[key] = {
|
||||||
|
backdropFilter: bf,
|
||||||
|
background: rawFill,
|
||||||
|
border: qs.border,
|
||||||
|
borderRadius: qs.borderRadius,
|
||||||
|
boxShadow: qs.boxShadow === "none" ? "" : qs.boxShadow,
|
||||||
|
area: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
glassByKey[key].area += qarea;
|
||||||
|
}
|
||||||
|
var glass = Object.values(glassByKey)
|
||||||
|
.sort(function(a, b) { return b.area - a.area; })
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(function(g) { return { backdropFilter: g.backdropFilter, background: g.background, border: g.border, borderRadius: g.borderRadius, boxShadow: g.boxShadow, area: Math.round(g.area) }; });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
typography: uniqueTypo,
|
typography: uniqueTypo,
|
||||||
spacing: { observed: observedSpacing.slice(0, 15), baseUnit: baseUnit },
|
spacing: { observed: observedSpacing.slice(0, 15), baseUnit: baseUnit },
|
||||||
@@ -373,7 +486,9 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
|||||||
nav: nav,
|
nav: nav,
|
||||||
chips: chips,
|
chips: chips,
|
||||||
statCells: statCells,
|
statCells: statCells,
|
||||||
tabs: tabs
|
tabs: tabs,
|
||||||
|
backgrounds: backgrounds,
|
||||||
|
glass: glass
|
||||||
};
|
};
|
||||||
})()`;
|
})()`;
|
||||||
|
|
||||||
|
|||||||
@@ -179,6 +179,10 @@ export interface TypographyRole {
|
|||||||
export interface ComponentStyle {
|
export interface ComponentStyle {
|
||||||
label: string;
|
label: string;
|
||||||
background: string;
|
background: string;
|
||||||
|
/** Gradient background-image if any (url()/none dropped) — a core brand signal */
|
||||||
|
backgroundImage?: string;
|
||||||
|
/** backdrop-filter value if any (frosted-glass panels); "" when none */
|
||||||
|
backdropFilter?: string;
|
||||||
color: string;
|
color: string;
|
||||||
padding: string;
|
padding: string;
|
||||||
borderRadius: string;
|
borderRadius: string;
|
||||||
@@ -217,6 +221,17 @@ export interface DesignStyles {
|
|||||||
statCells?: StatCellStyle[];
|
statCells?: StatCellStyle[];
|
||||||
/** tab controls */
|
/** tab controls */
|
||||||
tabs?: ComponentStyle[];
|
tabs?: ComponentStyle[];
|
||||||
|
/** Dominant gradient/mesh background washes, ranked by on-screen area covered */
|
||||||
|
backgrounds?: Array<{ value: string; area: number }>;
|
||||||
|
/** Frosted-glass panels (backdrop-filter): raw translucent fill + blur, ranked by area */
|
||||||
|
glass?: Array<{
|
||||||
|
backdropFilter: string;
|
||||||
|
background: string;
|
||||||
|
border: string;
|
||||||
|
borderRadius: string;
|
||||||
|
boxShadow: string;
|
||||||
|
area: number;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Assets ──────────────────────────────────────────────────────────────────
|
// ── Assets ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user