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
+36
View File
@@ -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")}`);
});
});
// 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")})`);
});
});
+90
View File
@@ -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");
});
});
+177
View File
@@ -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(", ")}`);
});
});
+60
View File
@@ -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);
});
});
+18
View File
@@ -133,3 +133,21 @@ describe("declToUtil letter-spacing sub-0.1px preservation", () => {
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]");
});
});
+66
View File
@@ -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);
});
});
+32
View File
@@ -317,3 +317,35 @@ describe("walker sizing probe: circular authored-height guard", () => {
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");
});
});