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
@@ -410,8 +410,22 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
|||||||
hAuto = false;
|
hAuto = false;
|
||||||
hFill = 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 = {
|
sizing = {
|
||||||
wAuto: Math.abs(wa - r.width) <= 0.5, wFill: Math.abs(wf - r.width) <= 0.5, hAuto,
|
wAuto, wFill, hAuto,
|
||||||
hFill,
|
hFill,
|
||||||
wMin: Math.round(wmin * 100) / 100, wMax: Math.round(wmax * 100) / 100,
|
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
|
// 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.
|
// the height is load-bearing, so we trust the declaration over the reflow verdict.
|
||||||
const explicitHeightSelectors: string[] = [];
|
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 => {
|
const absUrl = (u: string): string => {
|
||||||
try { return new URL(u, document.baseURI).href; } catch { return u; }
|
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;
|
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 => {
|
const readRules = (rules: CSSRuleList): void => {
|
||||||
for (const rule of Array.from(rules)) {
|
for (const rule of Array.from(rules)) {
|
||||||
const type = rule.constructor.name;
|
const type = rule.constructor.name;
|
||||||
@@ -657,6 +693,11 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
|||||||
isExplicitHeight(r.style.getPropertyValue("min-height")))) {
|
isExplicitHeight(r.style.getPropertyValue("min-height")))) {
|
||||||
explicitHeightSelectors.push(r.selectorText);
|
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") {
|
} else if (type === "CSSMediaRule" || type === "CSSSupportsRule") {
|
||||||
try { readRules((rule as CSSGroupingRule).cssRules); } catch { /* ignore */ }
|
try { readRules((rule as CSSGroupingRule).cssRules); } catch { /* ignore */ }
|
||||||
} else if (type === "CSSImportRule") {
|
} 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.
|
// CSS custom properties declared on :root.
|
||||||
try {
|
try {
|
||||||
const rootCs = window.getComputedStyle(document.documentElement);
|
const rootCs = window.getComputedStyle(document.documentElement);
|
||||||
|
|||||||
+137
-17
@@ -1469,6 +1469,18 @@ function fluidPercentByVp(node: IRNode, parentNode: IRNode | undefined, viewport
|
|||||||
const flexGridItem = /(?:^|-)(?:flex|grid)$/.test(pdisp);
|
const flexGridItem = /(?:^|-)(?:flex|grid)$/.test(pdisp);
|
||||||
const blockLevel = /^(block|flow-root|list-item|flex|grid|inline-block)$/.test(cs.display || "");
|
const blockLevel = /^(block|flow-root|list-item|flex|grid|inline-block)$/.test(cs.display || "");
|
||||||
if (!flexGridItem && !blockLevel) return null;
|
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);
|
const container = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
|
||||||
if (container <= 0) return null;
|
if (container <= 0) return null;
|
||||||
const ratio = nb.width / container;
|
const ratio = nb.width / container;
|
||||||
@@ -2034,6 +2046,31 @@ function flexRowHasSlack(container: IRNode, viewports: number[]): boolean {
|
|||||||
return checked >= 1;
|
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
|
/** 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
|
* 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
|
* 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
|
// 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.
|
// (a 240px hero → 720px @16/9). Do NOT flow such a box's height.
|
||||||
let allInflowFill = true;
|
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) {
|
for (const c of node.children) {
|
||||||
if (isTextChild(c)) continue;
|
if (isTextChild(c)) continue;
|
||||||
const ccs = c.computedByVp[vp]; const cb = c.bboxByVp[vp];
|
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.position === "absolute" || ccs.position === "fixed") continue;
|
||||||
if ((ccs.float || "none") !== "none") continue;
|
if ((ccs.float || "none") !== "none") continue;
|
||||||
hasChild = true;
|
hasChild = true;
|
||||||
const csz = c.sizingByVp?.[vp];
|
if (!childDerivesHeightFromParent(c, vp, nodeContentH)) allInflowFill = false;
|
||||||
if (!(csz && csz.hFill === true && csz.hAuto === false)) allInflowFill = false;
|
|
||||||
bottom = Math.max(bottom, cb.y + cb.height);
|
bottom = Math.max(bottom, cb.y + cb.height);
|
||||||
bottomMargin = Math.max(bottomMargin, cb.y + cb.height + Math.max(0, pf(ccs.marginBottom)));
|
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. */
|
* aren't probed (the probe skips them), so this never fires on img/svg. */
|
||||||
function heightProbeFills(node: IRNode, viewports: number[]): boolean {
|
function heightProbeFills(node: IRNode, viewports: number[]): boolean {
|
||||||
if (process.env.NO_HFILL) return false; // diagnostic toggle
|
if (process.env.NO_HFILL) return false; // diagnostic toggle
|
||||||
let painted = 0;
|
let painted = 0; let paintedTotal = 0;
|
||||||
for (const vp of viewports) {
|
for (const vp of viewports) {
|
||||||
const cs = node.computedByVp[vp];
|
const cs = node.computedByVp[vp];
|
||||||
if (!cs || !node.visibleByVp[vp] || (cs.display || "") === "none") continue;
|
if (!cs || !node.visibleByVp[vp] || (cs.display || "") === "none") continue;
|
||||||
|
paintedTotal++;
|
||||||
const pos = cs.position || "static";
|
const pos = cs.position || "static";
|
||||||
if (pos !== "static" && pos !== "relative") return false;
|
if (pos !== "static" && pos !== "relative") return false;
|
||||||
const s = node.sizingByVp?.[vp];
|
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
|
if (!s.hFill || s.hAuto) return false; // must fill at 100% AND not be content-sized
|
||||||
painted++;
|
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:
|
/** 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.
|
// collapsing is preserved.
|
||||||
let explicitHeight = false;
|
let explicitHeight = false;
|
||||||
const nb = node.bboxByVp[vp];
|
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) {
|
if (!noCollapse && !isLeaf && cs.height && cs.height !== "auto" && nb) {
|
||||||
const top = nb.y + (parseFloat(cs.paddingTop || "0") || 0) + (parseFloat(cs.borderTopWidth || "0") || 0);
|
const top = nb.y + (parseFloat(cs.paddingTop || "0") || 0) + (parseFloat(cs.borderTopWidth || "0") || 0);
|
||||||
let contentBottom = top; // includes trailing margins (for taller detection)
|
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
|
// 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.
|
// not be allowed to "explain away" an authored height below.
|
||||||
let allInflowFill = true;
|
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) {
|
for (const c of node.children) {
|
||||||
if (isTextChild(c)) continue;
|
if (isTextChild(c)) continue;
|
||||||
const ccs = c.computedByVp[vp]; const cb = c.bboxByVp[vp];
|
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.position === "absolute" || ccs.position === "fixed") continue;
|
||||||
if ((ccs.float || "none") !== "none") continue;
|
if ((ccs.float || "none") !== "none") continue;
|
||||||
inflowCount++;
|
inflowCount++;
|
||||||
const csz = c.sizingByVp?.[vp];
|
// A fill child derives its height from this box: probe hFill && !hAuto, OR an un-probed
|
||||||
// A fill child: the probe measured height:100% reproduces AND auto does not (hFill && !hAuto).
|
// <picture>/object-fit:cover replaced child whose border-box == this box's content height.
|
||||||
// No probe data (older captures) ⇒ treat as real content (conservative: leave allInflowFill off).
|
// No probe data on a plain block child ⇒ treat as real content (leave allInflowFill off).
|
||||||
if (!(csz && csz.hFill === true && csz.hAuto === false)) allInflowFill = false;
|
if (!childDerivesHeightFromParent(c, vp, nodeContentH)) allInflowFill = false;
|
||||||
contentBottom = Math.max(contentBottom, cb.y + cb.height + (parseFloat(ccs.marginBottom || "0") || 0));
|
contentBottom = Math.max(contentBottom, cb.y + cb.height + (parseFloat(ccs.marginBottom || "0") || 0));
|
||||||
borderBottom = Math.max(borderBottom, cb.y + cb.height);
|
borderBottom = Math.max(borderBottom, cb.y + cb.height);
|
||||||
}
|
}
|
||||||
@@ -2563,6 +2617,14 @@ function declsForViewport(
|
|||||||
// emitted once below so it never bands per-viewport-px.
|
// emitted once below so it never bands per-viewport-px.
|
||||||
if (prop === "gridTemplateColumns" && gridCols !== undefined) continue;
|
if (prop === "gridTemplateColumns" && gridCols !== undefined) continue;
|
||||||
// grid-template-rows likewise replaced by the fluid (1fr) template when one was inferred.
|
// 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;
|
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.
|
// 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;
|
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
|
// Centered max-width container: emit auto side margins so the browser centers
|
||||||
// it at every width. getComputedStyle sometimes reports 0 for resolved auto
|
// it at every width. getComputedStyle sometimes reports 0 for resolved auto
|
||||||
// margins at wide viewports, so replaying px would left-align the clone.
|
// 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) {
|
if (isCentered) {
|
||||||
out.set("margin-left", "auto");
|
out.set("margin-left", "auto");
|
||||||
out.set("margin-right", "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;
|
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 {
|
function centeredAtVp(node: IRNode, parentNode: IRNode, vp: number): boolean {
|
||||||
const cs = node.computedByVp[vp];
|
const cs = node.computedByVp[vp];
|
||||||
const nb = node.bboxByVp[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
|
// 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
|
// 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.
|
// 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;
|
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;
|
if (parentFlexGrid) return false;
|
||||||
// Signal 2: bbox-centered within the parent content box with ~0 reported margins.
|
// 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
|
if (Math.abs(ml) > 0.5 || Math.abs(mr) > 0.5) return false; // margins already explain position
|
||||||
@@ -3437,16 +3535,38 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
|
|||||||
if ((node.computedByVp[baseVp]?.display || "") !== "none") {
|
if ((node.computedByVp[baseVp]?.display || "") !== "none") {
|
||||||
nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) });
|
nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) });
|
||||||
}
|
}
|
||||||
|
continue;
|
||||||
} else if (shownAtBase) {
|
} else if (shownAtBase) {
|
||||||
// Hidden by an ancestor (or zero-size / opacity:0) but visible at base: geometry
|
// The node is not painted here yet was not hidden by its own display:none / an
|
||||||
// overrides are breakpoint noise — the ancestor's own hide (or the reveal replay,
|
// ancestor's visibility (handled above). It can be (a) off-viewport / clipped — the
|
||||||
// for scroll-reveal opacity) covers it. Emit only the hide the node itself carries.
|
// walker marks a box invisible when `bbox.x >= vpW` — while still IN FLOW and
|
||||||
const hide = new Map<string, string>();
|
// OCCUPYING real layout space, or (b) genuinely suppressed (opacity:0 / visibility:hidden,
|
||||||
if (pf(vpCs.opacity) === 0 && !animOwned.has("opacity")) hide.set("opacity", "0");
|
// typically a scroll-reveal or ancestor state that is breakpoint noise).
|
||||||
if (/^(hidden|collapse)$/.test(vpCs.visibility || "")) hide.set("visibility", "hidden");
|
//
|
||||||
if (hide.size) nr.bands.push({ media: b.media, decls: hide });
|
// 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;
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const centeredVp = stableCenter || (layoutParent ? centeredAtVp(node, layoutParent, b.vp) : false);
|
const centeredVp = stableCenter || (layoutParent ? centeredAtVp(node, layoutParent, b.vp) : false);
|
||||||
|
|||||||
@@ -1000,7 +1000,28 @@ function sourceIntentUtilities(node: IRNode, parent: IRNode | undefined, viewpor
|
|||||||
const css: string[] = [];
|
const css: string[] = [];
|
||||||
const bands = computeBands(viewports, canonical);
|
const bands = computeBands(viewports, canonical);
|
||||||
for (const [axis, byVp] of perAxis) {
|
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;
|
if (!sourceAxisCompatible(node, parent, axis, byVp, viewports)) continue;
|
||||||
const base = byVp.get(canonical)!;
|
const base = byVp.get(canonical)!;
|
||||||
axes.add(axis);
|
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
|
// 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
|
// 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.
|
// 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>();
|
const inlineStyle = new Map<string, string>();
|
||||||
for (const [p, v] of [...baseRaw]) {
|
for (const [p, v] of [...baseRaw]) {
|
||||||
if (!bandedRawProps.has(p)) { inlineStyle.set(p, tokenizeColors(p, v)); baseRaw.delete(p); }
|
if (!bandedRawProps.has(p)) { inlineStyle.set(p, tokenizeColors(p, v)); baseRaw.delete(p); }
|
||||||
|
|||||||
@@ -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}`);
|
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", () => {
|
describe("walker text-wrap capture", () => {
|
||||||
let browser: Browser;
|
let browser: Browser;
|
||||||
let page: Page;
|
let page: Page;
|
||||||
|
|||||||
Reference in New Issue
Block a user