Initial commit

This commit is contained in:
Samraaj Bath
2026-06-29 15:11:48 -07:00
commit 66dfdcc58d
404 changed files with 45970 additions and 0 deletions
+350
View File
@@ -0,0 +1,350 @@
import type { Page } from "playwright";
/**
* Stage 5 motion capture. Runs at the canonical viewport AFTER `tagElements` (so every
* element carries a `data-cid-cap` the IR threads through to a cid). Two families that
* the declarative CSS path (per-node `animation-name` + captured `@keyframes`, handled
* entirely in the IR/generator) does NOT cover:
*
* - **WAAPI** (`element.animate(...)`, e.g. Framer Motion): only ever visible via
* `document.getAnimations()`. Captured here as keyframes + timing so a fixed client
* controller can replay it. (Entrance WAAPI that already finished by capture time is
* not observable — persistent/looping WAAPI is; that's the deterministic subset.)
* - **Rotating text**: a JS interval swapping an element's text (shopify "category
* creator → global empire", notion "Ship → Create"). Observed with a MutationObserver
* over a short window; captured as the text cycle + cadence for a fixed controller.
*
* CSS @keyframes/transition motion is intentionally NOT re-captured here — it is fully
* reconstructable from the static evidence already in the IR, which is the most
* deterministic path.
*/
export type WaapiAnim = {
cap: string; // data-cid-cap of the animated element (→ cid at generation)
keyframes: Array<Record<string, string | number>>; // effect.getKeyframes()
duration: number; // ms
delay: number; // ms
easing: string;
iterations: number; // -1 encodes Infinity (JSON-safe)
direction: string;
fill: string;
};
export type RotatorSpec = {
cap: string; // data-cid-cap of the element whose text cycles
texts: string[]; // the observed cycle of trimmed text values (in order)
intervalMs: number; // approximate cadence between swaps
};
export type RevealSpec = {
cap: string; // data-cid-cap of a scroll-revealed element
opacity: string; // its hidden (pre-reveal) opacity, e.g. "0"
transform: string; // its hidden transform (slide/scale offset), or "none"
transition: string; // the transition to animate the reveal with
};
export type MarqueeSpec = {
cap: string; // data-cid-cap of the continuously-translating track
axis: "x"; // horizontal marquee (the common case; vertical reserved)
pxPerSec: number; // signed scroll velocity (negative = leftward)
periodPx: number; // distance per seamless loop (one duplicated copy ≈ scrollWidth/2)
};
export type MotionCapture = {
waapi: WaapiAnim[];
rotators: RotatorSpec[];
reveals: RevealSpec[]; // scroll-triggered entrance reveals (start hidden, reveal on view)
marquees: MarqueeSpec[]; // rAF-driven continuous tickers (Framer Motion etc.) — not in getAnimations()
cssAnimated: number; // elements with a computed animation-name (informational)
};
/**
* Stage 5 (scroll reveals) — pre-scroll probe. Scroll-triggered reveals start hidden
* (opacity:0 / transform offset) and animate in when scrolled into view; by the time the
* settled snapshot is taken (after `autoScroll` has walked the page) they are already
* revealed, so their hidden state must be sampled BEFORE the first scroll. Records, on
* `window.__cloneReveal`, the pre-scroll opacity/transform/transition of every tagged
* element that is hidden-but-boxed with a real transition — the candidate reveal set
* `captureMotion` later confirms (kept only if the element ends up visible). Idempotent;
* call once at the first viewport, after settle, before `autoScroll`.
*/
export async function probeReveals(page: Page): Promise<void> {
try {
await page.evaluate(() => {
const out: Record<string, { opacity: string; transform: string; transition: string }> = {};
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) {
const cap = el.getAttribute("data-cid-cap"); if (!cap) continue;
let cs: CSSStyleDeclaration; try { cs = getComputedStyle(el); } catch { continue; }
const op = parseFloat(cs.opacity || "1");
if (op > 0.05) continue; // only currently-hidden elements are reveal candidates
const r = (el as HTMLElement).getBoundingClientRect();
if (r.width < 8 || r.height < 8) continue; // must occupy real space (not a 0-box hidden node)
// must have a transition on opacity/transform/all (so the reveal animates, not snaps)
const tp = (cs.transitionProperty || "").toLowerCase();
const td = cs.transitionDuration || "0s";
const animates = /opacity|transform|all/.test(tp) && !/^0s(,\s*0s)*$/.test(td);
if (!animates) continue;
out[cap] = {
opacity: cs.opacity || "0",
transform: cs.transform && cs.transform !== "none" ? cs.transform : "none",
transition: cs.transition && cs.transition !== "all 0s ease 0s" ? cs.transition : `opacity ${td}, transform ${td}`,
};
}
(window as unknown as { __cloneReveal?: unknown }).__cloneReveal = out;
});
} catch { /* ignore */ }
}
/**
* Stage 5 (marquees) — rAF-driven continuous tickers. Framer Motion (and similar)
* drive infinite horizontal scrollers by mutating an element's `transform` every frame
* via requestAnimationFrame, so they are invisible to `getAnimations()` (the WAAPI path)
* and have no CSS `@keyframes` (the declarative path). They are also paused while
* off-screen, so the velocity is only observable after scrolling the track into view.
* Detect a track by a translateX that changes steadily in one direction once visible,
* and record its signed velocity + seamless-loop period (one duplicated copy ≈ half the
* scroll width) so the clone can replay the loop. Read-only beyond scrolling (restores
* scroll to top); does not touch the captured snapshot/IR.
*/
async function detectMarquees(page: Page): Promise<MarqueeSpec[]> {
try {
return await page.evaluate(async () => {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const txOf = (el: Element): number => { try { return new DOMMatrixReadOnly(getComputedStyle(el).transform).m41; } catch { return 0; } };
const inClip = (el: Element): boolean => {
let p = el.parentElement, depth = 0;
while (p && depth < 6) { const ox = getComputedStyle(p).overflowX; if (ox === "hidden" || ox === "clip") return true; p = p.parentElement; depth++; }
return false;
};
// Candidates: tagged, already translated (paused tickers keep their offset), with
// duplicated content (≥2 children) inside an overflow-clip viewport — the marquee shape.
const cand: Element[] = [];
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) {
if (Math.abs(txOf(el)) <= 0.5) continue;
if (el.children.length < 2) continue;
if (!inClip(el)) continue;
cand.push(el);
if (cand.length >= 16) break;
}
const out: Array<{ cap: string; axis: "x"; pxPerSec: number; periodPx: number }> = [];
const seen = new Set<string>();
for (const el of cand) {
const cap = el.getAttribute("data-cid-cap"); if (!cap || seen.has(cap)) continue;
try { el.scrollIntoView({ block: "center" }); } catch { /* ignore */ }
await sleep(450); // wake the off-screen-paused ticker + let the scroll settle
const xs: number[] = [];
for (let i = 0; i < 6; i++) { xs.push(txOf(el)); await sleep(120); }
const dxs: number[] = []; for (let i = 1; i < xs.length; i++) dxs.push(xs[i]! - xs[i - 1]!);
if (dxs.length < 3) continue;
// velocity = median per-120ms delta (median ignores the single wrap-reset outlier).
const med = [...dxs].sort((a, b) => a - b)[Math.floor(dxs.length / 2)]!;
const pxPerSec = Math.round((med / 120) * 1000);
if (Math.abs(pxPerSec) < 4) continue; // not actually moving (e.g. a frozen scroll-scrub offset)
// direction must be consistent in all but (at most) the one wrap step.
if (dxs.filter((d) => Math.sign(d) === Math.sign(med)).length < dxs.length - 1) continue;
const periodPx = Math.round(el.scrollWidth / 2);
if (periodPx < 40) continue;
seen.add(cap);
out.push({ cap, axis: "x", pxPerSec, periodPx });
}
try { window.scrollTo(0, 0); } catch { /* ignore */ }
return out;
});
} catch { return []; }
}
/**
* Stage 5 (scroll-scrub reveals) — scroll-LINKED entrance animations. Some sections
* (framer's "Agents" sticky-pinned panels) drive opacity/transform as a continuous
* function of scroll position rather than a one-way IntersectionObserver reveal, so they
* start only PARTIALLY hidden (opacity ~0.2, a translate offset) and `probeReveals`
* (which requires opacity ≤ 0.05 pre-scroll) never sees them — and the settled snapshot
* catches them frozen MID-scrub (opacity 0.63), which the clone then bakes as a dimmed,
* offset panel. Detect them by sampling each panel's opacity/transform across a full
* scroll pass: a panel that is dimmed/slid at some scroll position but reaches full
* opacity at another is a scroll reveal. Reconstruct as a normal reveal (hide at the
* most-dimmed state, transition to full on view) — the deterministic, gate-reconciled
* path (the validator force-reveals before grading, so gates measure the revealed frame).
*/
async function detectScrubReveals(page: Page): Promise<RevealSpec[]> {
try {
return await page.evaluate(async () => {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const txOf = (cs: CSSStyleDeclaration): number => { try { return new DOMMatrixReadOnly(cs.transform).m41; } catch { return 0; } };
// Panel-like candidates: tagged, sized, content-bearing, in normal flow.
const cands: HTMLElement[] = [];
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]")) as HTMLElement[]) {
const r = el.getBoundingClientRect();
if (r.width < 120 || r.height < 60) continue;
const pos = getComputedStyle(el).position;
if (pos === "fixed" || pos === "sticky") continue;
if (!el.querySelector("img, p, h1, h2, h3, span")) continue; // has real content
cands.push(el);
if (cands.length >= 1200) break;
}
const samples = new Map<HTMLElement, Array<{ op: number; tx: number }>>();
const maxY = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
const steps = 14;
for (let i = 0; i <= steps; i++) {
window.scrollTo(0, Math.round((maxY * i) / steps));
await sleep(120);
for (const el of cands) {
const cs = getComputedStyle(el);
let a = samples.get(el); if (!a) { a = []; samples.set(el, a); }
a.push({ op: parseFloat(cs.opacity || "1"), tx: txOf(cs) });
}
}
window.scrollTo(0, 0);
const found: Array<{ el: HTMLElement; cap: string; opacity: string; transform: string; transition: string }> = [];
for (const [el, a] of samples) {
const ops = a.map((s) => s.op), absTx = a.map((s) => Math.abs(s.tx));
const maxOp = Math.max(...ops), minOp = Math.min(...ops);
const maxTx = Math.max(...absTx), minTx = Math.min(...absTx);
const opReveal = maxOp > 0.9 && minOp < 0.85; // fades in across scroll
const txReveal = maxOp > 0.9 && maxTx > 6 && minTx < 2; // slides in across scroll
if (!opReveal && !txReveal) continue;
// hidden = the most-dimmed sample (lowest opacity, tie-break largest offset).
let hidden = a[0]!;
for (const s of a) if (s.op < hidden.op - 0.001 || (Math.abs(s.op - hidden.op) <= 0.001 && Math.abs(s.tx) > Math.abs(hidden.tx))) hidden = s;
const cap = el.getAttribute("data-cid-cap"); if (!cap) continue;
found.push({
el, cap,
opacity: String(Math.max(0, Math.min(hidden.op, 0.5))),
transform: Math.abs(hidden.tx) > 2 ? `translateX(${Math.round(hidden.tx)}px)` : "none",
transition: "opacity 0.6s ease, transform 0.6s ease",
});
}
// Keep only the OUTERMOST scrub per nesting (a panel and its scrubbed children both match).
const outer = found.filter((f) => !found.some((o) => o !== f && o.el.contains(f.el)));
return outer.slice(0, 40).map(({ cap, opacity, transform, transition }) => ({ cap, opacity, transform, transition }));
});
} catch { return []; }
}
export async function captureMotion(page: Page, opts?: { observeMs?: number; log?: (e: Record<string, unknown>) => void }): Promise<MotionCapture> {
const log = opts?.log ?? (() => {});
const observeMs = opts?.observeMs ?? 2600;
try {
const result = await Promise.race([
page.evaluate(async (budget: number) => {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// ---- WAAPI: pure script-driven animations (exclude CSS animations/transitions,
// which the declarative path reproduces). Capture persistent/looping ones — the
// deterministic subset still alive at capture time. ----
const waapi: Array<{
cap: string; keyframes: Array<Record<string, string | number>>;
duration: number; delay: number; easing: string; iterations: number; direction: string; fill: string;
}> = [];
try {
const anims = (document as Document).getAnimations ? document.getAnimations() : [];
const seen = new Set<string>();
for (const a of anims) {
const ctor = a.constructor.name;
if (ctor === "CSSAnimation" || ctor === "CSSTransition") continue; // declarative path owns these
const eff = a.effect as KeyframeEffect | null;
const target = eff?.target as Element | undefined;
const cap = target?.getAttribute?.("data-cid-cap");
if (!cap || seen.has(cap)) continue;
let kfs: Array<Record<string, string | number>> = [];
try { kfs = (eff!.getKeyframes() as unknown as Array<Record<string, string | number>>); } catch { kfs = []; }
if (!kfs.length) continue;
const t = eff!.getTiming();
const iters = t.iterations === Infinity ? -1 : (typeof t.iterations === "number" ? t.iterations : 1);
seen.add(cap);
waapi.push({
cap,
keyframes: kfs,
duration: typeof t.duration === "number" ? t.duration : 0,
delay: t.delay ?? 0,
easing: String(t.easing ?? "linear"),
iterations: iters,
direction: String(t.direction ?? "normal"),
fill: String(t.fill ?? "none"),
});
}
} catch { /* ignore */ }
// ---- Rotating text: watch for elements whose text content cycles. Record the
// smallest text-bearing element that changes (so we cycle the word, not a whole
// section), the ordered set of values it shows, and the cadence. ----
const changes = new Map<string, { texts: string[]; times: number[] }>();
const norm = (s: string) => s.replace(/\s+/g, " ").trim();
const obs = new MutationObserver((records) => {
for (const rec of records) {
// climb to the nearest element with a data-cid-cap
let el: Node | null = rec.target;
while (el && !(el as Element).getAttribute?.("data-cid-cap")) el = el.parentNode;
const cap = el && (el as Element).getAttribute?.("data-cid-cap");
if (!cap) continue;
const elem = el as Element;
// only track small text-bearing elements (a rotating word/phrase, not a big block)
if (elem.querySelector("[data-cid-cap]")) continue; // has element children → not a leaf word
const txt = norm(elem.textContent || "");
if (!txt || txt.length > 80) continue;
const e = changes.get(cap) ?? { texts: [], times: [] };
if (e.texts[e.texts.length - 1] !== txt) { e.texts.push(txt); e.times.push(performance.now()); }
changes.set(cap, e);
}
});
obs.observe(document.body, { subtree: true, childList: true, characterData: true });
await sleep(budget);
obs.disconnect();
const rotators: Array<{ cap: string; texts: string[]; intervalMs: number }> = [];
for (const [cap, e] of changes) {
// a genuine rotator shows ≥2 distinct values
const distinct = Array.from(new Set(e.texts));
if (distinct.length < 2) continue;
let interval = 0;
if (e.times.length >= 2) {
const gaps: number[] = [];
for (let i = 1; i < e.times.length; i++) gaps.push(e.times[i]! - e.times[i - 1]!);
gaps.sort((a, b) => a - b);
interval = Math.round(gaps[Math.floor(gaps.length / 2)]!);
}
rotators.push({ cap, texts: distinct.slice(0, 12), intervalMs: interval || 2000 });
}
// ---- Scroll reveals: confirm the pre-scroll candidates (probeReveals) that are
// NOW visible — those genuinely revealed on scroll (vs. elements that stayed hidden).
const reveals: Array<{ cap: string; opacity: string; transform: string; transition: string }> = [];
try {
const probed = (window as unknown as { __cloneReveal?: Record<string, { opacity: string; transform: string; transition: string }> }).__cloneReveal || {};
for (const cap of Object.keys(probed)) {
const el = document.querySelector(`[data-cid-cap="${cap}"]`);
if (!el) continue;
const cs = getComputedStyle(el);
if (parseFloat(cs.opacity || "1") <= 0.05) continue; // still hidden → not a reveal, just hidden
const r = (el as HTMLElement).getBoundingClientRect();
if (r.width < 8 || r.height < 8) continue;
reveals.push({ cap, ...probed[cap]! });
}
} catch { /* ignore */ }
let cssAnimated = 0;
for (const el of Array.from(document.querySelectorAll("*"))) {
try { const an = getComputedStyle(el).animationName; if (an && an !== "none") cssAnimated++; } catch { /* ignore */ }
}
return { waapi, rotators, reveals, cssAnimated };
}, observeMs),
new Promise<Omit<MotionCapture, "marquees">>((res) => setTimeout(() => res({ waapi: [], rotators: [], reveals: [], cssAnimated: 0 }), observeMs + 4000)),
]);
// Scroll-scrub reveals: scroll-linked panels probeReveals can't see (they start only
// partially hidden). Merge into reveals, preferring the probe-confirmed entry when a cap
// appears in both.
const scrubReveals = await detectScrubReveals(page);
const reveals = [...result.reveals];
const haveCap = new Set(reveals.map((r) => r.cap));
for (const s of scrubReveals) if (!haveCap.has(s.cap)) { reveals.push(s); haveCap.add(s.cap); }
// Marquees run as a separate pass (scrolls each track into view to wake the paused
// rAF ticker; kept after the rotator MutationObserver window so it can't add false text rotators).
const marquees = await detectMarquees(page);
log({ event: "motion_captured", waapi: result.waapi.length, rotators: result.rotators.length, reveals: reveals.length, marquees: marquees.length, cssAnimated: result.cssAnimated });
return { ...result, reveals, marquees };
} catch (e) {
log({ event: "motion_error", error: String(e).slice(0, 200) });
return { waapi: [], rotators: [], reveals: [], marquees: [], cssAnimated: 0 };
}
}