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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
// 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.
|
||||
@@ -804,12 +839,57 @@ async function captureScreenshot(
|
||||
* 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
|
||||
* 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> {
|
||||
try {
|
||||
await page.evaluate(async () => {
|
||||
const vids = Array.from(document.querySelectorAll("video"));
|
||||
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>[] = [];
|
||||
for (const v of vids) {
|
||||
try { v.pause(); } catch { /* ignore */ }
|
||||
|
||||
@@ -24,6 +24,13 @@ const PSEUDO_PROPS = [
|
||||
|
||||
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
|
||||
// active/inactive state. Captured per discrete state so the generated controller
|
||||
// can toggle between them faithfully (M2: tabs + accordion).
|
||||
@@ -363,11 +370,42 @@ export async function captureInteractions(page: Page, opts?: { maxCandidates?: n
|
||||
return o;
|
||||
}, { capId, props: PSEUDO_PROPS as unknown as string[] });
|
||||
|
||||
const diff = (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;
|
||||
const diff = diffStyle;
|
||||
|
||||
// Pseudo-state driver via CDP `CSS.forcePseudoState`. Pointer-based hovering (page.hover)
|
||||
// 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
|
||||
// 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>> = {};
|
||||
|
||||
for (const capId of candidates) {
|
||||
const sel = `[data-cid-cap="${capId}"]`;
|
||||
const base = await read(capId);
|
||||
if (!base) continue;
|
||||
const nodeId = await nodeIdFor(capId);
|
||||
const hiddenBase = await readHiddenDesc(capId); // hidden descendants → hover-reveal candidates
|
||||
const hiddenCaps = Object.keys(hiddenBase);
|
||||
// :hover — settle before reading: framer-style hover is driven by JS (Framer Motion)
|
||||
// with a short transition, so an immediate read catches the pre-transition frame and
|
||||
// sees no delta (the nav-link hover was missed this way). Wait for the transition to land.
|
||||
try {
|
||||
await page.hover(sel, { timeout: 1200, force: true });
|
||||
await page.waitForTimeout(180);
|
||||
// :hover — force the pseudo-class on the node (geometry-independent; see `force` above),
|
||||
// then settle before reading: an authored `:hover`/JS transition animates from the resting
|
||||
// frame, so an immediate read catches the pre-transition value and sees no delta. Wait for it.
|
||||
if (await force(nodeId, ["hover"])) {
|
||||
await settlePseudo();
|
||||
const h = await read(capId);
|
||||
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
|
||||
// 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) {
|
||||
await page.waitForTimeout(380); // let the reveal transition fully finish before reading
|
||||
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;
|
||||
}
|
||||
} catch { /* not hoverable (covered/offscreen) — skip */ }
|
||||
await page.mouse.move(1, 1).catch(() => {});
|
||||
// :focus (only for focusable-ish; cheap to attempt, guarded)
|
||||
try {
|
||||
await page.focus(sel, { timeout: 800 });
|
||||
await force(nodeId, []); // clear :hover before probing :focus
|
||||
}
|
||||
// :focus — force the pseudo-class the same way (also occlusion-independent).
|
||||
if (await force(nodeId, ["focus"])) {
|
||||
await settlePseudo();
|
||||
const f = await read(capId);
|
||||
if (f) { const d = diff(base, f); if (Object.keys(d).length) focus[capId] = d; }
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
|
||||
} catch { /* not focusable — skip */ }
|
||||
await force(nodeId, []); // restore the resting state
|
||||
}
|
||||
}
|
||||
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 });
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ export type RawNode = {
|
||||
// clone renders the browser's default gray, losing the authored placeholder color/type.
|
||||
placeholder?: RawStyle;
|
||||
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[];
|
||||
};
|
||||
|
||||
@@ -138,6 +142,11 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
"letterSpacing", "wordSpacing", "textAlign", "textTransform",
|
||||
"textDecorationLine", "textDecorationColor", "textDecorationStyle",
|
||||
"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",
|
||||
// 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
|
||||
@@ -464,6 +473,14 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
// Inline SVG → raw markup, no recursion.
|
||||
if (tag === "svg") {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user