Hover capture via forced pseudo-state, text-wrap support, per-viewport video source re-selection, svg paint recovery, semantic tokens & section naming
Capture: - Hover/focus states are driven with CDP CSS.forcePseudoState instead of moving the real cursor - transparent full-viewport overlay layers were swallowing every pointer hover, silently capturing zero hover states on hover-rich sites - text-wrap (balance/pretty) captured and emitted (Tailwind text-balance/ text-pretty, arbitrary fallback) - Videos re-run <source> selection per viewport before frame-0 normalization: resize-without-reload kept aspect-gated variants stuck on the load-time choice, poisoning mobile ground-truth screenshots - svg roots capture their computed paint; fill="none" with a computed paint (the fill-current pattern) recovers the real color instead of rendering blank Tokens & naming: - Full CSS-Color-4 parsing (oklab/oklch/lab/lch/hsl) so modern colors cluster and earn semantic roles; visually-equal literals share one token; decoration/gradient/shadow colors consult the palette before minting opaque tokens (ridge: 45 opaque tokens -> 15, anthropic: 12 -> 7) - Expanded role vocabulary (background/foreground/primary/accent/border/ surface/muted) with deterministic tiebreaks and a chroma guard - Section names mine CMS section ids and js-* hooks with hashy-suffix stripping (split_callout_JtTWTt -> split-callout-section) 364 tests pass (42 new), typecheck clean; determinism verified by double-regen byte-comparison on two reference runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
db0054e43b
commit
6e4921884c
@@ -182,6 +182,41 @@ export function looksLikeVideoFile(bytes: Buffer): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `<source>` candidate for the HTML media resource-selection algorithm. `media`/`type` are the
|
||||||
|
* raw attribute strings (null/undefined when absent, matching a missing attribute).
|
||||||
|
*/
|
||||||
|
export interface VideoSourceCandidate {
|
||||||
|
media?: string | null;
|
||||||
|
type?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure re-implementation of the source-selection step of the HTML media resource-selection
|
||||||
|
* algorithm: return the index of the FIRST `<source>` (document order) that is eligible NOW, or -1
|
||||||
|
* when none is. A source is eligible when its `media` query matches (a missing/empty media matches
|
||||||
|
* unconditionally) AND the UA can play its `type` (a missing/empty type is not a disqualifier —
|
||||||
|
* `canPlayType` is only consulted when a type is present).
|
||||||
|
*
|
||||||
|
* The predicates are injected so this is testable in Node (the in-page caller passes the real
|
||||||
|
* `matchMedia`/`canPlayType`). Kept deterministic: no state, first-match wins in document order.
|
||||||
|
*/
|
||||||
|
export function selectVideoSourceIndex(
|
||||||
|
sources: VideoSourceCandidate[],
|
||||||
|
mediaMatches: (media: string) => boolean,
|
||||||
|
canPlay: (type: string) => boolean,
|
||||||
|
): number {
|
||||||
|
for (let i = 0; i < sources.length; i++) {
|
||||||
|
const s = sources[i]!;
|
||||||
|
const media = (s.media ?? "").trim();
|
||||||
|
if (media && !mediaMatches(media)) continue;
|
||||||
|
const type = (s.type ?? "").trim();
|
||||||
|
if (type && !canPlay(type)) continue;
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
async function autoScroll(page: import("playwright").Page, vpHeight: number): Promise<void> {
|
async function autoScroll(page: import("playwright").Page, vpHeight: number): Promise<void> {
|
||||||
// Scroll through the page to trigger lazy-loaded images/backgrounds, then return
|
// Scroll through the page to trigger lazy-loaded images/backgrounds, then return
|
||||||
// to the top so document-coordinate bboxes are measured from a settled layout.
|
// to the top so document-coordinate bboxes are measured from a settled layout.
|
||||||
@@ -804,12 +839,57 @@ async function captureScreenshot(
|
|||||||
* diff that no CSS change can close. Seeking BOTH sides to frame 0 before EVERY viewport screenshot
|
* diff that no CSS change can close. Seeking BOTH sides to frame 0 before EVERY viewport screenshot
|
||||||
* removes it. `seeked` fires once the new frame is decoded; we still yield two rAFs so the compositor
|
* removes it. `seeked` fires once the new frame is decoded; we still yield two rAFs so the compositor
|
||||||
* has painted it before the screenshot reads pixels. Bounded so a stalled/unseekable video can't hang.
|
* has painted it before the screenshot reads pixels. Bounded so a stalled/unseekable video can't hang.
|
||||||
|
*
|
||||||
|
* FIRST it re-runs `<source media>` resource selection for THIS viewport. The capture pipeline loads
|
||||||
|
* the page once at the canonical width and reaches every other viewport by resizing — but the HTML
|
||||||
|
* media resource-selection algorithm evaluates `<source media>` only at load, so an aspect/orientation
|
||||||
|
* -gated hero video stays frozen on whatever variant matched at load width across all resized shots.
|
||||||
|
* The validator fresh-loads per viewport and correctly re-selects, so the two channels disagree on
|
||||||
|
* which variant is on screen (a large phantom diff on full-bleed portrait/landscape hero videos).
|
||||||
|
* Re-selecting here keeps the channels symmetric; it is a no-op on the fresh-loading validator side
|
||||||
|
* and — the common case — a no-op whenever the current selection is still the right one, so the fast
|
||||||
|
* path never touches `video.load()`.
|
||||||
*/
|
*/
|
||||||
export async function normalizeVideoTime(page: import("playwright").Page): Promise<void> {
|
export async function normalizeVideoTime(page: import("playwright").Page): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const vids = Array.from(document.querySelectorAll("video"));
|
const vids = Array.from(document.querySelectorAll("video"));
|
||||||
const raf = () => new Promise<void>((r) => requestAnimationFrame(() => r()));
|
const raf = () => new Promise<void>((r) => requestAnimationFrame(() => r()));
|
||||||
|
|
||||||
|
// Re-select the <source> the resource-selection algorithm would pick at the CURRENT viewport,
|
||||||
|
// mirroring selectVideoSourceIndex (Node-side, unit-tested): first source in document order
|
||||||
|
// whose media matches (missing media matches unconditionally) and whose type is playable
|
||||||
|
// (missing type is never disqualifying). Only reload when the winner differs from what is
|
||||||
|
// currently loaded — the fast path leaves untouched videos alone.
|
||||||
|
const reloadWaits: Promise<void>[] = [];
|
||||||
|
for (const v of vids) {
|
||||||
|
try {
|
||||||
|
const sources = Array.from(v.querySelectorAll("source"));
|
||||||
|
if (sources.length === 0) continue; // src attribute (no <source> children): nothing to re-select
|
||||||
|
let winner: HTMLSourceElement | null = null;
|
||||||
|
for (const s of sources) {
|
||||||
|
const media = (s.getAttribute("media") ?? "").trim();
|
||||||
|
if (media && !window.matchMedia(media).matches) continue;
|
||||||
|
const type = (s.getAttribute("type") ?? "").trim();
|
||||||
|
if (type && !v.canPlayType(type)) continue;
|
||||||
|
winner = s;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!winner || !winner.src) continue;
|
||||||
|
// Compare resolved URLs; currentSrc is absolute, winner.src is already resolved.
|
||||||
|
if (v.currentSrc === winner.src) continue; // no change — fast path, do not reload
|
||||||
|
v.load();
|
||||||
|
reloadWaits.push(new Promise<void>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const done = () => { if (settled) return; settled = true; v.removeEventListener("loadeddata", done); resolve(); };
|
||||||
|
if (v.readyState >= 2 /* HAVE_CURRENT_DATA */) { done(); return; }
|
||||||
|
v.addEventListener("loadeddata", done, { once: true });
|
||||||
|
setTimeout(done, 4000); // bound: a stalled reload resolves anyway so the shot never hangs
|
||||||
|
}));
|
||||||
|
} catch { /* a malformed <source>/media query must not block the others */ }
|
||||||
|
}
|
||||||
|
if (reloadWaits.length) await Promise.all(reloadWaits);
|
||||||
|
|
||||||
const waits: Promise<void>[] = [];
|
const waits: Promise<void>[] = [];
|
||||||
for (const v of vids) {
|
for (const v of vids) {
|
||||||
try { v.pause(); } catch { /* ignore */ }
|
try { v.pause(); } catch { /* ignore */ }
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ const PSEUDO_PROPS = [
|
|||||||
|
|
||||||
export type StyleDelta = Record<string, string>;
|
export type StyleDelta = Record<string, string>;
|
||||||
|
|
||||||
|
/** Changed-properties delta: every key in `b` whose value differs from `a`. Pure (unit-tested). */
|
||||||
|
export function diffStyle(a: StyleDelta, b: StyleDelta): StyleDelta {
|
||||||
|
const d: StyleDelta = {};
|
||||||
|
for (const k of Object.keys(b)) if (a[k] !== b[k]) d[k] = b[k]!;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
// Properties that distinguish a panel's shown/hidden state and a trigger's
|
// Properties that distinguish a panel's shown/hidden state and a trigger's
|
||||||
// active/inactive state. Captured per discrete state so the generated controller
|
// active/inactive state. Captured per discrete state so the generated controller
|
||||||
// can toggle between them faithfully (M2: tabs + accordion).
|
// can toggle between them faithfully (M2: tabs + accordion).
|
||||||
@@ -363,11 +370,42 @@ export async function captureInteractions(page: Page, opts?: { maxCandidates?: n
|
|||||||
return o;
|
return o;
|
||||||
}, { capId, props: PSEUDO_PROPS as unknown as string[] });
|
}, { capId, props: PSEUDO_PROPS as unknown as string[] });
|
||||||
|
|
||||||
const diff = (a: StyleDelta, b: StyleDelta): StyleDelta => {
|
const diff = diffStyle;
|
||||||
const d: StyleDelta = {};
|
|
||||||
for (const k of Object.keys(b)) if (a[k] !== b[k]) d[k] = b[k]!;
|
// Pseudo-state driver via CDP `CSS.forcePseudoState`. Pointer-based hovering (page.hover)
|
||||||
return d;
|
// moves the REAL cursor to the element's box centre, so the browser applies `:hover` to
|
||||||
|
// whatever sits under that point — on a page with a transparent full-viewport overlay (a
|
||||||
|
// fixed page-wrapper / scroll layer, common on modern builder stacks) the point lands on the
|
||||||
|
// overlay and the target never enters `:hover`, silently capturing ZERO authored hover states
|
||||||
|
// even though page.hover throws nothing. Forcing the pseudo-class on the node itself is
|
||||||
|
// geometry-independent (occlusion-immune), applies pure-CSS `:hover` rules directly, and — for
|
||||||
|
// `.card:hover .overlay` — also styles descendants of the forced node, so reveals still show.
|
||||||
|
// Resolves each cap → CDP nodeId; a live map is rebuilt per element (cheap) so it survives
|
||||||
|
// any DOM churn. Falls back to no forcing if a CDP session can't be opened (then hover/focus
|
||||||
|
// capture is simply empty rather than wrong).
|
||||||
|
const client = await page.context().newCDPSession(page).catch(() => null);
|
||||||
|
if (client) {
|
||||||
|
try { await client.send("DOM.enable"); await client.send("CSS.enable"); await client.send("DOM.getDocument", {}); }
|
||||||
|
catch { /* CDP unavailable — force() below no-ops */ }
|
||||||
|
}
|
||||||
|
const nodeIdFor = async (capId: string): Promise<number | null> => {
|
||||||
|
if (!client) return null;
|
||||||
|
let objectId: string | undefined;
|
||||||
|
try {
|
||||||
|
const ev = await client.send("Runtime.evaluate", { expression: `document.querySelector('[data-cid-cap="${capId}"]')` }) as { result?: { objectId?: string } };
|
||||||
|
objectId = ev.result?.objectId; if (!objectId) return null;
|
||||||
|
const r = await client.send("DOM.requestNode", { objectId }) as { nodeId?: number };
|
||||||
|
return r.nodeId ?? null;
|
||||||
|
} catch { return null; }
|
||||||
|
finally { if (objectId) await client.send("Runtime.releaseObject", { objectId }).catch(() => {}); }
|
||||||
};
|
};
|
||||||
|
// Force (or clear, with []) a pseudo-class on a node. Deterministic and reversible.
|
||||||
|
const force = async (nodeId: number | null, classes: string[]): Promise<boolean> => {
|
||||||
|
if (!client || nodeId == null) return false;
|
||||||
|
try { await client.send("CSS.forcePseudoState", { nodeId, forcedPseudoClasses: classes }); return true; }
|
||||||
|
catch { return false; }
|
||||||
|
};
|
||||||
|
const settlePseudo = () => page.waitForTimeout(180); // let a `:hover`/`:focus` transition land
|
||||||
|
|
||||||
// Reveal-relevant props for descendants that appear on hover (overlay glow + CTA). Only
|
// Reveal-relevant props for descendants that appear on hover (overlay glow + CTA). Only
|
||||||
// clean show/hide props — NOT transform/filter, whose mid-animation values bake a janky
|
// clean show/hide props — NOT transform/filter, whose mid-animation values bake a janky
|
||||||
@@ -413,21 +451,21 @@ export async function captureInteractions(page: Page, opts?: { maxCandidates?: n
|
|||||||
const hoverDesc: Record<string, Record<string, StyleDelta>> = {};
|
const hoverDesc: Record<string, Record<string, StyleDelta>> = {};
|
||||||
|
|
||||||
for (const capId of candidates) {
|
for (const capId of candidates) {
|
||||||
const sel = `[data-cid-cap="${capId}"]`;
|
|
||||||
const base = await read(capId);
|
const base = await read(capId);
|
||||||
if (!base) continue;
|
if (!base) continue;
|
||||||
|
const nodeId = await nodeIdFor(capId);
|
||||||
const hiddenBase = await readHiddenDesc(capId); // hidden descendants → hover-reveal candidates
|
const hiddenBase = await readHiddenDesc(capId); // hidden descendants → hover-reveal candidates
|
||||||
const hiddenCaps = Object.keys(hiddenBase);
|
const hiddenCaps = Object.keys(hiddenBase);
|
||||||
// :hover — settle before reading: framer-style hover is driven by JS (Framer Motion)
|
// :hover — force the pseudo-class on the node (geometry-independent; see `force` above),
|
||||||
// with a short transition, so an immediate read catches the pre-transition frame and
|
// then settle before reading: an authored `:hover`/JS transition animates from the resting
|
||||||
// sees no delta (the nav-link hover was missed this way). Wait for the transition to land.
|
// frame, so an immediate read catches the pre-transition value and sees no delta. Wait for it.
|
||||||
try {
|
if (await force(nodeId, ["hover"])) {
|
||||||
await page.hover(sel, { timeout: 1200, force: true });
|
await settlePseudo();
|
||||||
await page.waitForTimeout(180);
|
|
||||||
const h = await read(capId);
|
const h = await read(capId);
|
||||||
if (h) { const d = diff(base, h); if (Object.keys(d).length) hover[capId] = d; }
|
if (h) { const d = diff(base, h); if (Object.keys(d).length) hover[capId] = d; }
|
||||||
// Descendant reveals (while still hovered): a hidden child shown on hover — the card's
|
// Descendant reveals (while still hovered): a hidden child shown on hover — the card's
|
||||||
// OWN style is unchanged, so this is the only signal (framer's "Read story" overlay).
|
// OWN style is unchanged, so this is the only signal (a "Read story" overlay). Forcing
|
||||||
|
// `:hover` on the card also matches `.card:hover .overlay` rules on its descendants.
|
||||||
if (hiddenCaps.length) {
|
if (hiddenCaps.length) {
|
||||||
await page.waitForTimeout(380); // let the reveal transition fully finish before reading
|
await page.waitForTimeout(380); // let the reveal transition fully finish before reading
|
||||||
const after = await readDescProps(hiddenCaps);
|
const after = await readDescProps(hiddenCaps);
|
||||||
@@ -444,16 +482,17 @@ export async function captureInteractions(page: Page, opts?: { maxCandidates?: n
|
|||||||
}
|
}
|
||||||
if (Object.keys(revealed).length) hoverDesc[capId] = revealed;
|
if (Object.keys(revealed).length) hoverDesc[capId] = revealed;
|
||||||
}
|
}
|
||||||
} catch { /* not hoverable (covered/offscreen) — skip */ }
|
await force(nodeId, []); // clear :hover before probing :focus
|
||||||
await page.mouse.move(1, 1).catch(() => {});
|
}
|
||||||
// :focus (only for focusable-ish; cheap to attempt, guarded)
|
// :focus — force the pseudo-class the same way (also occlusion-independent).
|
||||||
try {
|
if (await force(nodeId, ["focus"])) {
|
||||||
await page.focus(sel, { timeout: 800 });
|
await settlePseudo();
|
||||||
const f = await read(capId);
|
const f = await read(capId);
|
||||||
if (f) { const d = diff(base, f); if (Object.keys(d).length) focus[capId] = d; }
|
if (f) { const d = diff(base, f); if (Object.keys(d).length) focus[capId] = d; }
|
||||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
|
await force(nodeId, []); // restore the resting state
|
||||||
} catch { /* not focusable — skip */ }
|
}
|
||||||
}
|
}
|
||||||
|
await client?.detach().catch(() => {});
|
||||||
|
|
||||||
log({ event: "interactions_hover_focus", candidates: candidates.length, hover: Object.keys(hover).length, focus: Object.keys(focus).length, hoverDesc: Object.keys(hoverDesc).length });
|
log({ event: "interactions_hover_focus", candidates: candidates.length, hover: Object.keys(hover).length, focus: Object.keys(focus).length, hoverDesc: Object.keys(hoverDesc).length });
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ export type RawNode = {
|
|||||||
// clone renders the browser's default gray, losing the authored placeholder color/type.
|
// clone renders the browser's default gray, losing the authored placeholder color/type.
|
||||||
placeholder?: RawStyle;
|
placeholder?: RawStyle;
|
||||||
rawHTML?: string; // set for inline <svg>
|
rawHTML?: string; // set for inline <svg>
|
||||||
|
// Computed paint of an inline <svg> root (fill/stroke/color). A raw `fill="none"` attribute may
|
||||||
|
// still resolve to a real paint via site CSS (`fill: currentColor`); these resolved values let
|
||||||
|
// codegen recover a paint the extraction stripped. Set only for svg roots.
|
||||||
|
svgPaint?: { fill: string; stroke: string; color: string };
|
||||||
children: RawChild[];
|
children: RawChild[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,6 +142,11 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
|||||||
"letterSpacing", "wordSpacing", "textAlign", "textTransform",
|
"letterSpacing", "wordSpacing", "textAlign", "textTransform",
|
||||||
"textDecorationLine", "textDecorationColor", "textDecorationStyle",
|
"textDecorationLine", "textDecorationColor", "textDecorationStyle",
|
||||||
"whiteSpace", "wordBreak", "overflowWrap", "textOverflow", "textIndent",
|
"whiteSpace", "wordBreak", "overflowWrap", "textOverflow", "textIndent",
|
||||||
|
// Modern line-wrapping: `text-wrap: balance/pretty` rebalances heading line breaks
|
||||||
|
// (getComputedStyle reports the shorthand — "balance"/"pretty"/"wrap"). Without it a
|
||||||
|
// balanced heading wraps differently in the clone (an even two-line title collapses
|
||||||
|
// to a lopsided break). Default "wrap" is elided downstream.
|
||||||
|
"textWrap",
|
||||||
"textShadow", "fontVariantCaps", "fontFeatureSettings",
|
"textShadow", "fontVariantCaps", "fontFeatureSettings",
|
||||||
// Line clamping (`display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:N`):
|
// Line clamping (`display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:N`):
|
||||||
// the mechanism that keeps cards equal height regardless of text length. Without it the engine
|
// the mechanism that keeps cards equal height regardless of text length. Without it the engine
|
||||||
@@ -464,6 +473,14 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
|||||||
// Inline SVG → raw markup, no recursion.
|
// Inline SVG → raw markup, no recursion.
|
||||||
if (tag === "svg") {
|
if (tag === "svg") {
|
||||||
node.rawHTML = el.outerHTML;
|
node.rawHTML = el.outerHTML;
|
||||||
|
// Capture the svg root's COMPUTED paint (fill/stroke/color) separately from the general
|
||||||
|
// computed-prop list. A raw `fill="none"` presentation attribute paints nothing on its own; a
|
||||||
|
// wordmark/icon is visible on the source only because site CSS (a `fill: currentColor` class,
|
||||||
|
// an inherited `color`) overrides it. Extraction strips that CSS, so the raw attribute alone is
|
||||||
|
// misleading — codegen consults these resolved values to decide whether the root truly paints.
|
||||||
|
try {
|
||||||
|
node.svgPaint = { fill: cs.fill, stroke: cs.stroke, color: cs.color };
|
||||||
|
} catch { /* getComputedStyle already read above; guard against exotic UAs */ }
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -401,18 +401,34 @@ export function propsList(node: IRNode, assetMap: Map<string, string>, sourceUrl
|
|||||||
// surface verbatim in the markup and (post-extraction) as a bogus data field.
|
// surface verbatim in the markup and (post-extraction) as a bogus data field.
|
||||||
const inner = svgInnerForNode(node, ctx);
|
const inner = svgInnerForNode(node, ctx);
|
||||||
const svgAttrs = extractSvgAttrs(node.rawHTML);
|
const svgAttrs = extractSvgAttrs(node.rawHTML);
|
||||||
let hasFillAttr = false;
|
let rawFill: string | undefined;
|
||||||
for (const [k, v] of svgAttrs) {
|
for (const [k, v] of svgAttrs) {
|
||||||
if (k.toLowerCase() === "fill") hasFillAttr = true;
|
if (k.toLowerCase() === "fill") { rawFill = v; continue; } // the root fill is resolved below, not copied verbatim
|
||||||
if (k === "class" || k === "style" || k === "data-cid-cap" || k.includes(":")) continue;
|
if (k === "class" || k === "style" || k === "data-cid-cap" || k.includes(":")) continue;
|
||||||
const reactName = SVG_ATTR_RENAME[k] ?? k;
|
const reactName = SVG_ATTR_RENAME[k] ?? k;
|
||||||
const propKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(reactName) ? reactName : JSON.stringify(reactName);
|
const propKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(reactName) ? reactName : JSON.stringify(reactName);
|
||||||
if (!props.some(([pk]) => pk === propKey)) props.push([propKey, JSON.stringify(v)]);
|
if (!props.some(([pk]) => pk === propKey)) props.push([propKey, JSON.stringify(v)]);
|
||||||
}
|
}
|
||||||
// Raw SVG icons often rely on site CSS (`svg { fill: currentColor }` or a class) that is
|
// Reconcile the root fill against the captured COMPUTED paint. A raw `fill="none"` only looks
|
||||||
// stripped during extraction. If the root didn't declare a fill, inherit the surrounding text
|
// unfilled: site CSS (`fill: currentColor` on the wordmark, an inherited color) may actually
|
||||||
// color so monochrome wordmarks/icons don't fall back to the browser's black default.
|
// paint it, and that CSS is stripped during extraction. resolveSvgRootFill recovers the real
|
||||||
if (!hasFillAttr && !props.some(([pk]) => pk === "fill")) props.push(["fill", JSON.stringify("currentColor")]);
|
// paint — as currentColor when the computed fill tracks the element's color (emit that color
|
||||||
|
// too), else the literal value — and leaves a genuinely unfilled svg as fill="none". When the
|
||||||
|
// root declared no fill at all we fall back to currentColor so monochrome icons don't drop to
|
||||||
|
// the browser's black default.
|
||||||
|
if (!props.some(([pk]) => pk === "fill")) {
|
||||||
|
const resolved = resolveSvgRootFill(rawFill, node.svgPaint);
|
||||||
|
if (resolved.mode === "keep") {
|
||||||
|
if (rawFill !== undefined) props.push(["fill", JSON.stringify(rawFill)]);
|
||||||
|
} else if (resolved.mode === "emit") {
|
||||||
|
props.push(["fill", JSON.stringify(resolved.value!)]);
|
||||||
|
if (resolved.emitColor && !props.some(([pk]) => pk === "color")) {
|
||||||
|
props.push(["color", JSON.stringify(resolved.emitColor)]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
props.push(["fill", JSON.stringify("currentColor")]);
|
||||||
|
}
|
||||||
|
}
|
||||||
props.push(["dangerouslySetInnerHTML", `{ __html: ${JSON.stringify(inner)} }`]);
|
props.push(["dangerouslySetInnerHTML", `{ __html: ${JSON.stringify(inner)} }`]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,6 +476,50 @@ function extractSvgAttrs(outerHTML: string): Array<[string, string]> {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A resolved paint value is "real" (paints something) when it is neither absent nor an explicit
|
||||||
|
* non-paint (`none`) nor fully transparent. `currentColor` counts as real (it paints the inherited
|
||||||
|
* color). Case/whitespace-insensitive. */
|
||||||
|
export function isRealPaint(value: string | null | undefined): boolean {
|
||||||
|
const v = (value ?? "").trim().toLowerCase();
|
||||||
|
if (!v || v === "none" || v === "transparent") return false;
|
||||||
|
if (v === "rgba(0, 0, 0, 0)" || v === "rgba(0,0,0,0)") return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide what `fill` an inline <svg> ROOT should emit, reconciling the raw `fill` attribute against
|
||||||
|
* the svg's COMPUTED paint (captured from the live source). Pure and deterministic.
|
||||||
|
*
|
||||||
|
* - Raw `fill` present and itself a real paint → keep it (`keep`): the author meant that fill.
|
||||||
|
* - Raw `fill="none"` but the computed fill IS a real paint → the root only looked unfilled because
|
||||||
|
* `fill="none"` is a presentation attribute the site's CSS overrode. Recover the real paint:
|
||||||
|
* emit `currentColor` when the computed fill equals the computed `color` (a `fill: currentColor`
|
||||||
|
* class — the common wordmark case; caller must also emit `color`), else the literal computed fill.
|
||||||
|
* - Raw `fill="none"` and computed fill also not a real paint → genuinely unfilled: keep `none`.
|
||||||
|
* - Raw `fill` absent → `fallback`: caller applies its existing currentColor default.
|
||||||
|
*/
|
||||||
|
export function resolveSvgRootFill(
|
||||||
|
rawFill: string | null | undefined,
|
||||||
|
computed?: { fill?: string; color?: string } | null,
|
||||||
|
): { mode: "keep" | "fallback" | "emit"; value?: string; emitColor?: string } {
|
||||||
|
const raw = (rawFill ?? "").trim();
|
||||||
|
if (raw && raw.toLowerCase() !== "none") return { mode: "keep" };
|
||||||
|
if (raw.toLowerCase() === "none") {
|
||||||
|
const cf = computed?.fill;
|
||||||
|
if (isRealPaint(cf)) {
|
||||||
|
const color = (computed?.color ?? "").trim();
|
||||||
|
// fill:currentColor resolves to the element's color; recover it as currentColor so the clone
|
||||||
|
// tracks whatever color the surrounding CSS supplies, and signal the caller to emit that color.
|
||||||
|
if (color && cf!.trim().toLowerCase() === color.toLowerCase() && isRealPaint(color)) {
|
||||||
|
return { mode: "emit", value: "currentColor", emitColor: color };
|
||||||
|
}
|
||||||
|
return { mode: "emit", value: cf!.trim() };
|
||||||
|
}
|
||||||
|
return { mode: "keep" }; // genuinely unfilled — leave fill="none"
|
||||||
|
}
|
||||||
|
return { mode: "fallback" };
|
||||||
|
}
|
||||||
|
|
||||||
// SVG presentation attributes that React requires in camelCase (kebab in JSX is silently
|
// SVG presentation attributes that React requires in camelCase (kebab in JSX is silently
|
||||||
// dropped — and `fill-rule`/`clip-rule` being dropped changes how a path fills). Anything not
|
// dropped — and `fill-rule`/`clip-rule` being dropped changes how a path fills). Anything not
|
||||||
// here that's already camel/lowercase (d, cx, fill, viewBox, opacity, transform, offset…) or a
|
// here that's already camel/lowercase (d, cx, fill, viewBox, opacity, transform, offset…) or a
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ function isViewportHeight(value: string, width: number): boolean {
|
|||||||
const INHERITED = new Set([
|
const INHERITED = new Set([
|
||||||
"color", "fontFamily", "fontSize", "fontWeight", "fontStyle", "lineHeight",
|
"color", "fontFamily", "fontSize", "fontWeight", "fontStyle", "lineHeight",
|
||||||
"letterSpacing", "wordSpacing", "textAlign", "textTransform", "whiteSpace",
|
"letterSpacing", "wordSpacing", "textAlign", "textTransform", "whiteSpace",
|
||||||
"wordBreak", "overflowWrap", "textIndent", "fontVariantCaps", "fontFeatureSettings",
|
"wordBreak", "overflowWrap", "textWrap", "textIndent", "fontVariantCaps", "fontFeatureSettings",
|
||||||
"listStyleType", "listStylePosition", "writingMode", "direction", "cursor",
|
"listStyleType", "listStylePosition", "writingMode", "direction", "cursor",
|
||||||
"textShadow", "visibility", "textDecorationColor", "webkitTextStroke",
|
"textShadow", "visibility", "textDecorationColor", "webkitTextStroke",
|
||||||
"webkitTextFillColor",
|
"webkitTextFillColor",
|
||||||
@@ -139,6 +139,8 @@ const GENERIC: Array<{ prop: string; def: string | string[] }> = [
|
|||||||
{ prop: "textDecorationStyle", def: "solid" },
|
{ prop: "textDecorationStyle", def: "solid" },
|
||||||
{ prop: "whiteSpace", def: "__never__" }, { prop: "wordBreak", def: "__never__" },
|
{ prop: "whiteSpace", def: "__never__" }, { prop: "wordBreak", def: "__never__" },
|
||||||
{ prop: "overflowWrap", def: "__never__" }, { prop: "textIndent", def: "__never__" },
|
{ prop: "overflowWrap", def: "__never__" }, { prop: "textIndent", def: "__never__" },
|
||||||
|
// text-wrap: elide the initial `wrap`; emit `balance`/`pretty`/`nowrap`/`stable`.
|
||||||
|
{ prop: "textWrap", def: "wrap" },
|
||||||
{ prop: "textShadow", def: "__never__" }, { prop: "fontVariantCaps", def: "__never__" },
|
{ prop: "textShadow", def: "__never__" }, { prop: "fontVariantCaps", def: "__never__" },
|
||||||
{ prop: "fontFeatureSettings", def: "__never__" }, { prop: "listStyleType", def: "__never_list__" },
|
{ prop: "fontFeatureSettings", def: "__never__" }, { prop: "listStyleType", def: "__never_list__" },
|
||||||
{ prop: "listStylePosition", def: "__never__" }, { prop: "writingMode", def: "__never__" },
|
{ prop: "listStylePosition", def: "__never__" }, { prop: "writingMode", def: "__never__" },
|
||||||
|
|||||||
@@ -100,6 +100,83 @@ function identName(name: string): string {
|
|||||||
return /^[A-Za-z_$]/.test(name) ? name : "S" + name;
|
return /^[A-Za-z_$]/.test(name) ? name : "S" + name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generic structural words that carry no section identity — dropped from source-derived names.
|
||||||
|
const GENERIC_NAME_WORDS = new Set([
|
||||||
|
"section", "sections", "wrapper", "container", "block", "blocks", "template", "templates",
|
||||||
|
"group", "inner", "outer", "content", "contents", "row", "col", "column", "grid", "layout",
|
||||||
|
"shopify", "js", "tvg", "elementor", "wp", "widget", "module", "region", "main", "page",
|
||||||
|
"component", "components", "el", "root", "body", "area", "box", "item", "items", "wrap",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** A trailing token looks like a build hash (mixed-case or long alnum entropy, e.g.
|
||||||
|
* `JtTWTt`, `RbEALJ`, `x19f2a`, `dDMm2q`) rather than a real word — strip it. A token is
|
||||||
|
* hashy when it is ≥4 chars and either mixes upper+lower case or is a long digit-bearing run. */
|
||||||
|
function looksHashy(tok: string): boolean {
|
||||||
|
if (tok.length < 4) return false;
|
||||||
|
const hasUpper = /[A-Z]/.test(tok), hasLower = /[a-z]/.test(tok), hasDigit = /\d/.test(tok);
|
||||||
|
if (hasUpper && hasLower) return true; // camel/entropy hash: JtTWTt, dDMm2q
|
||||||
|
if (hasDigit && tok.length >= 6) return true; // long id run: 19797275672650
|
||||||
|
if (!/[aeiou]/i.test(tok) && tok.length >= 5) return true; // vowelless run: bcdfgh
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn a source id/class token stream into ≤3 semantic PascalCase words, or "" when the
|
||||||
|
* evidence is all-generic or hashy. Strips CMS prefixes (shopify-section-…__), leading
|
||||||
|
* numeric template ids, generic structural words, and trailing hash suffixes. Exported for
|
||||||
|
* tests (the hashy-suffix stripping + generic-word filtering is the load-bearing part). */
|
||||||
|
export function nameFromSourceToken(raw: string): string {
|
||||||
|
// Peel known CMS section-id prefixes, keeping the semantic slug after `__` / the hook name.
|
||||||
|
// Case is preserved through prefix-stripping + tokenizing so `looksHashy` can see the
|
||||||
|
// mixed-case entropy of build hashes (RbEALJ, JtTWTt) BEFORE we normalize to lowercase.
|
||||||
|
const s = raw.trim()
|
||||||
|
.replace(/^shopify-section-(?:template|sections)--\d+__/i, "")
|
||||||
|
.replace(/^shopify-(?:section|block)-/i, "")
|
||||||
|
.replace(/^(?:js|tvg|elementor|wp|et|elementor-element)[-_]/i, "");
|
||||||
|
const tokens = s.split(/[\s\-_]+/).filter(Boolean);
|
||||||
|
const kept: string[] = [];
|
||||||
|
for (const t of tokens) {
|
||||||
|
if (kept.length >= 3) break;
|
||||||
|
if (/^\d+$/.test(t)) continue; // pure numeric template ids
|
||||||
|
if (GENERIC_NAME_WORDS.has(t.toLowerCase())) continue; // structural noise
|
||||||
|
if (looksHashy(t)) continue; // trailing entropy suffix (mixed-case aware)
|
||||||
|
if (t.length < 2) continue;
|
||||||
|
const lc = t.toLowerCase();
|
||||||
|
kept.push(lc[0]!.toUpperCase() + lc.slice(1));
|
||||||
|
}
|
||||||
|
return kept.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A source `id` (or `data-section-type`) is a DELIBERATE, developer-authored block name only
|
||||||
|
// when it carries a recognized CMS section prefix — Shopify's `shopify-section-…__split_callout`,
|
||||||
|
// or a generic `section-…`/`…-section`. Arbitrary utility classes (`g_section_space`,
|
||||||
|
// `duraldar-cta_section`) are styling noise, so we do NOT mine class names — a truncated heading
|
||||||
|
// slug reads better than a made-up name. `js-*` behaviour hooks ARE intentional and allowed.
|
||||||
|
const CMS_SECTION_ID = /shopify-section|^section[-_]|[-_]section(?:[-_]|$)|data-section/i;
|
||||||
|
|
||||||
|
/** Best semantic name derivable from a section subtree's source *ids* + `js-*` hooks — the
|
||||||
|
* only source signals reliable enough to beat a heading slug. Scans the root + shallow
|
||||||
|
* descendants. Returns "" when no trustworthy semantic evidence exists. */
|
||||||
|
function sourceNameForSection(sec: IRNode): string {
|
||||||
|
const candidates: string[] = [];
|
||||||
|
const collect = (n: IRNode, depth: number): void => {
|
||||||
|
const id = n.attrs.id;
|
||||||
|
if (id && CMS_SECTION_ID.test(id)) candidates.push(id);
|
||||||
|
const sectionType = n.attrs["data-section-type"] ?? n.attrs["data-section"];
|
||||||
|
if (sectionType) candidates.push(sectionType);
|
||||||
|
if (n.srcClass) {
|
||||||
|
// Only explicit `js-…` behaviour hooks — a deliberate semantic handle, not a utility class.
|
||||||
|
for (const cls of n.srcClass.split(/\s+/)) if (/^js[-_][a-z]/i.test(cls)) candidates.push(cls);
|
||||||
|
}
|
||||||
|
if (depth > 0) for (const c of elementChildren(n)) collect(c, depth - 1);
|
||||||
|
};
|
||||||
|
collect(sec, 2);
|
||||||
|
for (const c of candidates) {
|
||||||
|
const name = nameFromSourceToken(c);
|
||||||
|
if (name && name.length >= 3) return name;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function looksLikeNav(n: IRNode, cw: number): boolean {
|
function looksLikeNav(n: IRNode, cw: number): boolean {
|
||||||
if (n.tag === "nav" || n.tag === "header") return true;
|
if (n.tag === "nav" || n.tag === "header") return true;
|
||||||
if (subtreeHasTag(n, "nav")) return true;
|
if (subtreeHasTag(n, "nav")) return true;
|
||||||
@@ -279,8 +356,13 @@ export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan {
|
|||||||
} else if (recipeName) {
|
} else if (recipeName) {
|
||||||
name = recipeName;
|
name = recipeName;
|
||||||
} else {
|
} else {
|
||||||
|
// Prefer a clean semantic name from the source markup (Shopify/CMS section ids +
|
||||||
|
// `js-*` hooks, hash suffixes stripped) over a truncated heading slug — the source
|
||||||
|
// slug (`split_callout` → SplitCalloutSection) reads more like a hand-authored name.
|
||||||
|
const sourceName = sourceNameForSection(sec);
|
||||||
const slug = slugWords(titleText(sec));
|
const slug = slugWords(titleText(sec));
|
||||||
name = slug ? `${slug}Section`
|
name = sourceName ? `${sourceName}Section`
|
||||||
|
: slug ? `${slug}Section`
|
||||||
: subtreeHasTag(sec, "form", 6) ? "ContactSection"
|
: subtreeHasTag(sec, "form", 6) ? "ContactSection"
|
||||||
: subtreeHasTag(sec, "video", 6) || subtreeHasTag(sec, "iframe", 6) ? "MediaSection"
|
: subtreeHasTag(sec, "video", 6) || subtreeHasTag(sec, "iframe", 6) ? "MediaSection"
|
||||||
: `Section${i + 1}`;
|
: `Section${i + 1}`;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import type { IR, IRNode } from "../normalize/ir.js";
|
import type { IR, IRNode } from "../normalize/ir.js";
|
||||||
import { isTextChild } from "../normalize/ir.js";
|
import { isTextChild } from "../normalize/ir.js";
|
||||||
import { collectNodeRules, computeBands, keyframesCss, type NodeRule } from "./css.js";
|
import { collectNodeRules, computeBands, keyframesCss, type NodeRule } from "./css.js";
|
||||||
|
import { colorClusterKey } from "../infer/semanticTokens.js";
|
||||||
import type { InteractionCapture, StyleDelta } from "../capture/interactions.js";
|
import type { InteractionCapture, StyleDelta } from "../capture/interactions.js";
|
||||||
|
|
||||||
// ---- arbitrary-value escaping ----
|
// ---- arbitrary-value escaping ----
|
||||||
@@ -72,6 +73,7 @@ const KW: Record<string, Record<string, string>> = {
|
|||||||
"font-weight": { "100": "font-thin", "200": "font-extralight", "300": "font-light", "400": "font-normal", "500": "font-medium", "600": "font-semibold", "700": "font-bold", "800": "font-extrabold", "900": "font-black" },
|
"font-weight": { "100": "font-thin", "200": "font-extralight", "300": "font-light", "400": "font-normal", "500": "font-medium", "600": "font-semibold", "700": "font-bold", "800": "font-extrabold", "900": "font-black" },
|
||||||
"text-decoration-line": { underline: "underline", "line-through": "line-through", overline: "overline", none: "no-underline" },
|
"text-decoration-line": { underline: "underline", "line-through": "line-through", overline: "overline", none: "no-underline" },
|
||||||
"white-space": { nowrap: "whitespace-nowrap", normal: "whitespace-normal", pre: "whitespace-pre", "pre-line": "whitespace-pre-line", "pre-wrap": "whitespace-pre-wrap", "break-spaces": "whitespace-break-spaces" },
|
"white-space": { nowrap: "whitespace-nowrap", normal: "whitespace-normal", pre: "whitespace-pre", "pre-line": "whitespace-pre-line", "pre-wrap": "whitespace-pre-wrap", "break-spaces": "whitespace-break-spaces" },
|
||||||
|
"text-wrap": { wrap: "text-wrap", nowrap: "text-nowrap", balance: "text-balance", pretty: "text-pretty" },
|
||||||
"overflow-x": { hidden: "overflow-x-hidden", auto: "overflow-x-auto", scroll: "overflow-x-scroll", visible: "overflow-x-visible", clip: "overflow-x-clip" },
|
"overflow-x": { hidden: "overflow-x-hidden", auto: "overflow-x-auto", scroll: "overflow-x-scroll", visible: "overflow-x-visible", clip: "overflow-x-clip" },
|
||||||
"overflow-y": { hidden: "overflow-y-hidden", auto: "overflow-y-auto", scroll: "overflow-y-scroll", visible: "overflow-y-visible", clip: "overflow-y-clip" },
|
"overflow-y": { hidden: "overflow-y-hidden", auto: "overflow-y-auto", scroll: "overflow-y-scroll", visible: "overflow-y-visible", clip: "overflow-y-clip" },
|
||||||
"object-fit": { contain: "object-contain", cover: "object-cover", fill: "object-fill", none: "object-none", "scale-down": "object-scale-down" },
|
"object-fit": { contain: "object-contain", cover: "object-cover", fill: "object-fill", none: "object-none", "scale-down": "object-scale-down" },
|
||||||
@@ -1039,11 +1041,12 @@ export type TailwindOutput = {
|
|||||||
export type ColorInterner = {
|
export type ColorInterner = {
|
||||||
defs: Map<string, string>; // minted token name → literal value
|
defs: Map<string, string>; // minted token name → literal value
|
||||||
byValue: Map<string, string>; // literal value → minted token name
|
byValue: Map<string, string>; // literal value → minted token name
|
||||||
|
byKey: Map<string, string>; // rounded-sRGB cluster key → minted token name (visual dedup)
|
||||||
tokens: Set<string>; // ALL referenced color token names (palette + minted)
|
tokens: Set<string>; // ALL referenced color token names (palette + minted)
|
||||||
seq: { n: number }; // monotonic counter for clr-N names
|
seq: { n: number }; // monotonic counter for clr-N names
|
||||||
};
|
};
|
||||||
export function createColorInterner(): ColorInterner {
|
export function createColorInterner(): ColorInterner {
|
||||||
return { defs: new Map(), byValue: new Map(), tokens: new Set(), seq: { n: 0 } };
|
return { defs: new Map(), byValue: new Map(), byKey: new Map(), tokens: new Set(), seq: { n: 0 } };
|
||||||
}
|
}
|
||||||
/** `:root { --clr-N: <literal>; … }` for the interner's minted tokens (empty if none). */
|
/** `:root { --clr-N: <literal>; … }` for the interner's minted tokens (empty if none). */
|
||||||
export function colorDefsCssOf(it: ColorInterner): string {
|
export function colorDefsCssOf(it: ColorInterner): string {
|
||||||
@@ -1126,12 +1129,31 @@ export function buildTailwind(ir: IR, assetMap: Map<string, string>, colorVar?:
|
|||||||
indexNode(ir.root);
|
indexNode(ir.root);
|
||||||
|
|
||||||
// Color interner: every distinct color value → a stable theme token referenced as
|
// Color interner: every distinct color value → a stable theme token referenced as
|
||||||
// var(--…). Palette colors already arrive as var(--color-*) (kept, semantic); any other
|
// var(--…). A color the semantic palette recognizes (exactly OR within the grader's ±2
|
||||||
// color is minted a numbered token (--clr-N) so raw rgb/hex NEVER lands in markup.
|
// sRGB tolerance — the same tolerance css.ts already trusts for color/bg/border) reuses
|
||||||
// The token holds the literal value, minted in deterministic first-encounter order.
|
// that SEMANTIC name (--primary, --surface, --color-001…), so oklab/lch colours reached
|
||||||
|
// only through decoration/gradient/shadow props no longer each mint a fresh opaque
|
||||||
|
// --clr-N. Only colours with NO palette role fall through to a numbered --clr-N token,
|
||||||
|
// minted in deterministic first-encounter order (the literal is kept, byte-exact).
|
||||||
const internColor = (literal: string): string => {
|
const internColor = (literal: string): string => {
|
||||||
|
const semantic = colorVar?.(literal);
|
||||||
|
if (semantic) { const n = tokenName(semantic); if (n) colorTokens.add(n); return semantic; }
|
||||||
let name = interner.byValue.get(literal);
|
let name = interner.byValue.get(literal);
|
||||||
if (!name) { name = `clr-${interner.seq.n++}`; interner.byValue.set(literal, name); interner.defs.set(name, literal); }
|
if (!name) {
|
||||||
|
// Dedup visually-identical literals (many oklab()/lch() forms round to the same sRGB):
|
||||||
|
// reuse the token minted for that colour rather than a fresh --clr-N. Fidelity-neutral —
|
||||||
|
// the grader compares in sRGB and the shared token holds the FIRST literal (within ±0 of
|
||||||
|
// its own colour). Values that don't parse fall back to exact-literal keying.
|
||||||
|
const key = colorClusterKey(literal);
|
||||||
|
const existing = key ? interner.byKey.get(key) : undefined;
|
||||||
|
if (existing) { name = existing; interner.byValue.set(literal, name); }
|
||||||
|
else {
|
||||||
|
name = `clr-${interner.seq.n++}`;
|
||||||
|
interner.byValue.set(literal, name);
|
||||||
|
interner.defs.set(name, literal);
|
||||||
|
if (key) interner.byKey.set(key, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
colorTokens.add(name);
|
colorTokens.add(name);
|
||||||
return `var(--${name})`;
|
return `var(--${name})`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,12 +27,117 @@ export type ColorPalette = {
|
|||||||
const TRANSPARENT = new Set(["rgba(0, 0, 0, 0)", "transparent", "rgba(0,0,0,0)", ""]);
|
const TRANSPARENT = new Set(["rgba(0, 0, 0, 0)", "transparent", "rgba(0,0,0,0)", ""]);
|
||||||
|
|
||||||
type RGBA = [number, number, number, number];
|
type RGBA = [number, number, number, number];
|
||||||
|
|
||||||
|
/** Parse the numeric args of a `fn(a b c / d)` / `fn(a,b,c,d)` color into [a,b,c,alpha].
|
||||||
|
* Percentages resolve against `pctBase` (255 for rgb channels, 1 for lab/lch/oklab/oklch
|
||||||
|
* L and the c/ab axes where the caller passes their own scale). `none` → 0. */
|
||||||
|
function parseColorArgs(inner: string): { nums: number[]; alpha: number } | null {
|
||||||
|
const parts = inner.split("/");
|
||||||
|
const main = parts[0]!.trim().split(/[\s,]+/).filter(Boolean);
|
||||||
|
const nums = main.map((s) => (s === "none" ? 0 : parseFloat(s)));
|
||||||
|
if (nums.length < 3 || nums.slice(0, 3).some(Number.isNaN)) return null;
|
||||||
|
let alpha = 1;
|
||||||
|
const alphaTok = parts.length > 1 ? parts[1]!.trim() : main[3];
|
||||||
|
if (alphaTok !== undefined && alphaTok !== "none") {
|
||||||
|
const a = alphaTok.endsWith("%") ? parseFloat(alphaTok) / 100 : parseFloat(alphaTok);
|
||||||
|
if (!Number.isNaN(a)) alpha = a;
|
||||||
|
}
|
||||||
|
return { nums, alpha };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pctOr(tok: string, base: number): number {
|
||||||
|
return tok.endsWith("%") ? (parseFloat(tok) / 100) * base : parseFloat(tok);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Linear-light sRGB channel → gamma-encoded 0–255. */
|
||||||
|
function lin2srgb(c: number): number {
|
||||||
|
const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
||||||
|
return Math.round(Math.min(1, Math.max(0, v)) * 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OKLab → sRGB (CSS Color 4 reference matrices). Returns gamma-encoded 0–255. */
|
||||||
|
function oklabToRgb(L: number, a: number, b: number): [number, number, number] {
|
||||||
|
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
||||||
|
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
||||||
|
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
||||||
|
const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
|
||||||
|
const r = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
||||||
|
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
||||||
|
const bl = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
|
||||||
|
return [lin2srgb(r), lin2srgb(g), lin2srgb(bl)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CIE Lab (D50) → sRGB, gamma-encoded 0–255. */
|
||||||
|
function labToRgb(L: number, a: number, b: number): [number, number, number] {
|
||||||
|
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
|
||||||
|
const d = 6 / 29;
|
||||||
|
const inv = (t: number): number => (t > d ? t ** 3 : 3 * d * d * (t - 4 / 29));
|
||||||
|
// D50 white point.
|
||||||
|
const X = 0.9642956 * inv(fx), Y = 1.0 * inv(fy), Z = 0.8251046 * inv(fz);
|
||||||
|
// XYZ(D50) → linear sRGB (Bradford-adapted D50→D65 folded in).
|
||||||
|
const r = +3.1341359 * X - 1.6172206 * Y - 0.4906860 * Z;
|
||||||
|
const g = -0.9787684 * X + 1.9161415 * Y + 0.0334540 * Z;
|
||||||
|
const bl = +0.0719453 * X - 0.2289914 * Y + 1.4052427 * Z;
|
||||||
|
return [lin2srgb(r), lin2srgb(g), lin2srgb(bl)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse any CSS color value (`rgb`/`rgba`/`hsl`/`hsla`/`oklab`/`oklch`/`lab`/`lch`/hex/
|
||||||
|
* named-ish) into rounded sRGB `[r,g,b,alpha]`, else null. The literal value is kept
|
||||||
|
* verbatim in emitted CSS; this RGB is used ONLY for clustering (grader works in sRGB
|
||||||
|
* ±2), so oklab/lch colours cluster + earn semantic roles instead of falling to --clr-N. */
|
||||||
|
function parseColor(v: string): RGBA | null {
|
||||||
|
const s = v.trim().toLowerCase();
|
||||||
|
if (s === "white") return [255, 255, 255, 1];
|
||||||
|
if (s === "black") return [0, 0, 0, 1];
|
||||||
|
const hex = /^#([0-9a-f]{3,8})$/i.exec(s);
|
||||||
|
if (hex) {
|
||||||
|
let h = hex[1]!;
|
||||||
|
if (h.length === 3 || h.length === 4) h = h.split("").map((c) => c + c).join("");
|
||||||
|
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
|
||||||
|
const a = h.length >= 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
|
||||||
|
return [r, g, b, a];
|
||||||
|
}
|
||||||
|
const fn = /^(rgba?|hsla?|oklab|oklch|lab|lch)\(([^)]*)\)$/i.exec(s);
|
||||||
|
if (!fn) return null;
|
||||||
|
const kind = fn[1]!, parsed = parseColorArgs(fn[2]!);
|
||||||
|
if (!parsed) return null;
|
||||||
|
const { nums, alpha } = parsed;
|
||||||
|
const raw = fn[2]!.split("/")[0]!.trim().split(/[\s,]+/).filter(Boolean);
|
||||||
|
if (kind.startsWith("rgb")) {
|
||||||
|
return [Math.round(pctOr(raw[0]!, 255)), Math.round(pctOr(raw[1]!, 255)), Math.round(pctOr(raw[2]!, 255)), alpha];
|
||||||
|
}
|
||||||
|
if (kind.startsWith("hsl")) {
|
||||||
|
const H = ((nums[0]! % 360) + 360) % 360, S = pctOr(raw[1]!, 1), Lh = pctOr(raw[2]!, 1);
|
||||||
|
const c = (1 - Math.abs(2 * Lh - 1)) * S, x = c * (1 - Math.abs(((H / 60) % 2) - 1)), m = Lh - c / 2;
|
||||||
|
const [r1, g1, b1] = H < 60 ? [c, x, 0] : H < 120 ? [x, c, 0] : H < 180 ? [0, c, x] : H < 240 ? [0, x, c] : H < 300 ? [x, 0, c] : [c, 0, x];
|
||||||
|
return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255), alpha];
|
||||||
|
}
|
||||||
|
if (kind === "oklab") { const [r, g, b] = oklabToRgb(pctOr(raw[0]!, 1), nums[1]!, nums[2]!); return [r, g, b, alpha]; }
|
||||||
|
if (kind === "oklch") {
|
||||||
|
const L = pctOr(raw[0]!, 1), C = nums[1]!, h = (nums[2]! * Math.PI) / 180;
|
||||||
|
const [r, g, b] = oklabToRgb(L, C * Math.cos(h), C * Math.sin(h)); return [r, g, b, alpha];
|
||||||
|
}
|
||||||
|
if (kind === "lab") { const [r, g, b] = labToRgb(pctOr(raw[0]!, 100), nums[1]!, nums[2]!); return [r, g, b, alpha]; }
|
||||||
|
if (kind === "lch") {
|
||||||
|
const L = pctOr(raw[0]!, 100), C = nums[1]!, h = (nums[2]! * Math.PI) / 180;
|
||||||
|
const [r, g, b] = labToRgb(L, C * Math.cos(h), C * Math.sin(h)); return [r, g, b, alpha];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back-compat alias: the palette parses every colour space now, not just rgb(). */
|
||||||
function parseRgb(v: string): RGBA | null {
|
function parseRgb(v: string): RGBA | null {
|
||||||
const m = v.match(/rgba?\(([^)]+)\)/i);
|
return parseColor(v);
|
||||||
if (!m) return null;
|
}
|
||||||
const p = m[1]!.split(/[,\/]/).map((s) => parseFloat(s.trim()));
|
|
||||||
if (p.length < 3 || p.slice(0, 3).some(Number.isNaN)) return null;
|
/** A stable clustering key for a color literal: its rounded sRGB (+ alpha), so two literals
|
||||||
return [Math.round(p[0]!), Math.round(p[1]!), Math.round(p[2]!), p.length >= 4 && !Number.isNaN(p[3]!) ? p[3]! : 1];
|
* that render the same colour (`oklab(0.988242 …)` and `oklab(0.988371 …)` → rgb(251,251,251))
|
||||||
|
* collapse to ONE minted token instead of a wall of near-identical --clr-N. Null when the
|
||||||
|
* value can't be parsed (keeps the raw literal as its own key). Alpha bucketed to ±0.02. */
|
||||||
|
export function colorClusterKey(v: string): string | null {
|
||||||
|
const c = parseColor(v);
|
||||||
|
if (!c) return null;
|
||||||
|
return `${c[0]},${c[1]},${c[2]},${Math.round(c[3] * 50)}`;
|
||||||
}
|
}
|
||||||
function within2(a: RGBA, b: RGBA): boolean {
|
function within2(a: RGBA, b: RGBA): boolean {
|
||||||
return Math.abs(a[0] - b[0]) <= 2 && Math.abs(a[1] - b[1]) <= 2 && Math.abs(a[2] - b[2]) <= 2 && Math.abs(a[3] - b[3]) <= 0.04;
|
return Math.abs(a[0] - b[0]) <= 2 && Math.abs(a[1] - b[1]) <= 2 && Math.abs(a[2] - b[2]) <= 2 && Math.abs(a[3] - b[3]) <= 0.04;
|
||||||
@@ -140,33 +245,57 @@ function paletteFrom(usage: Map<string, Usage>, roleIr: IR, minCount: number): C
|
|||||||
const fgCluster = findCluster(pv?.bodyColor);
|
const fgCluster = findCluster(pv?.bodyColor);
|
||||||
if (fgCluster) assign("--foreground", fgCluster);
|
if (fgCluster) assign("--foreground", fgCluster);
|
||||||
|
|
||||||
// 2) Primary/accent: most-used chromatic color, weighted toward interactive usage.
|
// Luminance (0–1) for deterministic tiebreaks (Rec.709). Ties in every ranking below
|
||||||
const remaining = clusters.filter((c) => !c.members.some((m) => valueToName.has(m)));
|
// break by count, then luminance (dark→light), then the literal value — so the palette
|
||||||
|
// is byte-stable for a given capture (determinism gate 6).
|
||||||
|
const lum = (c: Usage): number => (0.2126 * c.rgba[0] + 0.7152 * c.rgba[1] + 0.0722 * c.rgba[2]) / 255;
|
||||||
|
const unnamed = (c: { canon: Usage; members: string[] }): boolean => !c.members.some((m) => valueToName.has(m));
|
||||||
|
const byCount = (score: (c: Usage) => number) => (a: { canon: Usage }, b: { canon: Usage }): number =>
|
||||||
|
score(b.canon) - score(a.canon) || b.canon.count - a.canon.count || lum(a.canon) - lum(b.canon) || a.canon.value.localeCompare(b.canon.value);
|
||||||
|
|
||||||
|
// 2) Brand + accent: the most-used chromatic colours, weighted toward interactive usage
|
||||||
|
// (buttons/links). `--primary` (a.k.a. brand) is the strongest; `--accent` the next.
|
||||||
|
// A colour is "chromatic" only with real hue: HSL saturation ≥ 0.25 AND an absolute
|
||||||
|
// channel spread ≥ 24, so near-neutral off-whites/creams (max−min ≈ 10) that merely
|
||||||
|
// tip over the ratio threshold stay neutrals → --surface, not a fake --accent.
|
||||||
|
const chroma = (c: RGBA): number => Math.max(c[0], c[1], c[2]) - Math.min(c[0], c[1], c[2]);
|
||||||
|
const remaining = clusters.filter(unnamed);
|
||||||
const chromatic = remaining
|
const chromatic = remaining
|
||||||
.filter((c) => { const { s } = satLight(c.canon.rgba); return s >= 0.25; })
|
.filter((c) => satLight(c.canon.rgba).s >= 0.25 && chroma(c.canon.rgba) >= 24)
|
||||||
.sort((a, b) => (b.canon.interactive * 3 + b.canon.count) - (a.canon.interactive * 3 + a.canon.count));
|
.sort(byCount((c) => c.interactive * 3 + c.count));
|
||||||
if (chromatic[0]) assign("--primary", chromatic[0]);
|
if (chromatic[0]) assign("--primary", chromatic[0]);
|
||||||
if (chromatic[1]) assign("--accent", chromatic[1]);
|
if (chromatic[1]) assign("--accent", chromatic[1]);
|
||||||
|
|
||||||
// 3) Border: most-used color that appears predominantly as a border.
|
// 3) Border: most-used colour that appears predominantly as a border.
|
||||||
const borderC = remaining
|
const borderC = remaining
|
||||||
.filter((c) => !c.members.some((m) => valueToName.has(m)) && c.canon.border > 0)
|
.filter((c) => unnamed(c) && c.canon.border > 0)
|
||||||
.sort((a, b) => b.canon.border - a.canon.border)[0];
|
.sort(byCount((c) => c.border))[0];
|
||||||
if (borderC && borderC.canon.border >= Math.max(2, minCount - 1)) assign("--border", borderC);
|
if (borderC && borderC.canon.border >= Math.max(2, minCount - 1)) assign("--border", borderC);
|
||||||
|
|
||||||
// 4) Neutrals: a light neutral used as a background → surface; a mid neutral text → muted.
|
// 4) Neutrals, ranked so the most-used earns the cleanest name. Light neutrals used as
|
||||||
for (const c of remaining) {
|
// backgrounds → --surface, --surface-2, … (section/card/footer alt backgrounds);
|
||||||
if (c.members.some((m) => valueToName.has(m))) continue;
|
// mid/dark neutrals used as text → --muted-foreground then --muted. "Neutral" is the
|
||||||
if (c.canon.count < minCount) continue;
|
// complement of "chromatic" above (no strong hue: low saturation OR tiny channel spread),
|
||||||
const { s, l } = satLight(c.canon.rgba);
|
// so no colour falls through the gap between the two thresholds.
|
||||||
if (s < 0.15 && c.canon.bg >= c.canon.text && l > 0.85 && !taken.has("--surface")) assign("--surface", c);
|
const neutrals = remaining
|
||||||
else if (s < 0.2 && c.canon.text >= c.canon.bg && l > 0.35 && l < 0.7 && !taken.has("--muted-foreground")) assign("--muted-foreground", c);
|
.filter((c) => unnamed(c) && c.canon.count >= minCount && (satLight(c.canon.rgba).s < 0.25 || chroma(c.canon.rgba) < 24))
|
||||||
|
.sort(byCount((c) => c.count));
|
||||||
|
let surfaceN = 0;
|
||||||
|
for (const c of neutrals) {
|
||||||
|
if (!unnamed(c)) continue;
|
||||||
|
const { l } = satLight(c.canon.rgba);
|
||||||
|
if (c.canon.bg >= c.canon.text && l > 0.85) {
|
||||||
|
assign(surfaceN === 0 ? "--surface" : `--surface-${surfaceN + 1}`, c);
|
||||||
|
surfaceN++;
|
||||||
|
} else if (c.canon.text >= c.canon.bg && l > 0.2 && l < 0.7) {
|
||||||
|
assign(!taken.has("--muted-foreground") ? "--muted-foreground" : "--muted", c);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) Everything else used >= minCount → numbered.
|
// 5) Everything else used >= minCount → numbered, in a deterministic order.
|
||||||
let ci = 1;
|
let ci = 1;
|
||||||
for (const c of remaining) {
|
for (const c of [...remaining].sort(byCount(() => 0))) {
|
||||||
if (c.members.some((m) => valueToName.has(m))) continue;
|
if (!unnamed(c)) continue;
|
||||||
if (c.canon.count < minCount) continue;
|
if (c.canon.count < minCount) continue;
|
||||||
assign(`--color-${String(ci++).padStart(3, "0")}`, c);
|
assign(`--color-${String(ci++).padStart(3, "0")}`, c);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export type IRNode = {
|
|||||||
// element had no class.
|
// element had no class.
|
||||||
srcClass?: string;
|
srcClass?: string;
|
||||||
rawHTML?: string; // inline svg
|
rawHTML?: string; // inline svg
|
||||||
|
// Computed paint of an inline <svg> root (fill/stroke/color), carried from capture. Lets codegen
|
||||||
|
// recover a paint that a raw `fill="none"` attribute hides but site CSS actually supplied.
|
||||||
|
svgPaint?: { fill: string; stroke: string; color: string };
|
||||||
visibleByVp: Record<number, boolean>;
|
visibleByVp: Record<number, boolean>;
|
||||||
bboxByVp: Record<number, BBox>;
|
bboxByVp: Record<number, BBox>;
|
||||||
computedByVp: Record<number, StyleMap>;
|
computedByVp: Record<number, StyleMap>;
|
||||||
@@ -456,6 +459,7 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
|
|||||||
children: [],
|
children: [],
|
||||||
};
|
};
|
||||||
if (raw.rawHTML) node.rawHTML = raw.rawHTML;
|
if (raw.rawHTML) node.rawHTML = raw.rawHTML;
|
||||||
|
if (raw.svgPaint) node.svgPaint = raw.svgPaint;
|
||||||
const srcClass = (raw.attrs?.class ?? "").trim();
|
const srcClass = (raw.attrs?.class ?? "").trim();
|
||||||
if (srcClass) node.srcClass = srcClass;
|
if (srcClass) node.srcClass = srcClass;
|
||||||
if (Object.keys(sizingByVp).length) node.sizingByVp = sizingByVp;
|
if (Object.keys(sizingByVp).length) node.sizingByVp = sizingByVp;
|
||||||
|
|||||||
@@ -784,3 +784,39 @@ describe("generateCss breakpoint-gated marquee transform band suppression (Defec
|
|||||||
assert.ok(/matrix\(1, ?0, ?0, ?1, ?-40/.test(allRulesX(css, "n1")), `a non-animated per-band offset must be kept, got: ${allRulesX(css, "n1")}`);
|
assert.ok(/matrix\(1, ?0, ?0, ?1, ?-40/.test(allRulesX(css, "n1")), `a non-animated per-band offset must be kept, got: ${allRulesX(css, "n1")}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// text-wrap emission: a heading authored `text-wrap:balance` must survive to the clone, or the
|
||||||
|
// title wraps differently. The initial `wrap` is elided (it's the default), so only authored
|
||||||
|
// balance/pretty/nowrap ship.
|
||||||
|
describe("generateCss text-wrap", () => {
|
||||||
|
it("emits an authored text-wrap:balance on a heading", () => {
|
||||||
|
const h1 = node("n1", "h1", computed({ textWrap: "balance" }));
|
||||||
|
const root = node("n0", "body", computed(), [h1]);
|
||||||
|
const css = generateCss(irWith(root), new Map());
|
||||||
|
assert.ok(/text-wrap:balance/.test(baseRule(css, "n1")), `text-wrap:balance emitted (got: ${baseRule(css, "n1")})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits text-wrap:pretty", () => {
|
||||||
|
const p = node("n1", "p", computed({ textWrap: "pretty" }));
|
||||||
|
const root = node("n0", "body", computed(), [p]);
|
||||||
|
const css = generateCss(irWith(root), new Map());
|
||||||
|
assert.ok(/text-wrap:pretty/.test(baseRule(css, "n1")), `text-wrap:pretty emitted (got: ${baseRule(css, "n1")})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("elides the default text-wrap:wrap (no noise)", () => {
|
||||||
|
const h1 = node("n1", "h1", computed({ textWrap: "wrap" }));
|
||||||
|
const root = node("n0", "body", computed(), [h1]);
|
||||||
|
const css = generateCss(irWith(root), new Map());
|
||||||
|
assert.ok(!/text-wrap/.test(baseRule(css, "n1")), `default text-wrap:wrap must not be emitted (got: ${baseRule(css, "n1")})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips text-wrap on a child when it equals the parent (inherited)", () => {
|
||||||
|
// text-wrap is inherited: a child matching the parent's balance relies on inheritance.
|
||||||
|
const child = node("n2", "span", computed({ textWrap: "balance" }));
|
||||||
|
const h1 = node("n1", "h1", computed({ textWrap: "balance" }), [child]);
|
||||||
|
const root = node("n0", "body", computed(), [h1]);
|
||||||
|
const css = generateCss(irWith(root), new Map());
|
||||||
|
assert.ok(/text-wrap:balance/.test(baseRule(css, "n1")), "parent heading emits balance");
|
||||||
|
assert.ok(!/text-wrap/.test(baseRule(css, "n2")), `inherited child does not re-emit (got: ${baseRule(css, "n2")})`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { chromium, type Browser, type Page } from "playwright";
|
||||||
|
import { captureInteractions, tagElements, diffStyle } from "../src/capture/interactions.js";
|
||||||
|
|
||||||
|
describe("diffStyle", () => {
|
||||||
|
it("returns only the changed keys, with the b-side value", () => {
|
||||||
|
const a = { color: "rgb(0, 0, 0)", opacity: "1", transform: "none" };
|
||||||
|
const b = { color: "rgb(255, 0, 0)", opacity: "1", transform: "scale(1.1)" };
|
||||||
|
assert.deepEqual(diffStyle(a, b), { color: "rgb(255, 0, 0)", transform: "scale(1.1)" });
|
||||||
|
});
|
||||||
|
it("is empty when nothing changed", () => {
|
||||||
|
const a = { color: "rgb(0, 0, 0)", opacity: "1" };
|
||||||
|
assert.deepEqual(diffStyle(a, { ...a }), {});
|
||||||
|
});
|
||||||
|
it("only reports keys present in b (b drives the comparison)", () => {
|
||||||
|
// a resting style that carries an extra key does not fabricate a delta.
|
||||||
|
assert.deepEqual(diffStyle({ color: "red", extra: "x" }, { color: "red" }), {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("captureInteractions hover capture (occlusion-immune)", () => {
|
||||||
|
let browser: Browser;
|
||||||
|
let page: Page;
|
||||||
|
before(async () => {
|
||||||
|
browser = await chromium.launch();
|
||||||
|
page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const setup = async (html: string) => {
|
||||||
|
await page.setContent(html);
|
||||||
|
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||||
|
await tagElements(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("captures an authored :hover state even when a transparent full-viewport overlay covers the target", async () => {
|
||||||
|
// Exact regression for the empty-hover-capture bug: a modern builder stack parks a
|
||||||
|
// transparent fixed layer over the whole page. page.hover moves the real cursor to the
|
||||||
|
// link's centre, the point lands on the overlay, and `:hover` never reaches the link — so
|
||||||
|
// pointer-based probing captured ZERO hover states. Forcing the pseudo-class fixes it.
|
||||||
|
await setup(`
|
||||||
|
<style>
|
||||||
|
a.cta { color: rgb(0, 0, 0); background-color: rgb(255, 255, 255); }
|
||||||
|
a.cta:hover { color: rgb(255, 0, 0); background-color: rgb(0, 0, 255); }
|
||||||
|
.overlay { position: fixed; inset: 0; z-index: 9999; background: transparent; }
|
||||||
|
</style>
|
||||||
|
<a class="cta" href="#">Shop now</a>
|
||||||
|
<div class="overlay"></div>`);
|
||||||
|
const cap = await captureInteractions(page, { maxCandidates: 50 });
|
||||||
|
const hoverCaps = Object.keys(cap.hover);
|
||||||
|
assert.ok(hoverCaps.length >= 1, "at least one hover state is captured through the overlay");
|
||||||
|
const delta = cap.hover[hoverCaps[0]!]!;
|
||||||
|
assert.equal(delta.color, "rgb(255, 0, 0)", "hover color change is captured");
|
||||||
|
assert.equal(delta.backgroundColor, "rgb(0, 0, 255)", "hover background change is captured");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures :hover on a cursor:pointer element that is not a native interactive", async () => {
|
||||||
|
await setup(`
|
||||||
|
<style>
|
||||||
|
.card { cursor: pointer; border: 2px solid rgb(1, 1, 1); }
|
||||||
|
.card:hover { border-color: rgb(9, 9, 9); }
|
||||||
|
</style>
|
||||||
|
<div class="card" style="width:200px;height:120px;">Card</div>`);
|
||||||
|
const cap = await captureInteractions(page, { maxCandidates: 50 });
|
||||||
|
const found = Object.values(cap.hover).some((d) => d.borderTopColor === "rgb(9, 9, 9)");
|
||||||
|
assert.ok(found, "a cursor:pointer card's authored :hover border change is captured");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records no hover delta for an element with no authored :hover (self-limiting)", async () => {
|
||||||
|
await setup(`
|
||||||
|
<a class="plain" href="#" style="color:rgb(0,0,0);">No hover</a>`);
|
||||||
|
const cap = await captureInteractions(page, { maxCandidates: 50 });
|
||||||
|
assert.equal(Object.keys(cap.hover).length, 0, "no authored hover -> empty hover map");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the resting state after probing (forced pseudo-state cleared)", async () => {
|
||||||
|
await setup(`
|
||||||
|
<style>
|
||||||
|
a.cta { color: rgb(0, 0, 0); }
|
||||||
|
a.cta:hover { color: rgb(255, 0, 0); }
|
||||||
|
</style>
|
||||||
|
<a class="cta" href="#">Link</a>`);
|
||||||
|
await captureInteractions(page, { maxCandidates: 50 });
|
||||||
|
const resting = await page.evaluate(() => getComputedStyle(document.querySelector("a.cta")!).color);
|
||||||
|
assert.equal(resting, "rgb(0, 0, 0)", "the page is left in its resting (non-hover) state");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { buildColorPalette, colorClusterKey } from "../src/infer/semanticTokens.js";
|
||||||
|
import { planSections, nameFromSourceToken } from "../src/generate/sectionSplit.js";
|
||||||
|
import type { IR, IRNode, IRChild, StyleMap } from "../src/normalize/ir.js";
|
||||||
|
|
||||||
|
const CW = 1280;
|
||||||
|
|
||||||
|
type Box = { x?: number; y: number; width?: number; height: number };
|
||||||
|
function el(tag: string, box: Box, computed: StyleMap, children: IRChild[] = [], attrs: Record<string, string> = {}, srcClass?: string): IRNode {
|
||||||
|
const n: IRNode = {
|
||||||
|
id: "", tag, attrs,
|
||||||
|
visibleByVp: { [CW]: true },
|
||||||
|
bboxByVp: { [CW]: { x: box.x ?? 0, y: box.y, width: box.width ?? CW, height: box.height } },
|
||||||
|
computedByVp: { [CW]: { display: "block", ...computed } },
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
if (srcClass) n.srcClass = srcClass;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
function text(t: string): IRChild { return { text: t }; }
|
||||||
|
|
||||||
|
function page(children: IRNode[], pageH: number, body?: { bodyBg?: string; bodyColor?: string }): IR {
|
||||||
|
// The body carries its own bg/fg computed style (as a real capture does), so the palette's
|
||||||
|
// usage histogram actually sees the page background/foreground colours it will name.
|
||||||
|
const root = el("body", { y: 0, height: pageH }, {
|
||||||
|
...(body?.bodyBg ? { backgroundColor: body.bodyBg } : {}),
|
||||||
|
...(body?.bodyColor ? { color: body.bodyColor } : {}),
|
||||||
|
}, children);
|
||||||
|
let i = 0;
|
||||||
|
const assign = (n: IRNode): void => { n.id = `n${i++}`; for (const c of n.children) if ((c as IRNode).tag) assign(c as IRNode); };
|
||||||
|
assign(root);
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
sourceUrl: "https://example.test/", title: "Fixture", lang: "en", charset: "UTF-8",
|
||||||
|
metaViewport: "width=device-width, initial-scale=1",
|
||||||
|
viewports: [CW], sampleViewports: [CW], canonicalViewport: CW,
|
||||||
|
perViewport: { [CW]: { scrollHeight: pageH, scrollWidth: CW, htmlBg: "", bodyBg: body?.bodyBg ?? "", bodyColor: body?.bodyColor ?? "", bodyFont: "" } },
|
||||||
|
nodeCount: i, keyframes: [],
|
||||||
|
},
|
||||||
|
root,
|
||||||
|
} as IR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 1) Semantic role assignment — a fixture IR with known roles must produce the
|
||||||
|
// expected named tokens (deterministically), leftovers only for role-less colours.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
describe("semantic color palette: role assignment", () => {
|
||||||
|
// page bg = cream; body text = near-black; a saturated red brand used on buttons/links;
|
||||||
|
// a light-gray surface used as several card backgrounds; a border gray; one odd accent.
|
||||||
|
function siteIr(): IR {
|
||||||
|
const kids: IRNode[] = [];
|
||||||
|
// Body text (near-black) x8
|
||||||
|
for (let k = 0; k < 8; k++) kids.push(el("p", { y: 100 + k * 30, height: 20 }, { color: "rgb(20, 20, 19)" }, [text("copy")]));
|
||||||
|
// Brand red on buttons/links x6 (interactive → --primary)
|
||||||
|
for (let k = 0; k < 6; k++) kids.push(el("a", { y: 400 + k * 30, height: 40 }, { backgroundColor: "rgb(188, 0, 0)", color: "rgb(255,255,255)" }, [text("Buy")]));
|
||||||
|
// Light-gray card surfaces x5 (bg, light, low-sat → --surface)
|
||||||
|
for (let k = 0; k < 5; k++) kids.push(el("div", { y: 800 + k * 60, height: 50 }, { backgroundColor: "rgb(240, 238, 230)" }));
|
||||||
|
// Border gray x4 (border only → --border)
|
||||||
|
for (let k = 0; k < 4; k++) kids.push(el("div", { y: 1200 + k * 30, height: 20 }, { borderTopColor: "rgb(200, 200, 200)", borderTopWidth: "1px" }));
|
||||||
|
return page(kids, 1600, { bodyBg: "rgb(252, 251, 246)", bodyColor: "rgb(20, 20, 19)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
it("names background / foreground / primary / surface / border from usage evidence", () => {
|
||||||
|
const p = buildColorPalette(siteIr());
|
||||||
|
const byName = new Map(p.tokens.map((t) => [t.name, t.value]));
|
||||||
|
assert.equal(byName.get("--background"), "rgb(252, 251, 246)");
|
||||||
|
assert.equal(byName.get("--foreground"), "rgb(20, 20, 19)");
|
||||||
|
assert.equal(byName.get("--primary"), "rgb(188, 0, 0)");
|
||||||
|
assert.equal(byName.get("--surface"), "rgb(240, 238, 230)");
|
||||||
|
assert.equal(byName.get("--border"), "rgb(200, 200, 200)");
|
||||||
|
// Every named token resolves back to itself.
|
||||||
|
assert.equal(p.varForColor("rgb(188, 0, 0)"), "var(--primary)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is deterministic: same IR → byte-identical token list", () => {
|
||||||
|
const a = buildColorPalette(siteIr()).css;
|
||||||
|
const b = buildColorPalette(siteIr()).css;
|
||||||
|
assert.equal(a, b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves oklab/oklch forms of a named colour to the SAME semantic token (±2 sRGB)", () => {
|
||||||
|
// oklch(0.987… 97°) ≈ rgb(252,251,246) — the page background. Reached only via a
|
||||||
|
// gradient/decoration property, it must still map to --background, not a fresh --clr-N.
|
||||||
|
const p = buildColorPalette(page([
|
||||||
|
...Array.from({ length: 4 }, (_, k) => el("p", { y: 100 + k * 30, height: 20 }, { color: "rgb(20, 20, 19)" }, [text("x")])),
|
||||||
|
], 400, { bodyBg: "oklch(0.987472 0.00667657 97.3497)", bodyColor: "rgb(20, 20, 19)" }));
|
||||||
|
const bg = p.tokens.find((t) => t.name === "--background");
|
||||||
|
assert.ok(bg, "background named");
|
||||||
|
// The rgb() equivalent within tolerance resolves to --background via the ±2 fallback.
|
||||||
|
assert.equal(p.varForColor("rgb(252, 251, 246)"), "var(--background)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 2) colorClusterKey — visually identical literals share a key; distinct colours don't.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
describe("colorClusterKey (interner visual dedup)", () => {
|
||||||
|
it("collapses oklab forms that round to the same sRGB", () => {
|
||||||
|
// Both oklab(0.988…) whites → rgb(251,251,251).
|
||||||
|
const a = colorClusterKey("oklab(0.988242 -0.0000812355 0.00000757745)");
|
||||||
|
const b = colorClusterKey("oklab(0.988371 -0.0000803481 0.00000749468)");
|
||||||
|
assert.ok(a);
|
||||||
|
assert.equal(a, b);
|
||||||
|
});
|
||||||
|
it("keeps genuinely different colours on different keys", () => {
|
||||||
|
assert.notEqual(colorClusterKey("rgb(0,0,0)"), colorClusterKey("rgb(255,255,255)"));
|
||||||
|
assert.notEqual(colorClusterKey("rgb(188,0,0)"), colorClusterKey("rgb(0,0,188)"));
|
||||||
|
});
|
||||||
|
it("separates alpha variants", () => {
|
||||||
|
assert.notEqual(colorClusterKey("rgba(0,0,0,0.5)"), colorClusterKey("rgba(0,0,0,0.75)"));
|
||||||
|
});
|
||||||
|
it("returns null for unparseable values (keeps raw-literal keying)", () => {
|
||||||
|
assert.equal(colorClusterKey("var(--x)"), null);
|
||||||
|
assert.equal(colorClusterKey("currentColor"), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 3) Name sanitization — hashy-suffix stripping + generic-word filtering.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
describe("nameFromSourceToken (source-id sanitization)", () => {
|
||||||
|
it("strips Shopify template prefix + trailing hash → semantic slug", () => {
|
||||||
|
assert.equal(nameFromSourceToken("shopify-section-template--19797275672650__split_callout_JtTWTt"), "SplitCallout");
|
||||||
|
// `grid` is a generic structural word (dropped); the hash `RbEALJ` is stripped.
|
||||||
|
assert.equal(nameFromSourceToken("shopify-section-template--19797275672650__media_card_grid_RbEALJ"), "MediaCard");
|
||||||
|
});
|
||||||
|
it("strips mixed-case build hashes (JtTWTt / dDMm2q / RbEALJ)", () => {
|
||||||
|
assert.equal(nameFromSourceToken("split_callout_JtTWTt"), "SplitCallout");
|
||||||
|
assert.equal(nameFromSourceToken("hero_hD9krx"), "Hero");
|
||||||
|
});
|
||||||
|
it("drops generic structural words entirely", () => {
|
||||||
|
assert.equal(nameFromSourceToken("g_section_wrap"), "");
|
||||||
|
assert.equal(nameFromSourceToken("section-inner-content"), "");
|
||||||
|
assert.equal(nameFromSourceToken("shopify-section-group-header"), "Header");
|
||||||
|
});
|
||||||
|
it("handles js-* hooks and long numeric ids", () => {
|
||||||
|
assert.equal(nameFromSourceToken("js-media-banner-section"), "MediaBanner");
|
||||||
|
assert.equal(nameFromSourceToken("template--19797275672650"), "");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 4) planSections — a Shopify-id section beats a heading slug; noisy classes don't.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
describe("planSections: source-id naming precedence", () => {
|
||||||
|
it("names a section from its CMS section id (hash stripped) over generic evidence", () => {
|
||||||
|
const nav = el("nav", { y: 0, height: 62 }, {});
|
||||||
|
const hero = el("section", { y: 62, height: 800 }, {}, [el("h1", { y: 120, height: 60, x: 120, width: 900 }, {}, [text("Welcome")])]);
|
||||||
|
// A one-off band with NO heading but a clean Shopify id → SplitCalloutSection.
|
||||||
|
const callout = el("div", { y: 862, height: 600 }, {}, [
|
||||||
|
el("p", { y: 900, height: 40, x: 120, width: 600 }, {}, [text("Some marketing copy here")]),
|
||||||
|
], { id: "shopify-section-template--19797275672650__split_callout_JtTWTt" }, "shopify-section js-split-callout-section");
|
||||||
|
const b2 = el("section", { y: 1462, height: 500 }, {}, [el("h2", { y: 1500, height: 40, x: 120, width: 600 }, {}, [text("Everything you need today")]), el("p", { y: 1560, height: 24, x: 120, width: 600 }, {}, [text("extra")])]);
|
||||||
|
const b3 = el("section", { y: 1962, height: 500 }, {}, [el("h2", { y: 2000, height: 40, x: 120, width: 600 }, {}, [text("What customers say now")]), el("p", { y: 2060, height: 24, x: 120, width: 600 }, {}, [text("a")]), el("p", { y: 2090, height: 24, x: 120, width: 600 }, {}, [text("b")])]);
|
||||||
|
const footer = el("footer", { y: 2462, height: 400 }, {}, [el("a", { y: 2500, height: 20, x: 120, width: 200 }, {}, [text("Privacy")], {}, undefined)]);
|
||||||
|
const ir = page([nav, hero, callout, b2, b3, footer], 2862);
|
||||||
|
const names = [...planSections(ir).roots.values()];
|
||||||
|
assert.ok(names.includes("SplitCalloutSection"), `expected SplitCalloutSection in ${names.join(", ")}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT mine arbitrary utility classes (falls back to heading slug)", () => {
|
||||||
|
const nav = el("nav", { y: 0, height: 62 }, {});
|
||||||
|
const hero = el("section", { y: 62, height: 800 }, {}, [el("h1", { y: 120, height: 60, x: 120, width: 900 }, {}, [text("Welcome home")])]);
|
||||||
|
// Heading present, but the only source class is a noisy Webflow utility → use the heading.
|
||||||
|
const band = el("section", { y: 862, height: 600 }, {}, [
|
||||||
|
el("h2", { y: 900, height: 40, x: 120, width: 600 }, {}, [text("Latest releases from us")]),
|
||||||
|
], {}, "g_section_space duraldar-cta_section w-variant-60a7ad7d");
|
||||||
|
const b2 = el("section", { y: 1462, height: 500 }, {}, [el("h2", { y: 1500, height: 40, x: 120, width: 600 }, {}, [text("Everything you need")]), el("p", { y: 1560, height: 24, x: 120, width: 600 }, {}, [text("x")])]);
|
||||||
|
const footer = el("footer", { y: 1962, height: 400 }, {}, [el("a", { y: 2000, height: 20, x: 120, width: 200 }, {}, [text("Privacy")])]);
|
||||||
|
const ir = page([nav, hero, band, b2, footer], 2362);
|
||||||
|
const names = [...planSections(ir).roots.values()];
|
||||||
|
assert.ok(names.some((n) => /^LatestReleases/.test(n)), `expected heading slug, got ${names.join(", ")}`);
|
||||||
|
assert.ok(!names.some((n) => /Duraldar|Space/.test(n)), `must not mine utility classes: ${names.join(", ")}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { resolveSvgRootFill, isRealPaint } from "../src/generate/app.js";
|
||||||
|
|
||||||
|
describe("isRealPaint", () => {
|
||||||
|
it("treats none / empty / transparent as non-paint", () => {
|
||||||
|
for (const v of [undefined, null, "", " ", "none", "NONE", "transparent", "rgba(0, 0, 0, 0)", "rgba(0,0,0,0)"]) {
|
||||||
|
assert.equal(isRealPaint(v), false, `${String(v)} should not paint`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("treats a color / currentColor as a real paint", () => {
|
||||||
|
for (const v of ["rgb(255, 255, 255)", "#000", "currentColor", "red", "rgb(0, 0, 0)"]) {
|
||||||
|
assert.equal(isRealPaint(v), true, `${v} should paint`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSvgRootFill", () => {
|
||||||
|
it("keeps a raw fill that is itself a real paint", () => {
|
||||||
|
assert.deepEqual(resolveSvgRootFill("#123456", { fill: "rgb(255,255,255)", color: "rgb(0,0,0)" }), { mode: "keep" });
|
||||||
|
assert.deepEqual(resolveSvgRootFill("red", null), { mode: "keep" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back when no raw fill is declared (existing currentColor default)", () => {
|
||||||
|
assert.deepEqual(resolveSvgRootFill(undefined, { fill: "rgb(255,255,255)", color: "rgb(255,255,255)" }), { mode: "fallback" });
|
||||||
|
assert.deepEqual(resolveSvgRootFill(null, null), { mode: "fallback" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers currentColor when fill=none but computed fill tracks the element color (wordmark case)", () => {
|
||||||
|
// The a16z logo case: fill="none" attribute, but CSS `fill: currentColor` with white color.
|
||||||
|
const r = resolveSvgRootFill("none", { fill: "rgb(255, 255, 255)", color: "rgb(255, 255, 255)" });
|
||||||
|
assert.equal(r.mode, "emit");
|
||||||
|
assert.equal(r.value, "currentColor");
|
||||||
|
assert.equal(r.emitColor, "rgb(255, 255, 255)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the literal computed fill when it differs from the element color", () => {
|
||||||
|
const r = resolveSvgRootFill("none", { fill: "rgb(255, 0, 0)", color: "rgb(0, 0, 0)" });
|
||||||
|
assert.equal(r.mode, "emit");
|
||||||
|
assert.equal(r.value, "rgb(255, 0, 0)");
|
||||||
|
assert.equal(r.emitColor, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a genuinely unfilled svg as none (computed fill also none)", () => {
|
||||||
|
assert.deepEqual(resolveSvgRootFill("none", { fill: "none", color: "rgb(0,0,0)" }), { mode: "keep" });
|
||||||
|
assert.deepEqual(resolveSvgRootFill("none", { fill: "rgba(0, 0, 0, 0)", color: "rgb(0,0,0)" }), { mode: "keep" });
|
||||||
|
// No computed paint captured at all → cannot prove it paints → stays none.
|
||||||
|
assert.deepEqual(resolveSvgRootFill("none", null), { mode: "keep" });
|
||||||
|
assert.deepEqual(resolveSvgRootFill("none", undefined), { mode: "keep" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not emit color when the recovered fill is currentColor but color is not a real paint", () => {
|
||||||
|
// fill == color but color is transparent → cannot be a meaningful currentColor recovery;
|
||||||
|
// the equality branch is guarded on isRealPaint(color), so it emits the literal fill instead.
|
||||||
|
const r = resolveSvgRootFill("none", { fill: "rgb(0, 128, 0)", color: "transparent" });
|
||||||
|
assert.equal(r.mode, "emit");
|
||||||
|
assert.equal(r.value, "rgb(0, 128, 0)");
|
||||||
|
assert.equal(r.emitColor, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -133,3 +133,21 @@ describe("declToUtil letter-spacing sub-0.1px preservation", () => {
|
|||||||
assert.equal(declToUtil("width", "204.9994px"), "w-[205px]");
|
assert.equal(declToUtil("width", "204.9994px"), "w-[205px]");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// text-wrap: modern heading line-balancing. `balance`/`pretty` rebalance where a title wraps;
|
||||||
|
// without emitting them a two-line heading breaks differently in the clone. Tailwind v4 has the
|
||||||
|
// named utilities text-balance / text-pretty / text-nowrap / text-wrap; anything else (e.g.
|
||||||
|
// `stable`) falls through to the arbitrary property escape.
|
||||||
|
describe("declToUtil text-wrap", () => {
|
||||||
|
it("maps balance and pretty to the named Tailwind v4 utilities", () => {
|
||||||
|
assert.equal(declToUtil("text-wrap", "balance"), "text-balance");
|
||||||
|
assert.equal(declToUtil("text-wrap", "pretty"), "text-pretty");
|
||||||
|
});
|
||||||
|
it("maps wrap and nowrap to their named utilities", () => {
|
||||||
|
assert.equal(declToUtil("text-wrap", "wrap"), "text-wrap");
|
||||||
|
assert.equal(declToUtil("text-wrap", "nowrap"), "text-nowrap");
|
||||||
|
});
|
||||||
|
it("falls back to the arbitrary property for an unmapped value", () => {
|
||||||
|
assert.equal(declToUtil("text-wrap", "stable"), "[text-wrap:stable]");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { selectVideoSourceIndex, type VideoSourceCandidate } from "../src/capture/capture.js";
|
||||||
|
|
||||||
|
// Predicate builders for the injected matchMedia / canPlayType.
|
||||||
|
const matchesAny = (matching: Set<string>) => (m: string) => matching.has(m);
|
||||||
|
const canPlayAll = () => true;
|
||||||
|
const canPlayNone = () => false;
|
||||||
|
|
||||||
|
describe("selectVideoSourceIndex (video <source> resource-selection)", () => {
|
||||||
|
it("picks the first source whose media matches; missing media matches unconditionally", () => {
|
||||||
|
// Aspect-gated hero: landscape sources first, portrait after, plain fall-through last.
|
||||||
|
const sources: VideoSourceCandidate[] = [
|
||||||
|
{ media: "(min-aspect-ratio: 21/9)", type: "video/webm" },
|
||||||
|
{ media: "(min-aspect-ratio: 16/9)", type: "video/webm" },
|
||||||
|
{ media: "(max-aspect-ratio: 4/5)", type: "video/webm" },
|
||||||
|
{ media: null, type: "video/webm" }, // plain fall-through
|
||||||
|
];
|
||||||
|
// Portrait viewport: only the max-aspect-ratio query matches → index 2.
|
||||||
|
assert.equal(
|
||||||
|
selectVideoSourceIndex(sources, matchesAny(new Set(["(max-aspect-ratio: 4/5)"])), canPlayAll),
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
// Landscape/wide viewport: the min-aspect 16/9 query matches → index 1.
|
||||||
|
assert.equal(
|
||||||
|
selectVideoSourceIndex(sources, matchesAny(new Set(["(min-aspect-ratio: 16/9)"])), canPlayAll),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
// Nothing matches → falls through to the plain no-media source (index 3).
|
||||||
|
assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set()), canPlayAll), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a source whose type the UA cannot play", () => {
|
||||||
|
const sources: VideoSourceCandidate[] = [
|
||||||
|
{ media: null, type: "video/webm" }, // unplayable here
|
||||||
|
{ media: null, type: "video/mp4" },
|
||||||
|
];
|
||||||
|
const canPlayMp4 = (t: string) => t === "video/mp4";
|
||||||
|
assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set()), canPlayMp4), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a missing/empty type as never disqualifying", () => {
|
||||||
|
const sources: VideoSourceCandidate[] = [
|
||||||
|
{ media: null, type: "" },
|
||||||
|
{ media: null },
|
||||||
|
];
|
||||||
|
// canPlay is never consulted when type is absent, even if it would reject everything.
|
||||||
|
assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set()), canPlayNone), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns -1 when no source is eligible", () => {
|
||||||
|
const sources: VideoSourceCandidate[] = [
|
||||||
|
{ media: "(max-aspect-ratio: 4/5)", type: "video/webm" },
|
||||||
|
];
|
||||||
|
assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set()), canPlayNone), -1);
|
||||||
|
assert.equal(selectVideoSourceIndex([], matchesAny(new Set()), canPlayAll), -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is deterministic and first-match-wins in document order", () => {
|
||||||
|
const sources: VideoSourceCandidate[] = [
|
||||||
|
{ media: "(min-width: 100px)", type: "video/mp4" },
|
||||||
|
{ media: "(min-width: 100px)", type: "video/mp4" }, // also eligible, but later
|
||||||
|
];
|
||||||
|
assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set(["(min-width: 100px)"])), canPlayAll), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -317,3 +317,35 @@ describe("walker sizing probe: circular authored-height guard", () => {
|
|||||||
assert.equal(pct.sizing!.hAuto, false, "percentage fill is not content-sized");
|
assert.equal(pct.sizing!.hAuto, false, "percentage fill is not content-sized");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("walker text-wrap capture", () => {
|
||||||
|
let browser: Browser;
|
||||||
|
let page: Page;
|
||||||
|
before(async () => {
|
||||||
|
browser = await chromium.launch();
|
||||||
|
page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const capture = async (html: string) => {
|
||||||
|
await page.setContent(html);
|
||||||
|
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||||
|
return page.evaluate(collectPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("captures text-wrap:balance on a heading (modern line-balancing)", async () => {
|
||||||
|
// Real case: a hero heading authored `text-wrap:balance` wraps its two lines evenly; without
|
||||||
|
// capturing the prop the clone wraps it lopsidedly.
|
||||||
|
const snap = await capture(`<h1 style="text-wrap:balance">BUILT RUGGED. WORN DAILY.</h1>`);
|
||||||
|
const h1 = findByTag(snap.root, "h1")!;
|
||||||
|
assert.equal(h1.computed.textWrap, "balance", "text-wrap:balance is captured");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures text-wrap:pretty", async () => {
|
||||||
|
const snap = await capture(`<p style="text-wrap:pretty">Some flowing paragraph text here.</p>`);
|
||||||
|
const p = findByTag(snap.root, "p")!;
|
||||||
|
assert.equal(p.computed.textWrap, "pretty", "text-wrap:pretty is captured");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user