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:
Samraaj Bath
2026-07-04 02:22:15 -07:00
co-authored by Claude Fable 5
parent db0054e43b
commit 6e4921884c
16 changed files with 968 additions and 54 deletions
+66 -6
View File
@@ -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.
const inner = svgInnerForNode(node, ctx);
const svgAttrs = extractSvgAttrs(node.rawHTML);
let hasFillAttr = false;
let rawFill: string | undefined;
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;
const reactName = SVG_ATTR_RENAME[k] ?? k;
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)]);
}
// Raw SVG icons often rely on site CSS (`svg { fill: currentColor }` or a class) that is
// stripped during extraction. If the root didn't declare a fill, inherit the surrounding text
// color so monochrome wordmarks/icons don't fall back to the browser's black default.
if (!hasFillAttr && !props.some(([pk]) => pk === "fill")) props.push(["fill", JSON.stringify("currentColor")]);
// Reconcile the root fill against the captured COMPUTED paint. A raw `fill="none"` only looks
// unfilled: site CSS (`fill: currentColor` on the wordmark, an inherited color) may actually
// paint it, and that CSS is stripped during extraction. resolveSvgRootFill recovers the real
// 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)} }`]);
}
@@ -460,6 +476,50 @@ function extractSvgAttrs(outerHTML: string): Array<[string, string]> {
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
// 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
+3 -1
View File
@@ -74,7 +74,7 @@ function isViewportHeight(value: string, width: number): boolean {
const INHERITED = new Set([
"color", "fontFamily", "fontSize", "fontWeight", "fontStyle", "lineHeight",
"letterSpacing", "wordSpacing", "textAlign", "textTransform", "whiteSpace",
"wordBreak", "overflowWrap", "textIndent", "fontVariantCaps", "fontFeatureSettings",
"wordBreak", "overflowWrap", "textWrap", "textIndent", "fontVariantCaps", "fontFeatureSettings",
"listStyleType", "listStylePosition", "writingMode", "direction", "cursor",
"textShadow", "visibility", "textDecorationColor", "webkitTextStroke",
"webkitTextFillColor",
@@ -139,6 +139,8 @@ const GENERIC: Array<{ prop: string; def: string | string[] }> = [
{ prop: "textDecorationStyle", def: "solid" },
{ prop: "whiteSpace", def: "__never__" }, { prop: "wordBreak", 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: "fontFeatureSettings", def: "__never__" }, { prop: "listStyleType", def: "__never_list__" },
{ prop: "listStylePosition", def: "__never__" }, { prop: "writingMode", def: "__never__" },
+83 -1
View File
@@ -100,6 +100,83 @@ function identName(name: string): string {
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 {
if (n.tag === "nav" || n.tag === "header") return true;
if (subtreeHasTag(n, "nav")) return true;
@@ -279,8 +356,13 @@ export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan {
} else if (recipeName) {
name = recipeName;
} 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));
name = slug ? `${slug}Section`
name = sourceName ? `${sourceName}Section`
: slug ? `${slug}Section`
: subtreeHasTag(sec, "form", 6) ? "ContactSection"
: subtreeHasTag(sec, "video", 6) || subtreeHasTag(sec, "iframe", 6) ? "MediaSection"
: `Section${i + 1}`;
+27 -5
View File
@@ -18,6 +18,7 @@
import type { IR, IRNode } from "../normalize/ir.js";
import { isTextChild } from "../normalize/ir.js";
import { collectNodeRules, computeBands, keyframesCss, type NodeRule } from "./css.js";
import { colorClusterKey } from "../infer/semanticTokens.js";
import type { InteractionCapture, StyleDelta } from "../capture/interactions.js";
// ---- 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" },
"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" },
"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-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" },
@@ -1039,11 +1041,12 @@ export type TailwindOutput = {
export type ColorInterner = {
defs: Map<string, string>; // minted token name → literal value
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)
seq: { n: number }; // monotonic counter for clr-N names
};
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). */
export function colorDefsCssOf(it: ColorInterner): string {
@@ -1126,12 +1129,31 @@ export function buildTailwind(ir: IR, assetMap: Map<string, string>, colorVar?:
indexNode(ir.root);
// Color interner: every distinct color value → a stable theme token referenced as
// var(--…). Palette colors already arrive as var(--color-*) (kept, semantic); any other
// color is minted a numbered token (--clr-N) so raw rgb/hex NEVER lands in markup.
// The token holds the literal value, minted in deterministic first-encounter order.
// var(--…). A color the semantic palette recognizes (exactly OR within the grader's ±2
// sRGB tolerance — the same tolerance css.ts already trusts for color/bg/border) reuses
// 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 semantic = colorVar?.(literal);
if (semantic) { const n = tokenName(semantic); if (n) colorTokens.add(n); return semantic; }
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);
return `var(--${name})`;
};