Emission geometry wave: occupying off-screen items, height/width chain trust, subgrid, track-relative percentages, banded override precedence
- Off-viewport in-flow items (carousel slides beyond the right edge) keep their per-viewport geometry deltas - they occupy layout and set track cross-sizes even when not visible - Height chains: single-viewport probe verdicts accepted for nodes that only paint at one band; un-probed picture/object-fit:cover children filling the parent count as height-deriving; a node whose own probe says authored-explicit keeps its height at emission (twin of the flow guard) - Symmetric authored-width circular guard in the walker (height-only before), harvesting shadow-root adopted/style sheets for custom elements - Auto margins only when width is genuinely content-constrained; gutter- inset full-width blocks keep literal margins - grid-template-rows: subgrid never dropped; variant-only subgrid intent re-enters via partial-coverage escape; grid-item percentage widths bail to auto (they resolve against the track, not the container) - Banded raw-prop detection counts non-raw band overrides so a banded bg-none reset isn't beaten by an inlined gradient 399 tests pass (13 new), typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
144e7ab16b
commit
1d68f16f96
@@ -0,0 +1,99 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { IR, IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
|
||||
import { buildTailwind } from "../src/generate/tailwind.js";
|
||||
|
||||
const VPS = [375, 1280];
|
||||
const CANONICAL = 1280;
|
||||
|
||||
function computed(over: StyleMap = {}): StyleMap {
|
||||
return { display: "block", position: "static", visibility: "visible", listStyleType: "disc", listStylePosition: "outside", ...over };
|
||||
}
|
||||
|
||||
/** Per-viewport node: distinct computed style per width so a band delta is produced. */
|
||||
function pvNode(id: string, tag: string, byVp: Record<number, StyleMap>, children: IRChild[] = [], srcClass?: string): IRNode {
|
||||
const computedByVp: Record<number, StyleMap> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
for (const vp of VPS) {
|
||||
computedByVp[vp] = computed(byVp[vp]);
|
||||
bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 100 };
|
||||
visibleByVp[vp] = true;
|
||||
}
|
||||
const n: IRNode = { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
|
||||
if (srcClass) n.srcClass = srcClass;
|
||||
return n;
|
||||
}
|
||||
|
||||
function irWith(root: IRNode): IR {
|
||||
return {
|
||||
doc: {
|
||||
sourceUrl: "https://example.test/banding",
|
||||
title: "Banding Fixture",
|
||||
lang: "en",
|
||||
charset: "UTF-8",
|
||||
metaViewport: "width=device-width, initial-scale=1",
|
||||
viewports: VPS,
|
||||
sampleViewports: VPS,
|
||||
canonicalViewport: CANONICAL,
|
||||
perViewport: Object.fromEntries(VPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)", bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial" }])),
|
||||
nodeCount: 3,
|
||||
keyframes: [],
|
||||
},
|
||||
root,
|
||||
};
|
||||
}
|
||||
|
||||
// C3 — a base RAW value (gradient) with a band that RESETS the same prop to a NON-raw value
|
||||
// (`background-image: none` → utility `bg-[none]`) must keep the base gradient in ditto.css, not
|
||||
// inline. An inline style out-specifies the @media reset, painting the gradient where the source
|
||||
// turned it off (the mobile shimmer-blob defect). The banded-props set must count ALL band-touched
|
||||
// props, not just the raw ones.
|
||||
describe("buildTailwind banded non-raw override keeps base gradient in ditto.css (C3)", () => {
|
||||
it("does not inline a base gradient when a band resets background-image to none", () => {
|
||||
const grad = "linear-gradient(90deg, rgb(1, 2, 3), rgb(4, 5, 6))";
|
||||
// 1280 (canonical/base): gradient painted; 375 (mobile band): background-image none.
|
||||
const el = pvNode("n1", "span", {
|
||||
375: { backgroundImage: "none" },
|
||||
1280: { backgroundImage: grad },
|
||||
}, [{ text: "Grepping" } as IRChild]);
|
||||
const root = pvNode("n0", "body", { 375: {}, 1280: {} }, [el]);
|
||||
const tw = buildTailwind(irWith(root), new Map());
|
||||
const inline = tw.styleOf.get("n1");
|
||||
const inlineHasGrad = !!inline && [...inline.values()].some((v) => v.includes("gradient("));
|
||||
assert.ok(!inlineHasGrad, `banded gradient must NOT be inlined, got inline: ${inline ? JSON.stringify([...inline]) : "none"}`);
|
||||
// It must instead live in ditto.css (extraCss folded into pseudoCss) as a [data-cid] rule.
|
||||
assert.ok(/\[data-cid="n1"\][\s\S]*gradient\(/.test(tw.pseudoCss), `base gradient must be a ditto.css rule, got:\n${tw.pseudoCss}`);
|
||||
});
|
||||
|
||||
it("still inlines a base gradient with NO band touching background-image (unchanged path)", () => {
|
||||
const grad = "linear-gradient(90deg, rgb(1, 2, 3), rgb(4, 5, 6))";
|
||||
const el = pvNode("n1", "span", {
|
||||
375: { backgroundImage: grad },
|
||||
1280: { backgroundImage: grad },
|
||||
}, [{ text: "static gradient" } as IRChild]);
|
||||
const root = pvNode("n0", "body", { 375: {}, 1280: {} }, [el]);
|
||||
const tw = buildTailwind(irWith(root), new Map());
|
||||
const inline = tw.styleOf.get("n1");
|
||||
const inlineHasGrad = !!inline && [...inline.values()].some((v) => v.includes("gradient("));
|
||||
assert.ok(inlineHasGrad, `a truly static (un-banded) gradient is still inlined, got inline: ${inline ? JSON.stringify([...inline]) : "none"}`);
|
||||
});
|
||||
});
|
||||
|
||||
// C1 (tailwind intent side) — a variant-only `max-lg:grid-rows-subgrid` covers only a subset of
|
||||
// viewports (the axis resolves to explicit tracks at ≥lg). The partial-coverage bail must let subgrid
|
||||
// through as a banded variant instead of discarding it.
|
||||
describe("buildTailwind partial-coverage subgrid intent (C1)", () => {
|
||||
it("emits grid-rows-subgrid from a variant-only source class", () => {
|
||||
// Grid at both widths; subgrid computed at the mobile band (375) and explicit tracks at 1280.
|
||||
// Source authored `max-lg:grid-rows-subgrid`.
|
||||
const el = pvNode("n1", "div", {
|
||||
375: { display: "grid", gridTemplateRows: "subgrid" },
|
||||
1280: { display: "grid", gridTemplateRows: "340px 340px" },
|
||||
}, [{ text: "card" } as IRChild], "max-lg:grid-rows-subgrid");
|
||||
const root = pvNode("n0", "body", { 375: {}, 1280: {} }, [el]);
|
||||
const tw = buildTailwind(irWith(root), new Map());
|
||||
const cls = tw.classOf.get("n1") || "";
|
||||
assert.ok(/grid-rows-subgrid/.test(cls), `subgrid must survive the partial-intent path, got class: "${cls}"`);
|
||||
});
|
||||
});
|
||||
@@ -864,3 +864,194 @@ describe("generateCss circular carousel slide with a block-level fill child (FIX
|
||||
assert.ok(/width:220px/.test(slide), `the library-sized slide must keep its 220px width, got: ${slide}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Emission-geometry fix wave (T1/T2/T3/T5, C1 css side, C2)
|
||||
// ===========================================================================
|
||||
|
||||
// T5 — a FULL-WIDTH block merely inset by a fixed gutter (`mx-4 md:mx-8`: box == container − 2·gutter,
|
||||
// width TRACKS the container) must NOT be auto-centred. Its symmetric margins vary per breakpoint and
|
||||
// the probe reads wAuto (auto reproduces the inset width), so it slips past the fill guard — but
|
||||
// emitting margin:auto on a width-dropped block resolves the margins to 0 and blows it full-bleed.
|
||||
describe("generateCss auto-margin gutter-inset guard (T5)", () => {
|
||||
const auto = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false });
|
||||
it("keeps literal px margins on a full-width gutter-inset block (width tracks the container)", () => {
|
||||
// mx-4 (16px) mobile, mx-8 (32px) desktop: box width == container − 2·gutter at every width, so it
|
||||
// TRACKS the container (343→704→1216). Not a centred content box.
|
||||
const child = xNode("n1", "div", {
|
||||
375: { cs: { display: "block", marginLeft: "16px", marginRight: "16px", width: "343px" }, bbox: { x: 16, y: 0, width: 343, height: 40 }, sizing: auto() },
|
||||
768: { cs: { display: "block", marginLeft: "32px", marginRight: "32px", width: "704px" }, bbox: { x: 32, y: 0, width: 704, height: 40 }, sizing: auto() },
|
||||
1280: { cs: { display: "block", marginLeft: "32px", marginRight: "32px", width: "1216px" }, bbox: { x: 32, y: 0, width: 1216, height: 40 }, sizing: auto() },
|
||||
});
|
||||
const parent = xNode("n0", "body", {
|
||||
375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 375, height: 40 } },
|
||||
768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 768, height: 40 } },
|
||||
1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 1280, height: 40 } },
|
||||
}, [child]);
|
||||
const css = generateCss(xIr(parent), new Map());
|
||||
const all = allRulesX(css, "n1");
|
||||
assert.ok(!/margin-left:auto/.test(all), `a gutter-inset full-width block must not be auto-centred, got: ${all}`);
|
||||
assert.ok(/margin-left:16px|margin-left:32px/.test(all), `it must keep its literal px margins, got: ${all}`);
|
||||
});
|
||||
});
|
||||
|
||||
// T3 — an authored height whose OWN probe says hAuto:false && hFill:false is load-bearing. The
|
||||
// structural "taller than content" tests can't fire when an inline child's extent equals the box, so
|
||||
// emission must trust the node's own probe (the twin of the heightFlows keep) and emit the height.
|
||||
describe("generateCss authored-height kept via own probe (T3)", () => {
|
||||
it("emits the height when the node's own probe is hAuto:false, hFill:false", () => {
|
||||
const explicit = (): RawSizing => ({ wAuto: false, wFill: false, hAuto: false, hFill: false });
|
||||
// Wrapper's only in-flow child is an inline <a> whose box equals the wrapper (content extent ==
|
||||
// box height), so neither the taller-than-content nor overflow test can fire.
|
||||
const link = xNode("n2", "a", {
|
||||
375: { cs: { display: "inline", position: "static" }, bbox: { x: 0, y: 0, width: 220, height: 300 } },
|
||||
768: { cs: { display: "inline", position: "static" }, bbox: { x: 0, y: 0, width: 220, height: 300 } },
|
||||
1280: { cs: { display: "inline", position: "static" }, bbox: { x: 0, y: 0, width: 220, height: 300 } },
|
||||
}, [{ text: "shop" } as IRChild]);
|
||||
const wrapper = xNode("n1", "div", {
|
||||
375: { cs: { display: "block", position: "static", height: "300px" }, bbox: { x: 0, y: 0, width: 220, height: 300 }, sizing: explicit() },
|
||||
768: { cs: { display: "block", position: "static", height: "300px" }, bbox: { x: 0, y: 0, width: 220, height: 300 }, sizing: explicit() },
|
||||
1280: { cs: { display: "block", position: "static", height: "300px" }, bbox: { x: 0, y: 0, width: 220, height: 300 }, sizing: explicit() },
|
||||
}, [link]);
|
||||
const parent = xNode("n0", "body", {
|
||||
375: { bbox: { x: 0, y: 0, width: 375, height: 300 } },
|
||||
768: { bbox: { x: 0, y: 0, width: 768, height: 300 } },
|
||||
1280: { bbox: { x: 0, y: 0, width: 1280, height: 300 } },
|
||||
}, [wrapper]);
|
||||
const css = generateCss(xIr(parent), new Map());
|
||||
const all = allRulesX(css, "n1");
|
||||
assert.ok(/height:300px/.test(all), `authored height must survive on own-probe evidence, got: ${all}`);
|
||||
});
|
||||
});
|
||||
|
||||
// C1 (css side) — a computed `grid-template-rows: subgrid` is a STRUCTURAL keyword, not a baked px
|
||||
// regime. It must NEVER be dropped by the reflow-mode dropGridRows path (which strips px row tracks).
|
||||
describe("collectNodeRules never drops a subgrid rows template (C1)", () => {
|
||||
function gridFixture() {
|
||||
const layer = xNode("n2", "a", {
|
||||
375: { cs: { display: "grid", position: "static", gridTemplateRows: "subgrid" }, bbox: { x: 0, y: 0, width: 375, height: 400 } },
|
||||
768: { cs: { display: "grid", position: "static", gridTemplateRows: "subgrid" }, bbox: { x: 0, y: 0, width: 768, height: 400 } },
|
||||
1280: { cs: { display: "grid", position: "static", gridTemplateRows: "subgrid" }, bbox: { x: 0, y: 0, width: 1280, height: 400 } },
|
||||
});
|
||||
const outer = xNode("n1", "div", {
|
||||
375: { cs: { display: "grid", position: "static", gridTemplateRows: "139px 717px" }, bbox: { x: 0, y: 0, width: 375, height: 856 } },
|
||||
768: { cs: { display: "grid", position: "static", gridTemplateRows: "139px 717px" }, bbox: { x: 0, y: 0, width: 768, height: 856 } },
|
||||
1280: { cs: { display: "grid", position: "static", gridTemplateRows: "340px 340px" }, bbox: { x: 0, y: 0, width: 1280, height: 680 } },
|
||||
}, [layer]);
|
||||
return xNode("n0", "body", {
|
||||
375: { bbox: { x: 0, y: 0, width: 375, height: 856 } },
|
||||
768: { bbox: { x: 0, y: 0, width: 768, height: 856 } },
|
||||
1280: { bbox: { x: 0, y: 0, width: 1280, height: 680 } },
|
||||
}, [outer]);
|
||||
}
|
||||
|
||||
it("emits grid-template-rows:subgrid even in reflow mode", () => {
|
||||
const rules = collectNodeRules(xIr(gridFixture()), new Map(), undefined, undefined, undefined, true);
|
||||
const nr = rules.get("n2");
|
||||
assert.ok(nr, "subgrid layer rule must exist");
|
||||
const all = [nr!.base.get("grid-template-rows"), ...nr!.bands.map((b) => b.decls.get("grid-template-rows"))].filter(Boolean).join(";");
|
||||
assert.ok(/subgrid/.test(all), `subgrid keyword must survive reflow dropGridRows, got: ${all}`);
|
||||
});
|
||||
});
|
||||
|
||||
// C2 — a grid ITEM's `%` width resolves against its GRID AREA (track), not the grid container's
|
||||
// content box. For a multi-column grid the parent-content-box ratio is the wrong base, so the fluid
|
||||
// per-band percent path must BAIL (the item falls to auto/fixed) rather than emit a collapsing %.
|
||||
describe("generateCss grid-item percent width bails to track (C2)", () => {
|
||||
function gridItemFixture() {
|
||||
// 4-column grid: each track ~302px. The item's width == its track (varies with the container), so
|
||||
// fluidPercentByVp would compute ~24.5% of the 1240px content box — wrong (browser applies it to
|
||||
// the 302px track → 74px). Must not emit a percent width.
|
||||
const item = xNode("n2", "div", {
|
||||
375: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 343, height: 108 } },
|
||||
768: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 176, height: 108 } },
|
||||
1280: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 302, height: 108 } },
|
||||
}, [{ text: "changelog card" } as IRChild]);
|
||||
const grid = xNode("n1", "div", {
|
||||
375: { cs: { display: "grid", position: "static", gridTemplateColumns: "343px" }, bbox: { x: 0, y: 0, width: 375, height: 108 } },
|
||||
768: { cs: { display: "grid", position: "static", gridTemplateColumns: "176px 176px 176px" }, bbox: { x: 0, y: 0, width: 768, height: 108 } },
|
||||
1280: { cs: { display: "grid", position: "static", gridTemplateColumns: "302px 302px 302px 302px" }, bbox: { x: 0, y: 0, width: 1240, height: 108 } },
|
||||
}, [item]);
|
||||
return xNode("n0", "body", {
|
||||
375: { bbox: { x: 0, y: 0, width: 375, height: 108 } },
|
||||
768: { bbox: { x: 0, y: 0, width: 768, height: 108 } },
|
||||
1280: { bbox: { x: 0, y: 0, width: 1280, height: 108 } },
|
||||
}, [grid]);
|
||||
}
|
||||
|
||||
it("does not emit a container-content-box percent width for a multi-column grid item", () => {
|
||||
const css = generateCss(xIr(gridItemFixture()), new Map());
|
||||
const all = allRulesX(css, "n2");
|
||||
assert.ok(!/width:24\.5%|width:24%/.test(all), `grid item must not resolve % against the wrong containing block, got: ${all}`);
|
||||
});
|
||||
});
|
||||
|
||||
// T1 — an off-screen-right (invisible-by-vp) in-flow flex slide still OCCUPIES layout and sets its
|
||||
// track's cross-size. The base rule bakes canonical (desktop) geometry; without a per-viewport delta
|
||||
// the frozen desktop width inflates the mobile track. The band must carry the slide's measured
|
||||
// per-viewport geometry, not just a hide.
|
||||
describe("collectNodeRules off-screen in-flow slide per-viewport geometry (T1)", () => {
|
||||
function carousel() {
|
||||
// Slide is visible at 1280 (285px desktop) but off-screen-right at 375 (invisible-by-vp, measured
|
||||
// 220px). It is in-flow (static), display:block, non-zero bbox at 375 — occupying.
|
||||
const slide = xNode("n2", "li", {
|
||||
375: { cs: { display: "block", position: "static", flexShrink: "0", width: "220px", opacity: "1" }, bbox: { x: 400, y: 0, width: 220, height: 300 }, visible: false },
|
||||
768: { cs: { display: "block", position: "static", flexShrink: "0", width: "220px", opacity: "1" }, bbox: { x: 500, y: 0, width: 220, height: 300 }, visible: false },
|
||||
1280: { cs: { display: "block", position: "static", flexShrink: "0", width: "285px", opacity: "1" }, bbox: { x: 0, y: 0, width: 285, height: 400 }, visible: true },
|
||||
}, [{ text: "slide" } as IRChild]);
|
||||
const track = xNode("n1", "ul", {
|
||||
375: { cs: { display: "flex", position: "static" }, bbox: { x: 0, y: 0, width: 375, height: 300 } },
|
||||
768: { cs: { display: "flex", position: "static" }, bbox: { x: 0, y: 0, width: 768, height: 300 } },
|
||||
1280: { cs: { display: "flex", position: "static" }, bbox: { x: 0, y: 0, width: 1280, height: 400 } },
|
||||
}, [slide]);
|
||||
return xNode("n0", "body", {
|
||||
375: { bbox: { x: 0, y: 0, width: 375, height: 300 } },
|
||||
768: { bbox: { x: 0, y: 0, width: 768, height: 300 } },
|
||||
1280: { bbox: { x: 0, y: 0, width: 1280, height: 400 } },
|
||||
}, [track]);
|
||||
}
|
||||
|
||||
it("emits a per-viewport width delta for the off-screen slide (not just a hide)", () => {
|
||||
const rules = collectNodeRules(xIr(carousel()), new Map());
|
||||
const nr = rules.get("n2");
|
||||
assert.ok(nr, "slide rule must exist");
|
||||
// The narrow band must carry the measured 220px width, overriding the baked 285px desktop base.
|
||||
const narrowBand = nr!.bands.find((b) => /max-width/.test(b.media) && b.decls.has("width"));
|
||||
assert.ok(narrowBand, `off-screen slide must get a per-viewport width delta, got bands: ${JSON.stringify(nr!.bands.map((b) => ({ m: b.media, w: b.decls.get("width") })))}`);
|
||||
assert.equal(narrowBand!.decls.get("width"), "220px", "the band carries the measured narrow-vp width");
|
||||
});
|
||||
});
|
||||
|
||||
// T2 — an authored parent height whose only in-flow child is an UN-PROBED <picture>/object-fit:cover
|
||||
// replaced element that FILLS the box (border-box == parent content box) must be treated as a
|
||||
// height-deriving fill child (so the authored height is kept, not dropped as content-derived).
|
||||
describe("collectNodeRules picture fill child keeps authored height (T2)", () => {
|
||||
function heroFixture() {
|
||||
const explicitH = (): RawSizing => ({ wAuto: false, wFill: true, hAuto: false, hFill: false });
|
||||
// <picture> is a replaced tag the probe skips → no sizing; its border-box == the wrapper content
|
||||
// box at every vp. Height varies across vps so the flow path would otherwise try to drop it.
|
||||
const picture = xNode("n2", "picture", {
|
||||
375: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 375, height: 560 } },
|
||||
768: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 768, height: 500 } },
|
||||
1280: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 1280, height: 480 } },
|
||||
});
|
||||
const wrapper = xNode("n1", "div", {
|
||||
375: { cs: { display: "block", position: "static", height: "560px" }, bbox: { x: 0, y: 0, width: 375, height: 560 }, sizing: explicitH() },
|
||||
768: { cs: { display: "block", position: "static", height: "500px" }, bbox: { x: 0, y: 0, width: 768, height: 500 }, sizing: explicitH() },
|
||||
1280: { cs: { display: "block", position: "static", height: "480px" }, bbox: { x: 0, y: 0, width: 1280, height: 480 }, sizing: explicitH() },
|
||||
}, [picture]);
|
||||
return xNode("n0", "body", {
|
||||
375: { bbox: { x: 0, y: 0, width: 375, height: 560 } },
|
||||
768: { bbox: { x: 0, y: 0, width: 768, height: 500 } },
|
||||
1280: { bbox: { x: 0, y: 0, width: 1280, height: 480 } },
|
||||
}, [wrapper]);
|
||||
}
|
||||
|
||||
it("keeps the wrapper's authored height when a <picture> child fills it", () => {
|
||||
const rules = collectNodeRules(xIr(heroFixture()), new Map());
|
||||
const nr = rules.get("n1");
|
||||
assert.ok(nr, "wrapper rule must exist");
|
||||
const all = [nr!.base.get("height"), ...nr!.bands.map((b) => b.decls.get("height"))].filter(Boolean).join(";");
|
||||
assert.ok(/560px|500px|480px/.test(all), `authored height must survive with a picture fill child, got: ${all}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -318,6 +318,84 @@ describe("walker sizing probe: circular authored-height guard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// T4 — symmetric circular-WIDTH guard: an authored `width:24px` inside a SHRINK-TO-FIT parent makes
|
||||
// both width:auto and width:100% reproduce the box (the parent's width still holds), so the raw probe
|
||||
// reads wAuto/wFill and the width is dropped — collapsing the swatch in the clone. When the element
|
||||
// authors an explicit definite width (cascade or inline), the probe must trust that and clear both.
|
||||
describe("walker sizing probe: circular authored-width guard (T4)", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
||||
});
|
||||
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("keeps an explicit px width inside a shrink-wrap parent (cascade rule)", async () => {
|
||||
// The wrapper is an inline-block that shrink-wraps to the swatch; width:auto on the swatch still
|
||||
// reads 24px because the parent width holds → the raw verdict would be wAuto:true (dropped).
|
||||
const snap = await capture(`
|
||||
<style>
|
||||
.wrapper { display: inline-block; border: 1px solid #000; }
|
||||
.swatch { width: 24px; height: 24px; display: block; background: red; }
|
||||
</style>
|
||||
<span class="wrapper"><a class="swatch"></a></span>`);
|
||||
const swatch = findByClass(snap.root, "swatch")!;
|
||||
assert.ok(swatch.sizing, "swatch was probed");
|
||||
assert.equal(swatch.sizing!.wAuto, false, "explicit 24px width is not content-sized (auto)");
|
||||
assert.equal(swatch.sizing!.wFill, false, "explicit 24px width is authored, not a parent fill");
|
||||
});
|
||||
|
||||
it("keeps an explicit width authored via inline style", async () => {
|
||||
const snap = await capture(`
|
||||
<span style="display:inline-block;border:1px solid #000;">
|
||||
<a class="swatch2" style="width:24px;height:24px;display:block;background:blue;"></a>
|
||||
</span>`);
|
||||
const swatch = findByClass(snap.root, "swatch2")!;
|
||||
assert.ok(swatch.sizing, "swatch was probed");
|
||||
assert.equal(swatch.sizing!.wAuto, false, "inline explicit width is kept (not auto)");
|
||||
assert.equal(swatch.sizing!.wFill, false, "inline explicit width is not a fill");
|
||||
});
|
||||
|
||||
it("still detects a genuinely content-sized (auto) width as wAuto", async () => {
|
||||
// No authored width: an inline-block sizing to its text must stay droppable (wAuto:true).
|
||||
const snap = await capture(`
|
||||
<div style="display:block;"><span class="cw" style="display:inline-block;">hello content</span></div>`);
|
||||
const cw = findByClass(snap.root, "cw")!;
|
||||
assert.ok(cw.sizing, "cw was probed");
|
||||
assert.equal(cw.sizing!.wAuto, true, "content-sized width is still auto");
|
||||
});
|
||||
|
||||
it("harvests an explicit-width rule from a custom element's SHADOW ROOT stylesheet", async () => {
|
||||
// The width:24px rule lives inside the custom element's shadow root (a <style> in the shadow tree),
|
||||
// never in document.styleSheets. Without walking shadow-root sheets the harvest misses it and the
|
||||
// circular-width guard can't fire for the swatch link. Verify the shadow swatch keeps its width.
|
||||
const snap = await capture(`
|
||||
<script>
|
||||
customElements.define('color-swatch', class extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
const r = this.attachShadow({ mode: 'open' });
|
||||
r.innerHTML = '<style>.wrap{display:inline-block;border:1px solid #000}.pin{width:24px;height:24px;display:block;background:green}</style><span class="wrap"><a class="pin"></a></span>';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<color-swatch></color-swatch>`);
|
||||
const pin = findByClass(snap.root, "pin")!;
|
||||
assert.ok(pin.sizing, "shadow swatch was probed");
|
||||
assert.equal(pin.sizing!.wAuto, false, "shadow-root explicit 24px width is kept (not auto)");
|
||||
assert.equal(pin.sizing!.wFill, false, "shadow-root explicit width is not a fill");
|
||||
});
|
||||
});
|
||||
|
||||
describe("walker text-wrap capture", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
Reference in New Issue
Block a user