Files
Samraaj BathandClaude Fable 5 afab12ff14 Fix mobile chip-strip overlap, reveal-replay stagger, scroll-linked text-fill
Three clone-fidelity fixes from manual review of the acceptance screenshots.

Fix 1 — mobile nav chip-strip overlap (generate/css.ts). A horizontally
scrollable flex strip (overflow-x:auto flex <ul>) of nowrap chips collapsed
because the base viewport reported min-width:0px on the chips (a mobile-only
strip that is 0-wide at desktop), so the generator emitted `min-w-0`, letting
the chips shrink below their content instead of overflowing — the nowrap text
then collided. Suppress `min-w-0` for a nowrap flex item whose flex parent
scrolls horizontally; scoped away from legitimate min-w-0 truncation (whose
parent does not scroll-x). Verified on a fresh ooni.com clone: 0 chip overlaps
at 375, scrollWidth 776 > clientWidth 375.

Fix 2 — reveal-replay stagger leaves tiles unpainted (generate/motion.ts).
DittoMotion re-hid each revealed element and replayed its entrance on
scroll-into-view preserving the captured delay/duration, so a fast scroll /
full-page screenshot caught most grid tiles mid-entrance. Cap the replayed
delay (<=300ms) and duration (<=600ms), settle each tile per-element a bounded
time after it enters view, and make the global failsafe settle (not re-animate)
so nothing stays hidden. Validator settle path (__dittoMotionStop) unchanged.
Verified: cropin cotton renders 12/12 crop tiles in a normal full-page pass;
motion gate reveals 24/24.

Fix 3 — scroll-linked text-fill frozen at end state (capture/stabilize.ts,
capture/capture.ts, generate/css.ts). A view-timeline text-fill
(animation-duration resolves to `auto`) was baked filled: the clone has no
scroll timeline, so emitting the animation made it jump to its end keyframe via
fill-mode:both. Capture-side, cancel scroll/view-timeline animations before each
snapshot so the DOM records the at-rest state; generation-side, suppress the
animation-* props when animation-duration is `auto` (we render the at-rest
state, never replay a scroll-linked animation). Teach the motion gate to exclude
these from the static-CSS expectation so the gate stays honest. Verified on a
fresh ooni clone: the "world's no.1 pizza ovens" em is white/gray (rgb 240,240,
240), not filled yellow, at rest; motion gate still passes.

Tests: +7 fixtures (css scroll-strip + scroll-timeline, reveal-replay caps,
motion-gate scroll-timeline exclusion); 123/123 compiler tests green, full test
suite green, typecheck clean. No gate regression on the fresh ooni run vs the
20260704-024247 baseline (all gate pass/fail unchanged; perceptual within
0.0004; motion restored to pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:46:28 -07:00

146 lines
6.7 KiB
TypeScript

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { IR, IRNode } from "../src/normalize/ir.js";
import { collectExpectedCss, cssReplayedByReveal } from "../src/validate/motionGate.js";
import { missingHarnessDeps } from "../src/validate/render.js";
// Minimal IRNode factory — collectExpectedCss only reads id, computedByVp[vp].animation*,
// and children, so we build just those fields and cast.
function node(id: string, anim: { animationName?: string; animationIterationCount?: string; animationDuration?: string } | null, children: IRNode[] = []): IRNode {
return {
id,
tag: "div",
attrs: {},
visibleByVp: {},
bboxByVp: {},
computedByVp: anim ? { 1280: anim as unknown as IRNode["computedByVp"][number] } : {},
children,
} as unknown as IRNode;
}
function ir(root: IRNode): IR {
return { doc: { canonicalViewport: 1280 } as unknown as IR["doc"], root };
}
describe("motion gate — CSS entrance / reveal-replay accounting", () => {
it("collectExpectedCss reports every node with a computed animation-name != none", () => {
const tree = ir(
node("n0", null, [
node("n464", { animationName: "fadeInUp", animationIterationCount: "1" }),
node("n2", { animationName: "none" }), // excluded
node("n3", null), // no animation
node("n4", { animationName: "spin", animationIterationCount: "infinite" }),
]),
);
const got = collectExpectedCss(tree);
assert.deepEqual(got.map((e) => e.cid).sort(), ["n4", "n464"]);
const spin = got.find((e) => e.cid === "n4")!;
assert.equal(spin.infinite, true);
const fade = got.find((e) => e.cid === "n464")!;
assert.equal(fade.infinite, false);
assert.deepEqual(fade.names, ["fadeInUp"]);
});
it("collectExpectedCss EXCLUDES a scroll/view-timeline animation (animation-duration:auto)", () => {
// Fix 3: the ooni text-fill em (animation-timeline:view → computed animation-duration:auto)
// is intentionally NOT emitted (we render the at-rest state, not a scroll replay). It must be
// excluded from the static-CSS expectation, not counted as an owed-but-missing animation.
const tree = ir(
node("n0", null, [
node("n358", { animationName: "fillAnimation", animationDuration: "auto", animationIterationCount: "1" }),
node("n4", { animationName: "spin", animationDuration: "1s", animationIterationCount: "infinite" }),
]),
);
const got = collectExpectedCss(tree);
assert.deepEqual(got.map((e) => e.cid), ["n4"], "scroll-timeline node n358 must be excluded");
});
it("cssReplayedByReveal excludes a captured entrance the clone replays via a reveal", () => {
// The cropin regression: n464's fadeInUp is driven by a DittoMotion reveal, so the static
// animation-name is deliberately NOT emitted — it must NOT fail emitted=false.
const revealAnim = new Map([["n464", "fadeInUp"]]);
assert.equal(cssReplayedByReveal("n464", ["fadeInUp"], revealAnim), true);
});
it("cssReplayedByReveal does NOT exclude a CSS anim whose node has no reveal (still static)", () => {
const revealAnim = new Map([["n464", "fadeInUp"]]);
// A different, non-reveal node keeps the static-CSS expectation.
assert.equal(cssReplayedByReveal("n9", ["spin"], revealAnim), false);
});
it("cssReplayedByReveal does NOT exclude when the reveal replays a DIFFERENT animation name", () => {
// Reveal covers the cid but with a different entrance — the captured CSS anim is unrelated
// and still owed as static CSS, so it must not be silently excused.
const revealAnim = new Map([["n464", "fadeInUp"]]);
assert.equal(cssReplayedByReveal("n464", ["pulse"], revealAnim), false);
});
it("cssReplayedByReveal ignores reveals with no entrance animationName (transition family)", () => {
// Transition-family reveals (opacity/transform only) carry no animationName; a node with a
// real CSS keyframe anim is NOT excused by such a reveal.
const revealAnim = new Map<string, string>(); // no entries for transition-family reveals
assert.equal(cssReplayedByReveal("n464", ["fadeInUp"], revealAnim), false);
});
});
describe("harness build — missing dependency detection (Lottie regression)", () => {
function tmp(): { app: string; harness: string; cleanup: () => void } {
const root = mkdtempSync(join(tmpdir(), "harness-dep-"));
const app = join(root, "app");
const harness = join(root, "harness");
mkdirSync(app, { recursive: true });
mkdirSync(join(harness, "node_modules"), { recursive: true });
return { app, harness, cleanup: () => rmSync(root, { recursive: true, force: true }) };
}
function writePkg(dir: string, deps: Record<string, string>): void {
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "x", dependencies: deps }));
}
function provision(harness: string, name: string, version: string): void {
const p = join(harness, "node_modules", name);
mkdirSync(p, { recursive: true });
writeFileSync(join(p, "package.json"), JSON.stringify({ name, version }));
}
it("flags a dep the app declares but the harness lacks, pinned to the app's exact version", () => {
const { app, harness, cleanup } = tmp();
try {
writePkg(app, { next: "15.5.19", react: "19.2.7", "lottie-web": "5.12.2" });
provision(harness, "next", "15.5.19");
provision(harness, "react", "19.2.7");
// lottie-web NOT provisioned in the harness — mirrors the real regression.
const missing = missingHarnessDeps(app, harness);
assert.deepEqual(missing, [{ name: "lottie-web", version: "5.12.2" }]);
} finally { cleanup(); }
});
it("returns nothing when every app dep is already in the harness node_modules", () => {
const { app, harness, cleanup } = tmp();
try {
writePkg(app, { next: "15.5.19", react: "19.2.7" });
provision(harness, "next", "15.5.19");
provision(harness, "react", "19.2.7");
assert.deepEqual(missingHarnessDeps(app, harness), []);
} finally { cleanup(); }
});
it("strips a caret/tilde range to a bare version for a deterministic pinned install", () => {
const { app, harness, cleanup } = tmp();
try {
writePkg(app, { "lottie-web": "^5.12.2" });
const missing = missingHarnessDeps(app, harness);
assert.deepEqual(missing, [{ name: "lottie-web", version: "5.12.2" }]);
} finally { cleanup(); }
});
it("returns nothing when the app has no package.json", () => {
const { app, harness, cleanup } = tmp();
try {
rmSync(join(app, "package.json"), { force: true });
assert.deepEqual(missingHarnessDeps(app, harness), []);
} finally { cleanup(); }
});
});