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>
157 lines
7.3 KiB
TypeScript
157 lines
7.3 KiB
TypeScript
import { describe, it, before, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { createServer, type Server } from "node:http";
|
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import type { PageSnapshot, RawNode, RawChild } from "../src/capture/walker.js";
|
|
import { captureSite, type CaptureResult } from "../src/capture/capture.js";
|
|
import { buildIR } from "../src/normalize/ir.js";
|
|
import { buildMotionSpec, motionWireJsx, DITTO_MOTION_TSX } from "../src/generate/motion.js";
|
|
import { readJSON } from "../src/util/fsx.js";
|
|
|
|
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "..", "fixtures");
|
|
const VIEWPORTS = [375, 800];
|
|
|
|
function isText(c: RawChild): c is { text: string } {
|
|
return (c as { text?: string }).text !== undefined;
|
|
}
|
|
|
|
function findById(n: RawNode, id: string): RawNode | null {
|
|
if (n.attrs.id === id) return n;
|
|
for (const c of n.children) {
|
|
if (isText(c)) continue;
|
|
const hit = findById(c, id);
|
|
if (hit) return hit;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Scroll reveals (Elementor/WOW/AOS pattern): content wrappers are `visibility:hidden`
|
|
// at load and revealed by an IntersectionObserver class swap applying entrance keyframes.
|
|
// The capture must (a) settle every reveal BEFORE any viewport snapshot so the clone never
|
|
// bakes hidden content, and (b) record the reveal as a motion spec so the clone re-hides
|
|
// and replays the entrance on scroll.
|
|
describe("scroll-reveal settling + replay capture (integration)", () => {
|
|
let server: Server;
|
|
let url = "";
|
|
let outDir = "";
|
|
let capture: CaptureResult;
|
|
|
|
before(async () => {
|
|
const html = readFileSync(join(FIXTURES, "reveal.html"), "utf8");
|
|
server = createServer((_req, res) => {
|
|
res.writeHead(200, { "content-type": "text/html" });
|
|
res.end(html);
|
|
});
|
|
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
|
url = `http://127.0.0.1:${(server.address() as { port: number }).port}/`;
|
|
outDir = mkdtempSync(join(tmpdir(), "ditto-reveal-"));
|
|
capture = await captureSite({
|
|
url,
|
|
outDir,
|
|
viewports: VIEWPORTS,
|
|
motion: true,
|
|
breakpoints: false,
|
|
screenshots: false,
|
|
});
|
|
});
|
|
|
|
after(async () => {
|
|
server?.close();
|
|
rmSync(outDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("records the POST-REVEAL steady state at every viewport snapshot", () => {
|
|
for (const vp of VIEWPORTS) {
|
|
const snap = readJSON<PageSnapshot>(join(outDir, "capture", `dom-${vp}.json`));
|
|
for (const id of ["reveal-io", "reveal-far"]) {
|
|
const node = findById(snap.root, id)!;
|
|
assert.ok(node, `#${id} captured at ${vp}`);
|
|
assert.equal(node.visible, true, `#${id} visible at ${vp}`);
|
|
assert.notEqual(node.computed.visibility, "hidden", `#${id} not baked hidden at ${vp}`);
|
|
assert.equal(node.computed.animationName, "fadeInUp", `#${id} carries the library's revealed animation at ${vp}`);
|
|
assert.ok(!node.attrs.class?.includes("elementor-invisible"), `#${id} pre-reveal marker cleared at ${vp}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("never settles at a mid-fade frame (opacity is full after the entrance completes)", () => {
|
|
for (const vp of VIEWPORTS) {
|
|
const snap = readJSON<PageSnapshot>(join(outDir, "capture", `dom-${vp}.json`));
|
|
for (const id of ["reveal-io", "reveal-far"]) {
|
|
const node = findById(snap.root, id)!;
|
|
assert.equal(parseFloat(node.computed.opacity ?? "1"), 1, `#${id} opacity settled at ${vp}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("leaves genuinely hidden non-library content hidden (fidelity)", () => {
|
|
for (const vp of VIEWPORTS) {
|
|
const snap = readJSON<PageSnapshot>(join(outDir, "capture", `dom-${vp}.json`));
|
|
const never = findById(snap.root, "never")!;
|
|
assert.ok(never, `#never captured at ${vp}`);
|
|
assert.equal(never.visible, false, `#never stays hidden at ${vp}`);
|
|
}
|
|
});
|
|
|
|
it("captures both reveals as visibility-family motion specs with the entrance animation", () => {
|
|
const reveals = capture.motion?.reveals ?? [];
|
|
assert.equal(reveals.length, 2, `exactly the two reveal roots (got ${JSON.stringify(reveals)})`);
|
|
for (const rv of reveals) {
|
|
assert.equal(rv.visibility, "hidden");
|
|
assert.equal(rv.animationName, "fadeInUp");
|
|
assert.equal(rv.animationDuration, "0.4s");
|
|
assert.equal(rv.animationDelay, "0s");
|
|
assert.ok(rv.animationTiming, "timing function recorded");
|
|
assert.equal(rv.transition, "", "visibility family carries no transition");
|
|
}
|
|
});
|
|
|
|
it("threads the reveal specs into the generated motion spec (re-hide + replay)", () => {
|
|
const ir = buildIR(outDir, VIEWPORTS);
|
|
const spec = buildMotionSpec(ir, capture.motion);
|
|
assert.equal(spec.reveals.length, 2, "both reveals resolve to surviving cids");
|
|
for (const rv of spec.reveals) {
|
|
assert.ok(rv.cid, "reveal mapped to a rendered cid");
|
|
assert.equal(rv.visibility, "hidden");
|
|
assert.equal(rv.animationName, "fadeInUp");
|
|
}
|
|
const jsx = motionWireJsx(spec, 0);
|
|
assert.match(jsx, /<DittoMotion spec=/);
|
|
assert.match(jsx, /"animationName":"fadeInUp"/);
|
|
assert.match(jsx, /"visibility":"hidden"/);
|
|
});
|
|
|
|
it("DittoMotion re-hides via JS-applied visibility and replays the captured @keyframes", () => {
|
|
// Initial hide is JS-applied (SSR/non-JS still shows content), entrance restarted on view.
|
|
assert.match(DITTO_MOTION_TSX, /el\.style\.visibility = "hidden"/);
|
|
assert.match(DITTO_MOTION_TSX, /el\.style\.animationName = rv\.animationName/);
|
|
assert.match(DITTO_MOTION_TSX, /el\.style\.visibility = "visible"/);
|
|
// Validator settle path reveals WITHOUT replaying (no mid-entrance graded frames).
|
|
assert.match(DITTO_MOTION_TSX, /f\(false\)/);
|
|
});
|
|
|
|
// Fix 2 — reveal-replay stagger must not leave tiles unpainted. A long captured entrance
|
|
// delay/duration replayed on scroll-into-view means a fast scroll / full-page screenshot
|
|
// catches most tiles mid-entrance. The replay caps the delay + duration and force-settles
|
|
// each tile shortly after it enters view, so tiles paint promptly and none stay hidden.
|
|
it("caps the replayed entrance delay and duration", () => {
|
|
// The animate path uses the clamp helpers rather than the raw captured values.
|
|
assert.match(DITTO_MOTION_TSX, /el\.style\.animationDuration = clampDuration\(rv\.animationDuration\)/);
|
|
assert.match(DITTO_MOTION_TSX, /el\.style\.animationDelay = clampDelay\(rv\.animationDelay\)/);
|
|
// Caps are bounded (delay <= 300ms, duration <= 600ms).
|
|
assert.match(DITTO_MOTION_TSX, /REVEAL_MAX_DELAY_MS = 300/);
|
|
assert.match(DITTO_MOTION_TSX, /REVEAL_MAX_DURATION_MS = 600/);
|
|
});
|
|
|
|
it("force-settles each revealed tile per-element and settles everything at the failsafe", () => {
|
|
// Per-element settle timer jumps a tile to its settled frame a bounded time after it animates
|
|
// in (so it can't be caught mid-entrance regardless of WHEN it scrolled into view).
|
|
assert.match(DITTO_MOTION_TSX, /settleTimers\.push\(setTimeout\(\(\) => f\(false\), settleAfter\)\)/);
|
|
// Global failsafe settles (animate=false) rather than re-animating — nothing stays hidden.
|
|
assert.match(DITTO_MOTION_TSX, /forceTimer = setTimeout\(\(\) => \{ for \(const f of revealed\) f\(false\); \}, 4000\)/);
|
|
});
|
|
});
|