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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user