Neutralize animation-owned transforms across viewports; probe-veto flex auto-width conversion
- Nodes carrying an infinite CSS animation at ANY viewport get their frozen mid-animation transform zeroed at EVERY viewport (the @keyframes owns the property at runtime); animOwnedProps is now viewport-aware so breakpoint- gated marquees can't bake a mid-scroll translateX into other bands - contentSizedFlexRow/contentSizedFlexItemAuto honor the sizing probe: a flex item whose captured width the probe proved load-bearing (wAuto=false) is never converted to width:auto, preserving narrow centered text columns 247 tests pass (7 new), typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d5a713d31f
commit
e33354cfb5
@@ -1540,6 +1540,18 @@ function contentSizedFlexRow(container: IRNode, viewports: number[]): Set<string
|
|||||||
for (const vp of viewports) for (const it of items) {
|
for (const vp of viewports) for (const it of items) {
|
||||||
const cs = it.computedByVp[vp]; if (cs && pf(cs.flexGrow) > 0) return null;
|
const cs = it.computedByVp[vp]; if (cs && pf(cs.flexGrow) > 0) return null;
|
||||||
}
|
}
|
||||||
|
// The sizing probe is ground truth: if it proved `width:auto` does NOT reproduce an item's width at
|
||||||
|
// any painted viewport (`wAuto:false`), that item is load-bearing and cannot be dropped. Because this
|
||||||
|
// rule is all-or-nothing per line (dropping a subset shifts siblings via justify-content), one such
|
||||||
|
// item vetoes the whole line. This catches a wrapping-text item — e.g. a paragraph in a
|
||||||
|
// `justify-content:flex-end` row paints at its balanced-wrap width (below max-content), but
|
||||||
|
// `width:auto` on the block child fills the line to max-content — which the geometric slack test
|
||||||
|
// alone (no shrink fired) would wrongly convert to auto.
|
||||||
|
for (const it of items) {
|
||||||
|
for (const vp of viewports) {
|
||||||
|
if (it.sizingByVp?.[vp]?.wAuto === false) return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
// At every width: the visible items + gaps + margins must not overflow the container (slack ≥ 0).
|
// At every width: the visible items + gaps + margins must not overflow the container (slack ≥ 0).
|
||||||
for (const vp of viewports) {
|
for (const vp of viewports) {
|
||||||
const pcs = container.computedByVp[vp]; const pb = container.bboxByVp[vp];
|
const pcs = container.computedByVp[vp]; const pb = container.bboxByVp[vp];
|
||||||
@@ -1923,6 +1935,12 @@ function contentSizedFlexItemAuto(node: IRNode, parentNode: IRNode | undefined,
|
|||||||
const cs = node.computedByVp[vp]; const pcs = parentNode.computedByVp[vp];
|
const cs = node.computedByVp[vp]; const pcs = parentNode.computedByVp[vp];
|
||||||
const nb = node.bboxByVp[vp]; const pb = parentNode.bboxByVp[vp];
|
const nb = node.bboxByVp[vp]; const pb = parentNode.bboxByVp[vp];
|
||||||
if (!cs || !pcs || !nb || !pb || !node.visibleByVp[vp] || (cs.display || "") === "none") continue;
|
if (!cs || !pcs || !nb || !pb || !node.visibleByVp[vp] || (cs.display || "") === "none") continue;
|
||||||
|
// The sizing probe is ground truth: if it proved `width:auto` does NOT reproduce the captured
|
||||||
|
// width at any painted viewport (`wAuto:false`), the geometric "never shrank ⇒ at content size"
|
||||||
|
// read below is wrong — e.g. a wrapping paragraph in a `justify-content:flex-end` row paints at
|
||||||
|
// its balanced-wrap width (below max-content), but `width:auto` on the block child fills the line
|
||||||
|
// to max-content and left-aligns it. Honor the probe over the heuristic.
|
||||||
|
if (node.sizingByVp?.[vp]?.wAuto === false) return false;
|
||||||
if (!/^(flex|inline-flex)$/.test(pcs.display || "")) return false;
|
if (!/^(flex|inline-flex)$/.test(pcs.display || "")) return false;
|
||||||
const dir = pcs.flexDirection || "row";
|
const dir = pcs.flexDirection || "row";
|
||||||
if (dir !== "row" && dir !== "row-reverse") return false; // width = main axis only for rows
|
if (dir !== "row" && dir !== "row-reverse") return false; // width = main axis only for rows
|
||||||
@@ -2218,11 +2236,22 @@ function confersDefiniteHeight(node: IRNode, parentNode: IRNode | undefined, got
|
|||||||
* change. transform-origin follows transform (skipped when no transform varies). */
|
* change. transform-origin follows transform (skipped when no transform varies). */
|
||||||
const ANIM_OWNED = new Set(["opacity", "transform"]);
|
const ANIM_OWNED = new Set(["opacity", "transform"]);
|
||||||
const EMPTY_SET = new Set<string>();
|
const EMPTY_SET = new Set<string>();
|
||||||
function animOwnedProps(node: IRNode, baseVp: number): Set<string> {
|
/** True when an INFINITE CSS animation is active on the node at this viewport (so it perpetually
|
||||||
const cs = node.computedByVp[baseVp];
|
* drives opacity/transform — the captured value is a frozen animation phase, not design). */
|
||||||
if (!cs || (cs.animationName || "none") === "none") return EMPTY_SET;
|
function hasInfiniteAnim(cs: StyleMap | undefined): boolean {
|
||||||
if (!/infinite/.test(cs.animationIterationCount || "1")) return EMPTY_SET;
|
if (!cs || (cs.animationName || "none") === "none") return false;
|
||||||
return ANIM_OWNED;
|
return /infinite/.test(cs.animationIterationCount || "1");
|
||||||
|
}
|
||||||
|
/** Properties an infinite animation owns (so their per-viewport captured values are frozen phase
|
||||||
|
* noise to suppress). Sampled across ALL emitted viewports, not just the base: a CSS marquee that
|
||||||
|
* runs only below a breakpoint (Webflow's `max-lg` logo/text tracks) is `animation:none` at the
|
||||||
|
* base (desktop) width but still freezes a random `translateX` at the mobile/tablet bands — banding
|
||||||
|
* those bakes a mid-scroll offset that shifts content offscreen at rest. When the animation owns the
|
||||||
|
* transform at ANY width, the frozen per-band deltas are dropped everywhere and the base value holds
|
||||||
|
* (the runtime `@keyframes` starts at translateX(0), so the strip renders aligned until it animates). */
|
||||||
|
function animOwnedProps(node: IRNode, viewports: number[]): Set<string> {
|
||||||
|
for (const vp of viewports) if (hasInfiniteAnim(node.computedByVp[vp])) return ANIM_OWNED;
|
||||||
|
return EMPTY_SET;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3292,7 +3321,10 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
|
|||||||
dropInsets.delete("bottom");
|
dropInsets.delete("bottom");
|
||||||
dropInsets.delete("left");
|
dropInsets.delete("left");
|
||||||
}
|
}
|
||||||
const animOwned = animOwnedProps(node, baseVp); // opacity/transform driven by an infinite animation
|
// opacity/transform driven by an infinite animation at the base OR any emitted band (a marquee
|
||||||
|
// gated to a breakpoint owns the transform only where it runs, but freezes a mid-scroll offset
|
||||||
|
// at the OTHER bands — suppress those frozen deltas at every width).
|
||||||
|
const animOwned = animOwnedProps(node, [baseVp, ...bands.map((b) => b.vp)]);
|
||||||
const baseDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[baseVp], baseVp, assetMap, centeredBase, colorVar, ir.doc.perViewport[baseVp]?.scrollHeight, widthPlan, gridColsByVp?.get(baseVp), gridRowsByVp?.get(baseVp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth, nowrapText, keepIdentityTransform), tokenResolver);
|
const baseDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[baseVp], baseVp, assetMap, centeredBase, colorVar, ir.doc.perViewport[baseVp]?.scrollHeight, widthPlan, gridColsByVp?.get(baseVp), gridRowsByVp?.get(baseVp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth, nowrapText, keepIdentityTransform), tokenResolver);
|
||||||
const nr: NodeRule = { base: baseDecls, bands: [] };
|
const nr: NodeRule = { base: baseDecls, bands: [] };
|
||||||
|
|
||||||
|
|||||||
@@ -226,6 +226,35 @@ export function canonicalizeTransforms(computedByVp: Record<number, StyleMap>):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when an INFINITE CSS animation is active at this viewport. Such an animation perpetually
|
||||||
|
* drives its animated properties (opacity/transform), so the captured value is a frozen phase of
|
||||||
|
* the loop, not authored design. */
|
||||||
|
function hasInfiniteAnimation(cs: StyleMap | undefined): boolean {
|
||||||
|
if (!cs || (cs.animationName || "none") === "none") return false;
|
||||||
|
return /infinite/.test(cs.animationIterationCount || "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neutralize the transform of a node that carries an INFINITE animation at ANY captured viewport.
|
||||||
|
* The capture shutter froze the marquee/spinner mid-loop, and — critically — a CSS animation gated
|
||||||
|
* to a breakpoint (Webflow's `max-lg` logo/big-text tracks) reads `animation:none` at the widths
|
||||||
|
* where it does NOT run, yet the browser still reports the last frozen `translateX` there. Banding
|
||||||
|
* those frozen values bakes a mid-scroll offset that shifts content offscreen-left AT REST (rows
|
||||||
|
* starting mid-glyph). The runtime `@keyframes` owns the transform and starts at translateX(0), so
|
||||||
|
* the faithful at-rest value is `none` at every width; generation's `animOwnedProps` then keeps the
|
||||||
|
* base holding while the animation drives it live. Only fires when a genuine infinite animation is
|
||||||
|
* present at some viewport — a statically-offset design element (no animation) is untouched.
|
||||||
|
* Deterministic, in place; only touches the `transform` slot.
|
||||||
|
*/
|
||||||
|
export function neutralizeAnimatedTransforms(computedByVp: Record<number, StyleMap>): void {
|
||||||
|
const vps = Object.keys(computedByVp).map(Number);
|
||||||
|
if (!vps.some((vp) => hasInfiniteAnimation(computedByVp[vp]))) return;
|
||||||
|
for (const vp of vps) {
|
||||||
|
const cs = computedByVp[vp];
|
||||||
|
if (cs && cs.transform && cs.transform !== "none") cs.transform = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Full identity signature (tag + id + class). */
|
/** Full identity signature (tag + id + class). */
|
||||||
function sigFull(n: RawNode): string {
|
function sigFull(n: RawNode): string {
|
||||||
return `${n.tag}#${n.attrs?.id ?? ""}.${(n.attrs?.class ?? "").trim()}`;
|
return `${n.tag}#${n.attrs?.id ?? ""}.${(n.attrs?.class ?? "").trim()}`;
|
||||||
@@ -370,6 +399,10 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
|
|||||||
// the generator's per-band delta treats them uniformly and a scroll/composite-noise transform
|
// the generator's per-band delta treats them uniformly and a scroll/composite-noise transform
|
||||||
// at one width can't leak across bands.
|
// at one width can't leak across bands.
|
||||||
canonicalizeTransforms(computedByVp);
|
canonicalizeTransforms(computedByVp);
|
||||||
|
// Drop transforms frozen mid-loop by an infinite animation (marquees/spinners) at every viewport —
|
||||||
|
// including the breakpoints where the animation is gated off but the browser still reports the last
|
||||||
|
// frozen offset. Prevents a baked mid-scroll translateX from clipping content offscreen at rest.
|
||||||
|
neutralizeAnimatedTransforms(computedByVp);
|
||||||
|
|
||||||
const node: IRNode = {
|
const node: IRNode = {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
|
|||||||
@@ -617,3 +617,82 @@ describe("collectNodeRules lottie mount height pin", () => {
|
|||||||
assert.ok(!/227px/.test(all), `without the mount flag the varying height should flow, got: ${all}`);
|
assert.ok(!/227px/.test(all), `without the mount flag the varying height should flow, got: ${all}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Defect E — a content-sized flex ROW's items are dropped to `width:auto` only when NO shrink fired
|
||||||
|
// (positive slack ⇒ items at content size). But a WRAPPING text leaf paints at its balanced-wrap
|
||||||
|
// width, which is BELOW its max-content: `width:auto` on the block child fills the line to
|
||||||
|
// max-content and left-aligns it, so the narrow right-pushed paragraph blows out to full-width. The
|
||||||
|
// sizing probe already read `wAuto:false` (auto does NOT reproduce the width); it must veto the
|
||||||
|
// whole line (the rule is all-or-nothing — dropping a subset shifts siblings via justify-content).
|
||||||
|
describe("generateCss content-sized flex row (probe veto for wrapping text)", () => {
|
||||||
|
// One <p> child, sole item of a `flex; justify-content:flex-end` row, `flex:0 1 auto`, that paints
|
||||||
|
// narrower than the container at every width (wraps below max-content) with positive slack.
|
||||||
|
function missionRow(pSizing: RawSizing) {
|
||||||
|
const p = xNode("n1", "p", {
|
||||||
|
375: { cs: { display: "block", flexGrow: "0", flexShrink: "1", flexBasis: "auto", width: "276px" }, bbox: { x: 69, y: 0, width: 276, height: 80 }, sizing: pSizing },
|
||||||
|
768: { cs: { display: "block", flexGrow: "0", flexShrink: "1", flexBasis: "auto", width: "529.922px" }, bbox: { x: 176, y: 0, width: 529.92, height: 60 }, sizing: pSizing },
|
||||||
|
1280: { cs: { display: "block", flexGrow: "0", flexShrink: "1", flexBasis: "auto", width: "774.141px" }, bbox: { x: 455, y: 0, width: 774.14, height: 40 }, sizing: pSizing },
|
||||||
|
}, [{ text: "Our UX staffing and recruiting teams place the best talent from around the world." }]);
|
||||||
|
return xNode("n0", "body", {
|
||||||
|
375: { cs: { display: "flex", flexDirection: "row", justifyContent: "flex-end" }, bbox: { x: 0, y: 0, width: 375, height: 80 } },
|
||||||
|
768: { cs: { display: "flex", flexDirection: "row", justifyContent: "flex-end" }, bbox: { x: 0, y: 0, width: 706.56, height: 60 } },
|
||||||
|
1280: { cs: { display: "flex", flexDirection: "row", justifyContent: "flex-end" }, bbox: { x: 0, y: 0, width: 1228.81, height: 40 } },
|
||||||
|
}, [p]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps a width on a wrapping paragraph the probe proved is not auto-reproducible", () => {
|
||||||
|
// Probe ground truth: width:auto does NOT reproduce (wAuto:false) — the painted width is the
|
||||||
|
// balanced-wrap width, below max-content (wMax).
|
||||||
|
const notAuto = (): RawSizing => ({ wAuto: false, wFill: false, hAuto: true, hFill: false, wMin: 95.88, wMax: 706.56 });
|
||||||
|
const css = generateCss(xIr(missionRow(notAuto())), new Map());
|
||||||
|
const all = allRulesX(css, "n1");
|
||||||
|
// It must NOT be emitted as content-auto (which drops width entirely and fills the line). The
|
||||||
|
// width plan falls to a real width — a fluid percentage or baked px — so the narrow column survives.
|
||||||
|
assert.ok(/width:/.test(all), `wrapping paragraph must keep a width, not collapse to auto, got: ${all}`);
|
||||||
|
assert.ok(!/width:auto/.test(all), `probe said wAuto:false — must not emit width:auto, got: ${all}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still drops width:auto on a genuinely content-sized row item the probe confirms", () => {
|
||||||
|
// Probe agrees width:auto reproduces (wAuto:true) — a toolbar/menu item whose width is its
|
||||||
|
// content. The content-sized-flex-row law should still fire and emit auto (no baked px band).
|
||||||
|
const isAuto = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false, wMin: 95.88, wMax: 706.56 });
|
||||||
|
const css = generateCss(xIr(missionRow(isAuto())), new Map());
|
||||||
|
const all = allRulesX(css, "n1");
|
||||||
|
assert.ok(!/width:\d/.test(all), `content-sized item must drop its baked px width, got: ${all}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Defect C (generate side) — a CSS marquee (infinite `@keyframes` animation) gated to a breakpoint
|
||||||
|
// (Webflow's `max-lg` logo/text tracks) is `animation:none` at the base but runs at the narrow band.
|
||||||
|
// The per-band transform delta at the animated width is a FROZEN mid-scroll phase; it must be
|
||||||
|
// suppressed so the base holds and the runtime keyframes drive the transform (from translateX(0)).
|
||||||
|
// (The upstream normalize pass zeroes the base residue — see neutralizeAnimatedTransforms; here the
|
||||||
|
// base is already `none`, and this test covers the band-delta suppression.)
|
||||||
|
describe("generateCss breakpoint-gated marquee transform band suppression (Defect C)", () => {
|
||||||
|
function logoStrip() {
|
||||||
|
const strip = xNode("n1", "div", {
|
||||||
|
375: { cs: { display: "grid", animationName: "track", animationIterationCount: "infinite", animationDuration: "35s", transform: "matrix(1, 0, 0, 1, -284.025, 0)" }, bbox: { x: 0, y: 0, width: 345, height: 100 } },
|
||||||
|
768: { cs: { display: "grid", animationName: "track", animationIterationCount: "infinite", animationDuration: "35s", transform: "matrix(1, 0, 0, 1, -280.982, 0)" }, bbox: { x: 0, y: 0, width: 706, height: 100 } },
|
||||||
|
1280: { cs: { display: "grid", animationName: "none", animationIterationCount: "1", transform: "none" }, bbox: { x: 0, y: 0, width: 1228, height: 145 } },
|
||||||
|
});
|
||||||
|
return xNode("n0", "body", { 375: { bbox: { x: 0, y: 0, width: 375, height: 100 } }, 768: { bbox: { x: 0, y: 0, width: 768, height: 100 } }, 1280: { bbox: { x: 0, y: 0, width: 1280, height: 145 } } }, [strip]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("does not bake the frozen mid-scroll translateX at the animated band", () => {
|
||||||
|
const css = generateCss(xIr(logoStrip()), new Map());
|
||||||
|
const all = allRulesX(css, "n1");
|
||||||
|
assert.ok(!/matrix\(1, ?0, ?0, ?1, ?-\d/.test(all), `frozen marquee translateX must not be emitted at any band, got: ${all}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a per-band transform of a NON-animated element", () => {
|
||||||
|
// A non-animated element whose transform genuinely varies per band keeps it (no animation owns it).
|
||||||
|
const el = xNode("n1", "div", {
|
||||||
|
375: { cs: { transform: "matrix(1, 0, 0, 1, -40, 0)" }, bbox: { x: -40, y: 0, width: 345, height: 100 } },
|
||||||
|
768: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 706, height: 100 } },
|
||||||
|
1280: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 1228, height: 100 } },
|
||||||
|
});
|
||||||
|
const root = xNode("n0", "body", { 375: { bbox: { x: 0, y: 0, width: 375, height: 100 } }, 768: { bbox: { x: 0, y: 0, width: 768, height: 100 } }, 1280: { bbox: { x: 0, y: 0, width: 1280, height: 100 } } }, [el]);
|
||||||
|
const css = generateCss(xIr(root), new Map());
|
||||||
|
assert.ok(/matrix\(1, ?0, ?0, ?1, ?-40/.test(allRulesX(css, "n1")), `a non-animated per-band offset must be kept, got: ${allRulesX(css, "n1")}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { RawNode, RawChild } from "../src/capture/walker.js";
|
import type { RawNode, RawChild } from "../src/capture/walker.js";
|
||||||
import { buildIR, isTextChild, type IRNode } from "../src/normalize/ir.js";
|
import { buildIR, isTextChild, neutralizeAnimatedTransforms, type IRNode } from "../src/normalize/ir.js";
|
||||||
|
|
||||||
const VPS = [375, 1280];
|
const VPS = [375, 1280];
|
||||||
|
|
||||||
@@ -116,3 +116,41 @@ describe("IR drops font-metric probe nodes (fix 4)", () => {
|
|||||||
assert.equal(findByTag(root, "div"), null);
|
assert.equal(findByTag(root, "div"), null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Defect C (normalize side) — an infinite CSS animation gated to a breakpoint (a Webflow `max-lg`
|
||||||
|
// marquee) is `animation:none` at the widths it does not run, but the browser still reports the last
|
||||||
|
// FROZEN translateX there. `neutralizeAnimatedTransforms` zeroes the transform at EVERY viewport
|
||||||
|
// once a genuine infinite animation is present at some width, so no frozen mid-scroll offset (the
|
||||||
|
// base residue or the animated phases) is banded and clips the strip offscreen at rest.
|
||||||
|
describe("neutralizeAnimatedTransforms (Defect C)", () => {
|
||||||
|
it("zeroes the transform at every viewport when an infinite animation runs at some width", () => {
|
||||||
|
const byVp = {
|
||||||
|
375: { animationName: "track", animationIterationCount: "infinite", transform: "matrix(1, 0, 0, 1, -284.025, 0)" } as any,
|
||||||
|
768: { animationName: "track", animationIterationCount: "infinite", transform: "matrix(1, 0, 0, 1, -280.982, 0)" } as any,
|
||||||
|
1280: { animationName: "none", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -9.94731, 0)" } as any,
|
||||||
|
};
|
||||||
|
neutralizeAnimatedTransforms(byVp as any);
|
||||||
|
assert.equal(byVp[375].transform, "none");
|
||||||
|
assert.equal(byVp[768].transform, "none");
|
||||||
|
assert.equal(byVp[1280].transform, "none", "the non-animated base residue is neutralized too");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a static transform untouched when NO viewport carries an infinite animation", () => {
|
||||||
|
const byVp = {
|
||||||
|
375: { animationName: "none", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -40, 0)" } as any,
|
||||||
|
1280: { animationName: "none", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -40, 0)" } as any,
|
||||||
|
};
|
||||||
|
neutralizeAnimatedTransforms(byVp as any);
|
||||||
|
assert.equal(byVp[375].transform, "matrix(1, 0, 0, 1, -40, 0)", "a deliberate static offset is preserved");
|
||||||
|
assert.equal(byVp[1280].transform, "matrix(1, 0, 0, 1, -40, 0)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire for a FINITE animation (a one-shot entrance, not a perpetual marquee)", () => {
|
||||||
|
const byVp = {
|
||||||
|
375: { animationName: "slideIn", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -100, 0)" } as any,
|
||||||
|
1280: { animationName: "slideIn", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -100, 0)" } as any,
|
||||||
|
};
|
||||||
|
neutralizeAnimatedTransforms(byVp as any);
|
||||||
|
assert.equal(byVp[375].transform, "matrix(1, 0, 0, 1, -100, 0)", "finite animations own a settled end state, not a perpetual phase");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user