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:
Samraaj Bath
2026-07-04 03:46:31 -07:00
co-authored by Claude Fable 5
parent 144e7ab16b
commit 1d68f16f96
6 changed files with 607 additions and 20 deletions
+75 -1
View File
@@ -410,8 +410,22 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
hAuto = false;
hFill = false;
}
let wAuto = Math.abs(wa - r.width) <= 0.5;
let wFill = Math.abs(wf - r.width) <= 0.5;
// Circular-WIDTH guard (the mirror of the height guard above): an authored `width:24px` inside a
// SHRINK-TO-FIT parent (a color-swatch link in a span that shrink-wraps to it) makes BOTH
// `width:auto` and `width:100%` reproduce the box — the parent's width still holds while the
// child re-measures — so the raw verdict reads wAuto/wFill (drop) and the clone collapses the
// child to 0 content width (and the shrink-wrap span to its borders). When this element's own
// cascade/inline style declares an explicit definite width, trust it: the width is authored, so
// it is neither content-sized (wAuto) nor a parent fill (wFill). Only overrides when a probe
// actually reproduced — a genuine explicit width that auto already shrinks stays wAuto:false.
if ((wAuto || wFill) && r.width > 2 && authorsExplicitWidth(el, sw)) {
wAuto = false;
wFill = false;
}
sizing = {
wAuto: Math.abs(wa - r.width) <= 0.5, wFill: Math.abs(wf - r.width) <= 0.5, hAuto,
wAuto, wFill, hAuto,
hFill,
wMin: Math.round(wmin * 100) / 100, wMax: Math.round(wmax * 100) / 100,
};
@@ -579,6 +593,12 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
// authored dimension gets dropped downstream. A declared explicit length is ground truth that
// the height is load-bearing, so we trust the declaration over the reflow verdict.
const explicitHeightSelectors: string[] = [];
// The WIDTH twin of explicitHeightSelectors: selectors that author an explicit definite `width`/
// `min-width` (px/rem/em/…, not auto/%/keyword). Consulted by the sizing probe to break a CIRCULAR
// width verdict — an authored `width:24px` inside a shrink-to-fit parent reproduces at both auto and
// 100% (the parent's width still holds), so the probe alone reads wAuto and the authored width gets
// dropped, collapsing the box in the clone.
const explicitWidthSelectors: string[] = [];
const absUrl = (u: string): string => {
try { return new URL(u, document.baseURI).href; } catch { return u; }
@@ -627,6 +647,22 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
return false;
};
// The WIDTH twin of authorsExplicitHeight: does this element author an explicit definite width via
// its own inline style (`inlineWidth`, already read by the probe) or any matched harvested rule?
// `isExplicitHeight` is unit-agnostic (it only rejects auto/%/keywords and matches definite lengths),
// so it doubles as the width predicate.
const authorsExplicitWidth = (el: Element, inlineWidth: string): boolean => {
if (isExplicitHeight(inlineWidth)) return true;
try {
const inlineMin = (el as HTMLElement).style?.getPropertyValue("min-width") || "";
if (isExplicitHeight(inlineMin)) return true;
} catch { /* ignore */ }
for (const sel of explicitWidthSelectors) {
try { if (el.matches(sel)) return true; } catch { /* invalid/unsupported selector */ }
}
return false;
};
const readRules = (rules: CSSRuleList): void => {
for (const rule of Array.from(rules)) {
const type = rule.constructor.name;
@@ -657,6 +693,11 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
isExplicitHeight(r.style.getPropertyValue("min-height")))) {
explicitHeightSelectors.push(r.selectorText);
}
if (r.selectorText && r.style &&
(isExplicitHeight(r.style.getPropertyValue("width")) ||
isExplicitHeight(r.style.getPropertyValue("min-width")))) {
explicitWidthSelectors.push(r.selectorText);
}
} else if (type === "CSSMediaRule" || type === "CSSSupportsRule") {
try { readRules((rule as CSSGroupingRule).cssRules); } catch { /* ignore */ }
} else if (type === "CSSImportRule") {
@@ -676,6 +717,39 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
}
}
// Custom elements (web components) carry their styles INSIDE their open shadow root — via
// `adoptedStyleSheets` (constructable sheets) and/or `<style>`/`<link>` in the shadow tree
// (`shadowRoot.styleSheets`). Those sheets never appear in `document.styleSheets`, so an authored
// rule like `color-swatch a { width: 24px }` is invisible to the harvest above and the sizing probe
// can't see the explicit width. Walk every open shadow root (composed-tree sweep, matching the
// serializer) and read its sheets too — same `readRules` (font-faces, url()s, explicit width/height
// selectors). Closed roots return null and are skipped, exactly as elsewhere.
const shadowRoots: ShadowRoot[] = [];
const collectShadowRoots = (): void => {
const stack: Element[] = [];
const pushChildren = (parent: Element | Document | ShadowRoot): void => {
let c = parent.firstElementChild;
while (c) { stack.push(c); c = c.nextElementSibling; }
};
pushChildren(document);
// Depth-first over the composed element tree, descending into every open shadow root.
while (stack.length) {
const node = stack.pop()!;
const sr = (node as HTMLElement).shadowRoot;
if (sr) { shadowRoots.push(sr); pushChildren(sr); }
pushChildren(node);
}
};
try { collectShadowRoots(); } catch { /* ignore */ }
for (const sr of shadowRoots) {
const sheets: CSSStyleSheet[] = [];
try { sheets.push(...(sr.adoptedStyleSheets || [])); } catch { /* ignore */ }
try { sheets.push(...Array.from(sr.styleSheets || [])); } catch { /* ignore */ }
for (const sheet of sheets) {
try { readRules(sheet.cssRules); } catch { if ((sheet as CSSStyleSheet).href) cssUrlSet.add(absUrl((sheet as CSSStyleSheet).href!)); }
}
}
// CSS custom properties declared on :root.
try {
const rootCs = window.getComputedStyle(document.documentElement);
+133 -13
View File
@@ -1469,6 +1469,18 @@ function fluidPercentByVp(node: IRNode, parentNode: IRNode | undefined, viewport
const flexGridItem = /(?:^|-)(?:flex|grid)$/.test(pdisp);
const blockLevel = /^(block|flow-root|list-item|flex|grid|inline-block)$/.test(cs.display || "");
if (!flexGridItem && !blockLevel) return null;
// A GRID item's `%` width resolves against its GRID AREA (its track span), NOT the grid
// container's content box. When the grid has more than one column track, the parent-content-box
// ratio computed below is against the wrong containing block — the browser would apply that % to
// the ~1-column track, collapsing the item (24.5% of 1240px emitted where % hits a 302px track →
// 74px column). We have no direct track-width evidence, so bail for a multi-column grid item and
// let it fall to its authored width (auto / source-intent). Single-column grids are safe: the one
// track spans the full content box, so the ratio base is correct.
const isGridItem = /(?:^|-)grid$/.test(pdisp);
if (isGridItem) {
const cols = parseTracks(pcs.gridTemplateColumns);
if (!cols || cols.length > 1) return null;
}
const container = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
if (container <= 0) return null;
const ratio = nb.width / container;
@@ -2034,6 +2046,31 @@ function flexRowHasSlack(container: IRNode, viewports: number[]): boolean {
return checked >= 1;
}
/** Does an in-flow element CHILD derive its HEIGHT from its parent's box (fill it) at this viewport?
* Two ways:
* • the sizing probe measured `height:100%` reproduces and `height:auto` does NOT (hFill && !hAuto);
* • the child is an UN-PROBED replaced element that soaks up the parent box height — a `<picture>`
* (the probe skips it — walker's probe excludes replaced tags) or an object-fit:cover img/video —
* whose border-box height ≈ the parent's CONTENT-box height. Such a child's extent equals the
* parent's height only BECAUSE it fills it (object-fit:cover paints to any box, `<picture>` sizes
* its inner img to the box), so it is NOT content evidence and must not explain away the parent's
* authored height. Without this a `<picture h-full>` reads as real content, the authored parent
* height is dropped, and the inner img re-derives from its selected source's natural aspect.
* `parentContentH` is the parent's content-box height (border-box minus padding/border, top+bottom). */
function childDerivesHeightFromParent(child: IRNode, vp: number, parentContentH: number): boolean {
const csz = child.sizingByVp?.[vp];
if (csz && csz.hFill === true && csz.hAuto === false) return true;
const ccs = child.computedByVp[vp]; const cb = child.bboxByVp[vp];
if (!ccs || !cb) return false;
const isPicture = child.tag === "picture";
const isCoverReplaced = REPLACED.has(child.tag) && /cover/.test(ccs.objectFit || "");
if (!isPicture && !isCoverReplaced) return false;
if (!(parentContentH > 0)) return false;
// Border-box height fills the parent content box (≤2px slop). An intrinsically-shorter media that
// merely SITS in a taller box (natural-aspect logo) fails this and is treated as real content.
return Math.abs(cb.height - parentContentH) <= Math.max(2, 0.01 * parentContentH);
}
/** Whether a node's height can FLOW (drop the baked px) — true when, at every sampled width, its
* border-box height is either content-driven (== the extent of its in-flow children + padding) or
* cross-axis STRETCH (a flex-row / grid item filling its container's content height). Both
@@ -2090,6 +2127,7 @@ function heightFlows(node: IRNode, parentNode: IRNode | undefined, viewports: nu
// header). Dropping the height then lets the fill child re-derive from its aspect ratio and inflate
// (a 240px hero → 720px @16/9). Do NOT flow such a box's height.
let allInflowFill = true;
const nodeContentH = nb.height - pf(cs.paddingTop) - pf(cs.paddingBottom) - pf(cs.borderTopWidth) - pf(cs.borderBottomWidth);
for (const c of node.children) {
if (isTextChild(c)) continue;
const ccs = c.computedByVp[vp]; const cb = c.bboxByVp[vp];
@@ -2097,8 +2135,7 @@ function heightFlows(node: IRNode, parentNode: IRNode | undefined, viewports: nu
if (ccs.position === "absolute" || ccs.position === "fixed") continue;
if ((ccs.float || "none") !== "none") continue;
hasChild = true;
const csz = c.sizingByVp?.[vp];
if (!(csz && csz.hFill === true && csz.hAuto === false)) allInflowFill = false;
if (!childDerivesHeightFromParent(c, vp, nodeContentH)) allInflowFill = false;
bottom = Math.max(bottom, cb.y + cb.height);
bottomMargin = Math.max(bottomMargin, cb.y + cb.height + Math.max(0, pf(ccs.marginBottom)));
}
@@ -2215,10 +2252,11 @@ function absoluteHeightFill(node: IRNode, parentNode: IRNode | undefined, viewpo
* aren't probed (the probe skips them), so this never fires on img/svg. */
function heightProbeFills(node: IRNode, viewports: number[]): boolean {
if (process.env.NO_HFILL) return false; // diagnostic toggle
let painted = 0;
let painted = 0; let paintedTotal = 0;
for (const vp of viewports) {
const cs = node.computedByVp[vp];
if (!cs || !node.visibleByVp[vp] || (cs.display || "") === "none") continue;
paintedTotal++;
const pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") return false;
const s = node.sizingByVp?.[vp];
@@ -2226,7 +2264,11 @@ function heightProbeFills(node: IRNode, viewports: number[]): boolean {
if (!s.hFill || s.hAuto) return false; // must fill at 100% AND not be content-sized
painted++;
}
return painted >= 2;
// Normally require ≥2 painted viewports so the fill verdict is corroborated across widths. But a
// responsive-only variant (a `max-md:`-scoped node) is PAINTED at only one captured viewport — its
// single unanimous probe reading is the COMPLETE evidence set, not a lone sample, so accept it.
// The parent still confers a definite height (its own band-baked height), so 100% resolves safely.
return painted >= 2 || (painted === 1 && paintedTotal === 1);
}
/** Does this node confer a DEFINITE height to its children (so their height:100% resolves)? True when:
@@ -2399,6 +2441,17 @@ function declsForViewport(
// collapsing is preserved.
let explicitHeight = false;
const nb = node.bboxByVp[vp];
// Probe ground truth, emission twin of the heightFlows keep (see the hAuto/hFill guard there):
// the sizing probe proved height:auto did NOT reproduce this box (hAuto:false) AND it is not a
// fill child of its own parent (hFill:false), so the captured px is AUTHORED and load-bearing.
// heightFlows refuses to FLOW such a box, but the emission gate below still needs a matching keep
// or the height is dropped when the fill-child evidence (the "taller than content" / all-inflow-fill
// tests) can't fire — an inline `<a>` child probes hAuto:true and content extent equals the box, so
// neither structural test detects the authored height. Trust the node's own probe.
{
const osz = node.sizingByVp?.[vp];
if (!noCollapse && !isLeaf && cs.height && cs.height !== "auto" && osz && osz.hAuto === false && osz.hFill === false) explicitHeight = true;
}
if (!noCollapse && !isLeaf && cs.height && cs.height !== "auto" && nb) {
const top = nb.y + (parseFloat(cs.paddingTop || "0") || 0) + (parseFloat(cs.borderTopWidth || "0") || 0);
let contentBottom = top; // includes trailing margins (for taller detection)
@@ -2409,6 +2462,7 @@ function declsForViewport(
// height only BECAUSE they fill it — it is not content-derived evidence, so it must
// not be allowed to "explain away" an authored height below.
let allInflowFill = true;
const nodeContentH = nb.height - (parseFloat(cs.paddingTop || "0") || 0) - (parseFloat(cs.paddingBottom || "0") || 0) - (parseFloat(cs.borderTopWidth || "0") || 0) - (parseFloat(cs.borderBottomWidth || "0") || 0);
for (const c of node.children) {
if (isTextChild(c)) continue;
const ccs = c.computedByVp[vp]; const cb = c.bboxByVp[vp];
@@ -2419,10 +2473,10 @@ function declsForViewport(
if (ccs.position === "absolute" || ccs.position === "fixed") continue;
if ((ccs.float || "none") !== "none") continue;
inflowCount++;
const csz = c.sizingByVp?.[vp];
// A fill child: the probe measured height:100% reproduces AND auto does not (hFill && !hAuto).
// No probe data (older captures) ⇒ treat as real content (conservative: leave allInflowFill off).
if (!(csz && csz.hFill === true && csz.hAuto === false)) allInflowFill = false;
// A fill child derives its height from this box: probe hFill && !hAuto, OR an un-probed
// <picture>/object-fit:cover replaced child whose border-box == this box's content height.
// No probe data on a plain block child ⇒ treat as real content (leave allInflowFill off).
if (!childDerivesHeightFromParent(c, vp, nodeContentH)) allInflowFill = false;
contentBottom = Math.max(contentBottom, cb.y + cb.height + (parseFloat(ccs.marginBottom || "0") || 0));
borderBottom = Math.max(borderBottom, cb.y + cb.height);
}
@@ -2563,6 +2617,14 @@ function declsForViewport(
// emitted once below so it never bands per-viewport-px.
if (prop === "gridTemplateColumns" && gridCols !== undefined) continue;
// grid-template-rows likewise replaced by the fluid (1fr) template when one was inferred.
// NEVER drop a `subgrid` template: it is a STRUCTURAL keyword (the row tracks are inherited from
// the parent grid so both stacked layers share row sizing), not a frozen per-viewport px regime.
// `dropGridRows` exists to strip baked px row heights in reflow mode, but computed
// getComputedStyle reports the keyword literally ("subgrid [] …") — dropping it collapses the
// empty layer's row-1 to 0 and its media overlaps the text layer. Emit it (tailwind maps it to
// `grid-rows-subgrid`); the fluidGridRows path already refuses to touch subgrid nodes, so gridRows
// is undefined here and this keyword flows straight through.
if (prop === "gridTemplateRows" && /\bsubgrid\b/.test(value || "")) { out.set("grid-template-rows", "subgrid"); continue; }
if (prop === "gridTemplateRows" && (gridRows !== undefined || dropGridRows)) continue;
// fill-to-cap sets max-width to the recovered cap (above); don't let the source value re-emit it.
if (prop === "maxWidth" && widthPlan.kind === "fillcap") continue;
@@ -2633,6 +2695,9 @@ function declsForViewport(
// Centered max-width container: emit auto side margins so the browser centers
// it at every width. getComputedStyle sometimes reports 0 for resolved auto
// margins at wide viewports, so replaying px would left-align the clone.
// The fill-inset guard lives in the centering DECISION (centeredAtVp): a full-width block merely
// inset by a fixed gutter is no longer flagged centred, so its literal px margins are kept and it
// never bleeds full-bleed from a `margin:auto` that resolves to 0.
if (isCentered) {
out.set("margin-left", "auto");
out.set("margin-right", "auto");
@@ -2744,6 +2809,31 @@ function marginsVaryAcrossVps(node: IRNode): boolean {
return ms.length >= 2 && Math.max(...ms) - Math.min(...ms) > 1;
}
/** Does the box's WIDTH grow in near-lockstep with its container across viewports? Then it is a
* full-width block INSET by a fixed gutter (an `mx-4 md:mx-8` section — box == container 2·gutter),
* NOT a centred content box (whose width stays roughly constant while the container grows and the
* side margins absorb the widening slack). The two are indistinguishable at a SINGLE width (both show
* equal side gaps), and the sizing probe reads BOTH as `wAuto:true` (auto reproduces the inset width).
* The discriminator is the trajectory: a centred 600px box in a 375→1280 container barely widens
* (Δbox ≪ Δcontainer), while a gutter-inset section tracks the container (Δbox ≈ Δcontainer). Used to
* keep auto-margin centring OFF a width-dropped fill-inset block — where `margin:auto` resolves to 0
* and blows it full-bleed — while still centring genuinely content-constrained blocks. */
function widthTracksContainerFill(node: IRNode, parentNode: IRNode, viewports: number[]): boolean {
const bw: number[] = []; const cw: number[] = [];
for (const vp of viewports) {
const nb = node.bboxByVp[vp]; const pb = parentNode.bboxByVp[vp]; const pcs = parentNode.computedByVp[vp];
if (!nb || !pb || !node.visibleByVp[vp]) continue;
const content = pb.width - pf(pcs?.paddingLeft) - pf(pcs?.paddingRight) - pf(pcs?.borderLeftWidth) - pf(pcs?.borderRightWidth);
if (!(content > 0)) continue;
bw.push(nb.width); cw.push(content);
}
if (bw.length < 2) return false;
const dBox = Math.max(...bw) - Math.min(...bw);
const dCon = Math.max(...cw) - Math.min(...cw);
if (dCon <= 8) return false; // container barely varies → trajectory says nothing
return dBox / dCon > 0.5; // box widens ≥ half as fast as the container → tracks it
}
function centeredAtVp(node: IRNode, parentNode: IRNode, vp: number): boolean {
const cs = node.computedByVp[vp];
const nb = node.bboxByVp[vp];
@@ -2783,8 +2873,16 @@ function centeredAtVp(node: IRNode, parentNode: IRNode, vp: number): boolean {
// px side margins, blowing a padded pill/section out to full-bleed. Equal literal margins are the
// geometric TWIN of centering slack (both split the free space symmetrically), so this probe read
// is the only reliable discriminator between them; when the box fills, keep the literal margins.
// A box the probe reads as a container-FILL (`width:100%` reproduces it) has no free space for auto
// margins to absorb. So does a full-width block merely INSET by a fixed gutter (`mx-4 md:mx-8`): the
// probe reads it `wAuto:true, wFill:false` (auto reproduces the inset width) and its symmetric
// margins vary per breakpoint — so it slips past `!fillsAtVp` and looks centred at any single width,
// yet its width TRACKS the container. Emitting `margin:auto` on it (after its width is dropped to
// auto) resolves the margins to 0 and blows it full-bleed. `widthTracksContainerFill` catches that
// trajectory; a genuinely centred content box (constant width, ballooning margins) does not track.
const fillsAtVp = node.sizingByVp?.[vp]?.wFill === true;
if (ml > 0.5 && Math.abs(ml - mr) < 1 && nb.width < pb.width - 4 && marginsVaryAcrossVps(node) && !fillsAtVp) return true;
const gutterInset = widthTracksContainerFill(node, parentNode, Object.keys(node.computedByVp).map(Number));
if (ml > 0.5 && Math.abs(ml - mr) < 1 && nb.width < pb.width - 4 && marginsVaryAcrossVps(node) && !fillsAtVp && !gutterInset) return true;
if (parentFlexGrid) return false;
// Signal 2: bbox-centered within the parent content box with ~0 reported margins.
if (Math.abs(ml) > 0.5 || Math.abs(mr) > 0.5) return false; // margins already explain position
@@ -3437,17 +3535,39 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
if ((node.computedByVp[baseVp]?.display || "") !== "none") {
nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) });
}
continue;
} else if (shownAtBase) {
// Hidden by an ancestor (or zero-size / opacity:0) but visible at base: geometry
// overrides are breakpoint noise — the ancestor's own hide (or the reveal replay,
// for scroll-reveal opacity) covers it. Emit only the hide the node itself carries.
// The node is not painted here yet was not hidden by its own display:none / an
// ancestor's visibility (handled above). It can be (a) off-viewport / clipped — the
// walker marks a box invisible when `bbox.x >= vpW` — while still IN FLOW and
// OCCUPYING real layout space, or (b) genuinely suppressed (opacity:0 / visibility:hidden,
// typically a scroll-reveal or ancestor state that is breakpoint noise).
//
// Case (a) is load-bearing: an off-screen-right carousel slide is a display:block flex
// item that still sets its flex track's cross-size. The base rule bakes CANONICAL
// (desktop) geometry — a frozen desktop slide width/aspect inflates the mobile track
// (an off-screen `w-[285px]` slide dragging a 375px carousel to 532px). Same policy as
// the ownHidden / ancestor-hidden occupying-box paths above: fall through to the normal
// per-viewport delta so it sits at THIS width's measured geometry, not the baked canonical.
const bb = node.bboxByVp[b.vp];
const suppressed = pf(vpCs.opacity) === 0 || /^(hidden|collapse)$/.test(vpCs.visibility || "");
const inFlowHere = (vpCs.position || "static") === "static" || (vpCs.position || "static") === "relative";
if (!suppressed && inFlowHere && bb && (bb.width > 0 || bb.height > 0)) {
// Occupying, in-flow, merely off-viewport/clipped — fall through to the per-viewport delta.
} else {
// Suppressed (opacity:0 / visibility:hidden) but visible at base: geometry overrides are
// breakpoint noise — the ancestor's own hide (or the reveal replay, for scroll-reveal
// opacity) covers it. Emit only the hide the node itself carries.
const hide = new Map<string, string>();
if (pf(vpCs.opacity) === 0 && !animOwned.has("opacity")) hide.set("opacity", "0");
if (/^(hidden|collapse)$/.test(vpCs.visibility || "")) hide.set("visibility", "hidden");
if (hide.size) nr.bands.push({ media: b.media, decls: hide });
}
continue;
}
} else {
continue;
}
}
}
const centeredVp = stableCenter || (layoutParent ? centeredAtVp(node, layoutParent, b.vp) : false);
const vpDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[b.vp], b.vp, assetMap, centeredVp, colorVar, ir.doc.perViewport[b.vp]?.scrollHeight, widthPlan, gridColsByVp?.get(b.vp), gridRowsByVp?.get(b.vp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth, nowrapText, keepIdentityTransform), tokenResolver);
+27 -2
View File
@@ -1000,7 +1000,28 @@ function sourceIntentUtilities(node: IRNode, parent: IRNode | undefined, viewpor
const css: string[] = [];
const bands = computeBands(viewports, canonical);
for (const [axis, byVp] of perAxis) {
if (!viewports.every((vp) => byVp.has(vp))) continue; // avoid partial custom-class inference for now
if (!viewports.every((vp) => byVp.has(vp))) {
// Partial-coverage escape hatch for SUBGRID. `grid-rows-subgrid`/`grid-cols-subgrid` is a
// structural keyword authored as a variant-only utility (`max-lg:grid-rows-subgrid`, with the
// axis resolving to explicit tracks at ≥lg), so the map covers only a subset of viewports and
// the full-coverage bail below would throw it away. Subgrid is safe to band partially: emit it
// exactly at the covered viewports as banded variants (and as the base only when canonical is
// covered). Adding the axis lets the redundant computed-derived subgrid bands drop in favour of
// this authored intent. Other partial axes still bail (partial fluid inference is unsafe).
const subgridOnly = (axis === "grid-rows" || axis === "grid-cols") &&
[...byVp.values()].every((u) => /-subgrid$/.test(u));
if (!subgridOnly) continue;
if (!sourceAxisCompatible(node, parent, axis, byVp, viewports)) continue;
axes.add(axis);
const canon = byVp.get(canonical);
if (canon) utilities.push(canon);
for (const b of bands) {
if (!b.media) continue;
const v = byVp.get(b.vp);
if (v && v !== canon) utilities.push(prefixFor(b.media) + v);
}
continue;
}
if (!sourceAxisCompatible(node, parent, axis, byVp, viewports)) continue;
const base = byVp.get(canonical)!;
axes.add(axis);
@@ -1204,7 +1225,11 @@ export function buildTailwind(ir: IR, assetMap: Map<string, string>, colorVar?:
// one-off — emit it as an inline `style={{…}}` (exact, no Tailwind-escape mangling) so the node
// needs no `[data-cid]` ditto.css rule and the shipped data-cid is stripped. If the prop IS
// banded, it must stay in ditto.css: an inline style would out-specify the @media override.
const bandedRawProps = new Set<string>(bandRaws.flatMap((b) => [...b.raw.keys()]));
// Count EVERY band-touched prop (raw or not), not just the raw ones: a band that RESETS the prop
// to a non-raw value (e.g. `max-lg:bg-[none]` turning a gradient off on mobile) becomes a utility
// rather than a raw decl, so a raw-only set misses it — and inlining the base gradient then
// out-specifies the `@media` reset, painting the gradient where the source turned it off.
const bandedRawProps = new Set<string>(nr.bands.flatMap((b) => [...b.decls.keys()]));
const inlineStyle = new Map<string, string>();
for (const [p, v] of [...baseRaw]) {
if (!bandedRawProps.has(p)) { inlineStyle.set(p, tokenizeColors(p, v)); baseRaw.delete(p); }
+99
View File
@@ -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}"`);
});
});
+191
View File
@@ -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}`);
});
});
+78
View File
@@ -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;