Wave 4: reveal settling + replay, 206/magic-byte media fix, hidden-geometry banding, bare-zero utility gating

Capture (4a):
- stabilize: dwell-scroll settling pass + known-library pre-reveal
  neutralization after lazy promotion, so every viewport snapshot records
  the post-reveal steady state (about-page sections no longer captured
  blanket-hidden: 24 base `invisible` wrappers -> 0)
- probeReveals: extended to the visibility + entrance-animation family;
  DittoMotion replays them (JS re-hide on mount, IntersectionObserver
  reveal, 4s force-reveal failsafe) so SSR/non-JS still shows content
- assets: 206 range-response bodies are no longer stored as full assets
  (full-fetch fallback) + video magic-byte validation - the about-page
  hero video is now a valid 6.0MB ftyp mp4 instead of 18.9KB of garbage

Generate (4b):
- css: hidden-node geometry policy - a visibility:hidden box with a 0x0
  captured bbox at a band becomes display:none there; an OCCUPYING hidden
  box gets the captured per-viewport geometry instead of the baked
  canonical one. Also covers the ancestor-hidden-at-base case (the
  cropin.com/cotton slider arrow: hide inherited from an elementor-widget
  ancestor at every width parked a desktop left:548px box inside a 375px
  viewport -> body.scrollWidth 585; now 375 on both verified pages)
- tailwind: bare-zero utility gating - font-size:0 -> text-[0px] and
  letter-spacing:0 -> tracking-[0px] (text-0/tracking-0 are silently
  invalid in Tailwind v4; fixes permanently-visible map labels on the
  cotton Global-presence section)

Verified: 87/87 compiler tests green, full-workspace suite green,
regenerated cropin.com + /cotton/ builds measure 375px scrollWidth at a
375px viewport, and a fresh /about/ clone renders all reveal sections
with scroll-replay against the live site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-03 18:33:10 -07:00
co-authored by Claude Fable 5
parent 9aed2540aa
commit 199359d0a6
6 changed files with 494 additions and 15 deletions
+135
View File
@@ -0,0 +1,135 @@
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\)/);
});
});