Merge pull request #14 from ion-design/clone-fidelity-fixes
Clone-fidelity fixes from the new audit reviews
This commit is contained in:
@@ -0,0 +1,83 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Autoplay carousel fixture</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; color: #1a1a1a; }
|
||||||
|
main { max-width: 720px; margin: 0 auto; padding: 40px 24px; }
|
||||||
|
|
||||||
|
/* Splide-like autoplaying carousel: clipped track, flex list moved by translateX,
|
||||||
|
pagination bullets, pause-on-hover (the library default this fixture mimics). */
|
||||||
|
.splide { position: relative; }
|
||||||
|
.splide__track { overflow: hidden; border-radius: 10px; }
|
||||||
|
.splide__list { display: flex; transition: transform .2s ease; margin: 0; padding: 0; list-style: none; }
|
||||||
|
.splide__slide { flex: 0 0 100%; min-width: 100%; height: 200px; display: flex; align-items: center; justify-content: center; font-size: 34px; color: #fff; }
|
||||||
|
.a0 { background: #4f46e5; } .a1 { background: #0891b2; } .a2 { background: #16a34a; } .a3 { background: #db2777; }
|
||||||
|
.splide__pagination { display: flex; gap: 8px; justify-content: center; margin-top: 12px; padding: 0; list-style: none; }
|
||||||
|
.splide__pagination__bullet { width: 10px; height: 10px; border-radius: 50%; border: 0; padding: 0; cursor: pointer; background: #d1d5db; }
|
||||||
|
.splide__pagination__bullet.is-active { background: #4f46e5; }
|
||||||
|
|
||||||
|
/* A control-less advanced track (what an interaction pass can leave behind):
|
||||||
|
no bullets, no arrows, not loop-mode — settle must pin it back to home. */
|
||||||
|
.swiper { overflow: hidden; border-radius: 10px; margin-top: 32px; }
|
||||||
|
.swiper-wrapper { display: flex; }
|
||||||
|
.swiper-slide { flex: 0 0 100%; min-width: 100%; height: 120px; display: flex; align-items: center; justify-content: center; font-size: 24px; color: #fff; background: #ea580c; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Autoplay carousel</h1>
|
||||||
|
|
||||||
|
<div id="auto" class="splide" aria-roledescription="carousel" aria-label="Autoplay">
|
||||||
|
<div class="splide__track">
|
||||||
|
<ul class="splide__list">
|
||||||
|
<li class="splide__slide a0 is-active">Slide 1</li>
|
||||||
|
<li class="splide__slide a1">Slide 2</li>
|
||||||
|
<li class="splide__slide a2">Slide 3</li>
|
||||||
|
<li class="splide__slide a3">Slide 4</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<ul class="splide__pagination">
|
||||||
|
<li><button class="splide__pagination__bullet is-active" aria-label="Go to slide 1"></button></li>
|
||||||
|
<li><button class="splide__pagination__bullet" aria-label="Go to slide 2"></button></li>
|
||||||
|
<li><button class="splide__pagination__bullet" aria-label="Go to slide 3"></button></li>
|
||||||
|
<li><button class="splide__pagination__bullet" aria-label="Go to slide 4"></button></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="stuck" class="swiper">
|
||||||
|
<div class="swiper-wrapper" style="transform: translateX(-100%);">
|
||||||
|
<div class="swiper-slide">A</div>
|
||||||
|
<div class="swiper-slide">B</div>
|
||||||
|
<div class="swiper-slide">C</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const root = document.getElementById("auto");
|
||||||
|
const list = root.querySelector(".splide__list");
|
||||||
|
const slides = [...root.querySelectorAll(".splide__slide")];
|
||||||
|
const bullets = [...root.querySelectorAll(".splide__pagination__bullet")];
|
||||||
|
let i = 0;
|
||||||
|
let paused = false;
|
||||||
|
const go = (n) => {
|
||||||
|
i = ((n % slides.length) + slides.length) % slides.length;
|
||||||
|
list.style.transform = "translateX(-" + (i * 100) + "%)";
|
||||||
|
slides.forEach((s, k) => s.classList.toggle("is-active", k === i));
|
||||||
|
bullets.forEach((b, k) => b.classList.toggle("is-active", k === i));
|
||||||
|
};
|
||||||
|
bullets.forEach((b, k) => b.addEventListener("click", () => go(k)));
|
||||||
|
// pause-on-hover, the Splide/Slick default this fixture mimics
|
||||||
|
root.addEventListener("mouseenter", () => { paused = true; });
|
||||||
|
root.addEventListener("mouseleave", () => { paused = false; });
|
||||||
|
window.__autoplayTicks = 0;
|
||||||
|
setInterval(() => { if (!paused) { window.__autoplayTicks++; go(i + 1); } }, 250);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Newsletter embed</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; background: #1a1a2e; color: #ffffff; font-family: Arial, sans-serif; }
|
||||||
|
form { padding: 12px; }
|
||||||
|
label { display: block; margin-bottom: 6px; font-size: 13px; }
|
||||||
|
input { display: block; width: 200px; color: #ffffff; background: #333344; border: 1px solid #555577; padding: 6px; }
|
||||||
|
input::placeholder { color: rgb(200, 150, 100); }
|
||||||
|
button { margin-top: 8px; background: #e94560; color: #ffffff; border: 0; padding: 8px 16px; }
|
||||||
|
a { color: #8888ff; font-size: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form id="signup" action="/subscribe">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input id="email" type="email" placeholder="Enter your email">
|
||||||
|
<button type="submit">Sign up</button>
|
||||||
|
<a href="#terms">Terms</a>
|
||||||
|
<img src="/pixel.png" alt="" width="10" height="10">
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Iframe host</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; font-family: Arial, sans-serif; background: #ffffff; }
|
||||||
|
h1 { margin: 20px; font-size: 24px; }
|
||||||
|
iframe.signup {
|
||||||
|
display: block;
|
||||||
|
margin: 30px 0 0 40px;
|
||||||
|
width: 320px;
|
||||||
|
height: 160px;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Host page</h1>
|
||||||
|
<!-- {{FRAME_ORIGIN}} is replaced by the test server with the second (cross-origin) server's origin. -->
|
||||||
|
<iframe class="signup" title="Newsletter" aria-label="Modal Overlay Box Frame" src="{{FRAME_ORIGIN}}/iframe-embed.html"></iframe>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Lazy-media fixture</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; padding: 24px; }
|
||||||
|
img { display: block; }
|
||||||
|
.bg { width: 300px; height: 150px; background-size: cover; background-repeat: no-repeat; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Lazy media</h1>
|
||||||
|
|
||||||
|
<!-- WP Rocket style: 0-size placeholder in src, real URL in data-lazy-src. -->
|
||||||
|
<img id="lazy1" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-lazy-src="og-image.png" alt="hero">
|
||||||
|
|
||||||
|
<!-- lazysizes style: no src at all, data-src + data-sizes="auto" (a swap flag, not a value). -->
|
||||||
|
<img id="lazy2" data-src="seo-icon.png" data-sizes="auto" alt="icon">
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source id="lazysource" type="image/png" data-srcset="og-image.png 1x">
|
||||||
|
<img id="lazy3" data-src="og-image.png" alt="picture">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
<!-- Lazy background image (WP Rocket url(...) form). -->
|
||||||
|
<div id="lazybg" class="bg" data-bg="url('brand.svg')"></div>
|
||||||
|
|
||||||
|
<!-- Must NOT be promoted: the data attr is not a plausible URL. -->
|
||||||
|
<img id="notaurl" data-src="{{ placeholder }}" alt="template token">
|
||||||
|
|
||||||
|
<!-- Must NOT count as a promotion: src already equals the target. -->
|
||||||
|
<img id="already" src="seo-icon.png" data-src="seo-icon.png" alt="already swapped">
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Placeholder styling</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; font-family: Arial, sans-serif; }
|
||||||
|
input.styled { color: rgb(10, 20, 30); font-size: 16px; }
|
||||||
|
input.styled::placeholder { color: rgb(120, 30, 200); font-size: 14px; }
|
||||||
|
textarea.styled::placeholder { color: rgb(5, 100, 5); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<input class="styled" type="email" placeholder="Enter your email">
|
||||||
|
<textarea class="styled" placeholder="Your message"></textarea>
|
||||||
|
<input class="plain" type="text" value="no placeholder here">
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Reveal fixture</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; font-family: sans-serif; }
|
||||||
|
.spacer { height: 2200px; background: linear-gradient(#eee, #ddd); }
|
||||||
|
/* Elementor pattern: content wrappers hidden at load, revealed by a waypoint class
|
||||||
|
swap that applies an entrance keyframe animation. */
|
||||||
|
.elementor-invisible { visibility: hidden; }
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from { opacity: 0; transform: translate3d(0, 40px, 0); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.animated { animation-duration: 0.4s; animation-fill-mode: both; }
|
||||||
|
.fadeInUp { animation-name: fadeInUp; }
|
||||||
|
.reveal-box { height: 220px; background: #2e8b57; color: #fff; padding: 20px; }
|
||||||
|
#never { visibility: hidden; height: 60px; background: #999; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Reveal fixture</h1>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<!-- Waypoint reveal: hidden at load, class-swapped when scrolled into view. -->
|
||||||
|
<div id="reveal-io" class="reveal-box elementor-invisible" data-io-reveal data-settings='{"_animation":"fadeInUp"}'>
|
||||||
|
<p>Scroll-revealed content (IntersectionObserver)</p>
|
||||||
|
</div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<!-- Same pre-reveal marker but keyed to a trigger the dwell walk cannot fire:
|
||||||
|
only the known-library neutralization reveals it. -->
|
||||||
|
<div id="reveal-far" class="reveal-box elementor-invisible" data-settings='{"_animation":"fadeInUp"}'>
|
||||||
|
<p>Reveal keyed to a non-scroll trigger</p>
|
||||||
|
</div>
|
||||||
|
<!-- Genuinely hidden, non-library content: must STAY hidden in the capture. -->
|
||||||
|
<div id="never">permanently hidden</div>
|
||||||
|
<script>
|
||||||
|
const io = new IntersectionObserver((entries) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
if (!e.isIntersecting) continue;
|
||||||
|
e.target.classList.remove("elementor-invisible");
|
||||||
|
e.target.classList.add("animated", "fadeInUp");
|
||||||
|
io.unobserve(e.target);
|
||||||
|
}
|
||||||
|
}, { threshold: 0.1 });
|
||||||
|
for (const el of document.querySelectorAll("[data-io-reveal]")) io.observe(el);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -3,6 +3,14 @@ import { join } from "node:path";
|
|||||||
import { collectPage, type PageSnapshot, type FontFace } from "./walker.js";
|
import { collectPage, type PageSnapshot, type FontFace } from "./walker.js";
|
||||||
import { tagElements, captureInteractions, type InteractionCapture } from "./interactions.js";
|
import { tagElements, captureInteractions, type InteractionCapture } from "./interactions.js";
|
||||||
import { captureMotion, probeReveals, type MotionCapture } from "./motion.js";
|
import { captureMotion, probeReveals, type MotionCapture } from "./motion.js";
|
||||||
|
import {
|
||||||
|
promoteLazyMedia, settleCarousels, settleScrollReveals, neutralizePreReveal,
|
||||||
|
forceRevealForShot, restoreRevealForShot, neutralizeScrollTimelineAnimations,
|
||||||
|
} from "./stabilize.js";
|
||||||
|
import {
|
||||||
|
enumerateFramesInPage, planForFrameUrl, graftFrameIntoSnapshot, frameHasRenderableContent,
|
||||||
|
MAX_GRAFT_FRAMES, FRAME_GRAFT_MAX_NODES, type FrameCandidate,
|
||||||
|
} from "./graft.js";
|
||||||
import { discoverBreakpoints } from "./breakpoints.js";
|
import { discoverBreakpoints } from "./breakpoints.js";
|
||||||
import { writeJSON, writeJSONCompact, writeBytes, ensureDir } from "../util/fsx.js";
|
import { writeJSON, writeJSONCompact, writeBytes, ensureDir } from "../util/fsx.js";
|
||||||
import { sha1_12, round } from "../util/canonical.js";
|
import { sha1_12, round } from "../util/canonical.js";
|
||||||
@@ -90,6 +98,23 @@ function viewportHeight(width: number): number {
|
|||||||
return VIEWPORT_HEIGHTS[width] ?? Math.round(width * 0.66);
|
return VIEWPORT_HEIGHTS[width] ?? Math.round(width * 0.66);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Asset download retry (Fix: transient failures silently degrade to placeholders) ----
|
||||||
|
|
||||||
|
/** Types whose absence is visible in the clone (image → transparent GIF, video → blank,
|
||||||
|
* font → fallback face). These earn one retry; css/manifest/lottie degrade gracefully. */
|
||||||
|
const RETRYABLE_ASSET_TYPES = new Set(["image", "svg", "video", "font"]);
|
||||||
|
/** Fixed, bounded delay before the single retry — deterministic (no jitter/backoff). */
|
||||||
|
export const ASSET_RETRY_DELAY_MS = 750;
|
||||||
|
|
||||||
|
/** Should a failed download of `type` with HTTP `status` (null = network error / no
|
||||||
|
* response) be retried once? Transient states only: connection failures, 5xx, and 429.
|
||||||
|
* 4xx (404/403/…) are authoritative — retrying can't change them. */
|
||||||
|
export function isRetryableAssetFailure(type: string, status: number | null): boolean {
|
||||||
|
if (!RETRYABLE_ASSET_TYPES.has(type)) return false;
|
||||||
|
if (status === null) return true;
|
||||||
|
return status >= 500 || status === 429;
|
||||||
|
}
|
||||||
|
|
||||||
function extFromUrl(url: string): string {
|
function extFromUrl(url: string): string {
|
||||||
try {
|
try {
|
||||||
const p = new URL(url).pathname;
|
const p = new URL(url).pathname;
|
||||||
@@ -137,6 +162,19 @@ function isCss(url: string, contentType: string | null): boolean {
|
|||||||
return classifyAsset(url, contentType) === "css";
|
return classifyAsset(url, contentType) === "css";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Container-magic check for accepted video bytes: ISO-BMFF (`ftyp` within the first
|
||||||
|
* 12 bytes — mp4/m4v/mov), webm/mkv (EBML 0x1A45DFA3), or ogg (`OggS`). A range
|
||||||
|
* fragment (e.g. an mp4's moov-atom tail served as a 206) fails all three, so a
|
||||||
|
* corrupt partial body is never stored as the asset. */
|
||||||
|
export function looksLikeVideoFile(bytes: Buffer): boolean {
|
||||||
|
if (bytes.length < 12) return false;
|
||||||
|
const head = bytes.subarray(0, 12);
|
||||||
|
if (head.includes("ftyp")) return true;
|
||||||
|
if (head[0] === 0x1a && head[1] === 0x45 && head[2] === 0xdf && head[3] === 0xa3) return true; // EBML (webm/mkv)
|
||||||
|
if (head[0] === 0x4f && head[1] === 0x67 && head[2] === 0x67 && head[3] === 0x53) return true; // "OggS"
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function autoScroll(page: import("playwright").Page, vpHeight: number): Promise<void> {
|
async function autoScroll(page: import("playwright").Page, vpHeight: number): Promise<void> {
|
||||||
// Scroll through the page to trigger lazy-loaded images/backgrounds, then return
|
// Scroll through the page to trigger lazy-loaded images/backgrounds, then return
|
||||||
// to the top so document-coordinate bboxes are measured from a settled layout.
|
// to the top so document-coordinate bboxes are measured from a settled layout.
|
||||||
@@ -535,6 +573,10 @@ export async function captureSite(opts: {
|
|||||||
|
|
||||||
const storeBytes = (url: string, type: string, bytes: Buffer): void => {
|
const storeBytes = (url: string, type: string, bytes: Buffer): void => {
|
||||||
if (!bytes || bytes.length === 0) return;
|
if (!bytes || bytes.length === 0) return;
|
||||||
|
// Reject non-container bytes for video from EVERY path (a range fragment or error
|
||||||
|
// page stored under first-stored-wins ships a corrupt file). Left unstored, the
|
||||||
|
// asset surfaces in visual_assets_missing instead.
|
||||||
|
if (type === "video" && !looksLikeVideoFile(bytes)) return;
|
||||||
const a = assetMap.get(url) ?? recordAsset(url, type, null, null, "network");
|
const a = assetMap.get(url) ?? recordAsset(url, type, null, null, "network");
|
||||||
if (a.storedAs) return;
|
if (a.storedAs) return;
|
||||||
const ext = extFromUrl(url) || extFromContentType(a.contentType) ||
|
const ext = extFromUrl(url) || extFromContentType(a.contentType) ||
|
||||||
@@ -655,6 +697,10 @@ export async function captureSite(opts: {
|
|||||||
const existing = assetMap.get(url);
|
const existing = assetMap.get(url);
|
||||||
if (existing?.storedAs) return;
|
if (existing?.storedAs) return;
|
||||||
if (status >= 400) return;
|
if (status >= 400) return;
|
||||||
|
// A 206 body is a RANGE FRAGMENT (media seek), not the asset — storing it would
|
||||||
|
// ship a corrupt file under first-stored-wins and the full-download fallback
|
||||||
|
// would then skip the asset. Record only; the fallback pass fetches the 200 body.
|
||||||
|
if (status === 206) return;
|
||||||
bodyPromises.push(
|
bodyPromises.push(
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -684,6 +730,42 @@ export async function captureSite(opts: {
|
|||||||
if (!navigated) throw navErr;
|
if (!navigated) throw navErr;
|
||||||
await settle(page);
|
await settle(page);
|
||||||
|
|
||||||
|
// Stage 2: lazy-loader promotion. WP Rocket/lazysizes keep a 0-size placeholder in
|
||||||
|
// `src` with the real URL in data attrs; autoScroll outruns their IntersectionObserver
|
||||||
|
// (collapsed sections in the snapshot) and the interaction pass can trigger the swap
|
||||||
|
// midway (viewports then DISAGREE about the section's size). Promote once, before any
|
||||||
|
// snapshot, so every viewport measures the same loaded media.
|
||||||
|
const lazyPromoted = await promoteLazyMedia(page);
|
||||||
|
if (lazyPromoted) {
|
||||||
|
log({ event: "lazy_promoted", count: lazyPromoted });
|
||||||
|
await settle(page, 2000); // newly-real images reflow the layout
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 2: reveal settling. Scroll reveals (Elementor waypoints, WOW/AOS class swaps)
|
||||||
|
// are the same class of one-shot load-state as lazy media: fire them ONCE, before any
|
||||||
|
// viewport snapshot, so every width records the POST-REVEAL steady state — otherwise
|
||||||
|
// the snapshot bakes `visibility:hidden` wrappers (or a mid-fade opacity) with no JS
|
||||||
|
// to ever reveal them, and the clone renders below-fold content blank.
|
||||||
|
// A scroll-locking consent wall would defeat the dwell walk, so clear it first (the
|
||||||
|
// per-viewport dismissal below still runs and owns the audit trail).
|
||||||
|
const preClicked = await clickDismiss(page);
|
||||||
|
if (preClicked.length) {
|
||||||
|
for (const d of preClicked) if (!dismissUnion.dismissed.includes(d)) dismissUnion.dismissed.push(d);
|
||||||
|
await settle(page, 1000);
|
||||||
|
}
|
||||||
|
// Motion capture needs the PRE-reveal hidden state, observable only before the first
|
||||||
|
// scroll — tag + probe now (captureMotion confirms the candidates at the canonical
|
||||||
|
// snapshot, reading each revealed element's entrance animation from computed style).
|
||||||
|
if (opts.motion) { await tagElements(page); await probeReveals(page); }
|
||||||
|
const revealSettle = await settleScrollReveals(page);
|
||||||
|
log({ event: "reveals_settled", ...revealSettle });
|
||||||
|
// Belt-and-braces: reveal any element STILL carrying a known-library pre-reveal marker
|
||||||
|
// (below the step bound, or keyed to a non-scroll trigger) via the library's own
|
||||||
|
// revealed state, so captured computed styles are genuine post-reveal values.
|
||||||
|
const neutralized = await neutralizePreReveal(page);
|
||||||
|
if (neutralized) log({ event: "prereveal_neutralized", count: neutralized });
|
||||||
|
await settle(page, 1500); // revealed content reflows the layout
|
||||||
|
|
||||||
for (const vw of viewports) {
|
for (const vw of viewports) {
|
||||||
const vh = viewportHeight(vw);
|
const vh = viewportHeight(vw);
|
||||||
await page.setViewportSize({ width: vw, height: vh });
|
await page.setViewportSize({ width: vw, height: vh });
|
||||||
@@ -710,12 +792,8 @@ export async function captureSite(opts: {
|
|||||||
};
|
};
|
||||||
await applyDismiss("load");
|
await applyDismiss("load");
|
||||||
|
|
||||||
// Stage 5 (scroll reveals): at the FIRST viewport, before any scroll, tag elements
|
// (Scroll-reveal probing happens once, before the viewport loop — see the reveal
|
||||||
// and snapshot the pre-reveal hidden state — scroll reveals fire on the first
|
// settling pass above; by this point all one-shot reveals have already fired.)
|
||||||
// autoScroll and stay revealed, so this is the only moment their hidden state is
|
|
||||||
// observable. Idempotent + motion-gated; the settled snapshot is unchanged.
|
|
||||||
if (opts.motion && vw === viewports[0]) { await tagElements(page); await probeReveals(page); }
|
|
||||||
|
|
||||||
await autoScroll(page, vh);
|
await autoScroll(page, vh);
|
||||||
await settle(page, 1500);
|
await settle(page, 1500);
|
||||||
await applyDismiss("post-scroll");
|
await applyDismiss("post-scroll");
|
||||||
@@ -737,6 +815,15 @@ export async function captureSite(opts: {
|
|||||||
});
|
});
|
||||||
await page.waitForTimeout(150);
|
await page.waitForTimeout(150);
|
||||||
|
|
||||||
|
// Stage 2: settle autoplaying carousels (pause + navigate to the home slide)
|
||||||
|
// before EVERY snapshot — otherwise each viewport freezes a different track
|
||||||
|
// offset (per-band CSS then bakes four different slides), and the interaction
|
||||||
|
// pass between the canonical and last snapshots would leave the final viewport
|
||||||
|
// contaminated. Scoped to named-library tracks so motion.ts's marquee/rotator
|
||||||
|
// capture (which runs after the canonical snapshot) observes them unchanged.
|
||||||
|
const carousels = await settleCarousels(page);
|
||||||
|
if (carousels.roots) log({ event: "carousels_settled", viewport: vw, ...carousels });
|
||||||
|
|
||||||
// Stage 2: dynamic-media first frame. Materialize a still for poster-less
|
// Stage 2: dynamic-media first frame. Materialize a still for poster-less
|
||||||
// videos so they don't render blank — canvas where the frame is readable, else
|
// videos so they don't render blank — canvas where the frame is readable, else
|
||||||
// an element screenshot (the page cannot screenshot itself). Done once at the
|
// an element screenshot (the page cannot screenshot itself). Done once at the
|
||||||
@@ -755,12 +842,27 @@ export async function captureSite(opts: {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
for (const s of plan.shots) {
|
for (const s of plan.shots) {
|
||||||
|
// A visibility:hidden video (entrance animation not yet fired) passes the size
|
||||||
|
// gate, but locator.screenshot auto-waits for visibility and times out —
|
||||||
|
// force-reveal the hidden ancestor chain for the shot, restore exactly after.
|
||||||
|
let shot = false;
|
||||||
try {
|
try {
|
||||||
|
await page.evaluate(forceRevealForShot, s.sel);
|
||||||
const buf = await page.locator(s.sel).first().screenshot({ type: "jpeg", quality: 82, timeout: 5000, animations: "disabled" });
|
const buf = await page.locator(s.sel).first().screenshot({ type: "jpeg", quality: 82, timeout: 5000, animations: "disabled" });
|
||||||
recordAsset(s.url, "image", "image/jpeg", 200, "video-still");
|
recordAsset(s.url, "image", "image/jpeg", 200, "video-still");
|
||||||
storeBytes(s.url, "image", buf);
|
storeBytes(s.url, "image", buf);
|
||||||
dismissUnion.videoStills++;
|
dismissUnion.videoStills++;
|
||||||
} catch { /* element not shootable (off-screen/detached) — poster falls back to placeholder */ }
|
shot = true;
|
||||||
|
} catch (e) {
|
||||||
|
log({ event: "video_still_error", viewport: vw, sel: s.sel, error: String(e).slice(0, 200) });
|
||||||
|
} finally {
|
||||||
|
await page.evaluate(restoreRevealForShot).catch(() => { /* ignore */ });
|
||||||
|
}
|
||||||
|
// A synthetic poster with no bytes behind it generates a transparent tile —
|
||||||
|
// on failure, remove the attr so the video renders as it did pre-capture.
|
||||||
|
if (!shot) {
|
||||||
|
await page.evaluate((sel) => { document.querySelector(sel)?.removeAttribute("poster"); }, s.sel).catch(() => { /* ignore */ });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Element screenshots scroll the target into view; restore scroll 0 so the
|
// Element screenshots scroll the target into view; restore scroll 0 so the
|
||||||
// canonical DOM walk + screenshot match the generated app's default render.
|
// canonical DOM walk + screenshot match the generated app's default render.
|
||||||
@@ -774,6 +876,103 @@ export async function captureSite(opts: {
|
|||||||
// them (whitelisted), enabling interaction deltas + motion specs to map to cids.
|
// them (whitelisted), enabling interaction deltas + motion specs to map to cids.
|
||||||
if ((opts.interactions || opts.motion) && vw === canonical) await tagElements(page);
|
if ((opts.interactions || opts.motion) && vw === canonical) await tagElements(page);
|
||||||
|
|
||||||
|
// Stage 2.6: cross-origin iframe content. The in-page walker cannot see into a
|
||||||
|
// cross-origin frame (newsletter/form embeds), but Node CAN evaluate in it — run the
|
||||||
|
// SAME collectPage per meaningful frame here, then graft each subtree into this
|
||||||
|
// viewport's snapshot below (graft.ts). Frames that can't be grafted (media players,
|
||||||
|
// dead frames) get an element-screenshot recorded as the iframe's background at the
|
||||||
|
// canonical viewport, so the box at least PAINTS. Runs before the main collectPage so
|
||||||
|
// the fallback's inline background is part of the canonical computed style.
|
||||||
|
const frameCands: FrameCandidate[] = await Promise.race([
|
||||||
|
page.evaluate(enumerateFramesInPage),
|
||||||
|
new Promise<FrameCandidate[]>((res) => setTimeout(() => res([]), 8000)),
|
||||||
|
]).catch(() => [] as FrameCandidate[]);
|
||||||
|
const frameGrafts: Array<{ cand: FrameCandidate; snap: PageSnapshot }> = [];
|
||||||
|
let graftBudget = MAX_GRAFT_FRAMES;
|
||||||
|
let stillBudget = MAX_GRAFT_FRAMES; // fallback stills share the same per-page bound
|
||||||
|
let frameShotScrolled = false;
|
||||||
|
for (const cand of frameCands) {
|
||||||
|
if (!cand.visible) continue;
|
||||||
|
const plan = planForFrameUrl(cand.url);
|
||||||
|
if (plan === "skip") continue;
|
||||||
|
let frameSnap: PageSnapshot | null = null;
|
||||||
|
if (plan === "graft" && graftBudget > 0) {
|
||||||
|
try {
|
||||||
|
const handle = await page.$(`iframe[data-ditto-frame="${cand.idx}"]`);
|
||||||
|
const frame = handle ? await handle.contentFrame() : null;
|
||||||
|
if (frame) {
|
||||||
|
await frame.evaluate(ESBUILD_SHIM);
|
||||||
|
frameSnap = await Promise.race([
|
||||||
|
frame.evaluate(collectPage, { maxNodes: FRAME_GRAFT_MAX_NODES }),
|
||||||
|
new Promise<never>((_, rej) => setTimeout(() => rej(new Error("frame collect timeout")), 20_000)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await handle?.dispose();
|
||||||
|
} catch (e) {
|
||||||
|
log({ event: "frame_graft_error", viewport: vw, frame: cand.idx, url: cand.url.slice(0, 160), error: String(e).slice(0, 200) });
|
||||||
|
frameSnap = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (frameSnap && frameHasRenderableContent(frameSnap.root)) {
|
||||||
|
graftBudget--;
|
||||||
|
frameGrafts.push({ cand, snap: frameSnap });
|
||||||
|
// Merge the frame's discoveries exactly like the main document's below: its
|
||||||
|
// assets/fonts flow through the same pipeline so grafted <img>/@font-face resolve.
|
||||||
|
for (const da of frameSnap.domAssets) {
|
||||||
|
const t = classifyAsset(da.url, null) ?? (da.kind === "video" ? "video" : da.kind === "svg" ? "svg" : "image");
|
||||||
|
recordAsset(da.url, t, null, null, `frame${cand.idx}:${da.via}`);
|
||||||
|
}
|
||||||
|
for (const u of frameSnap.cssUrls) {
|
||||||
|
const t = classifyAsset(u, null) ?? "other";
|
||||||
|
recordAsset(u, t, null, null, "css-url");
|
||||||
|
}
|
||||||
|
for (const ff of frameSnap.fontFaces) {
|
||||||
|
const key = `${ff.family}|${ff.weight ?? ""}|${ff.style ?? ""}|${ff.src}`;
|
||||||
|
if (!fontFaceMap.has(key)) fontFaceMap.set(key, ff);
|
||||||
|
}
|
||||||
|
} else if (vw === canonical && stillBudget > 0) {
|
||||||
|
stillBudget--;
|
||||||
|
// Screenshot fallback — synthetic-poster pattern (see captureVideoStills): the
|
||||||
|
// still's bytes ride the normal asset pipeline under a synthetic URL; the iframe's
|
||||||
|
// inline background then rewrites to the local file at generation.
|
||||||
|
const stillUrl = `https://clone-frame.local/${cand.idx}-${sha1_12(cand.url || String(cand.idx))}.jpg`;
|
||||||
|
const sel = `iframe[data-ditto-frame="${cand.idx}"]`;
|
||||||
|
try {
|
||||||
|
await page.evaluate(forceRevealForShot, sel);
|
||||||
|
const buf = await page.locator(sel).first().screenshot({ type: "jpeg", quality: 82, timeout: 5000, animations: "disabled" });
|
||||||
|
recordAsset(stillUrl, "image", "image/jpeg", 200, "iframe-still");
|
||||||
|
storeBytes(stillUrl, "image", buf);
|
||||||
|
frameShotScrolled = true;
|
||||||
|
await page.evaluate(({ s, u }) => {
|
||||||
|
const el = document.querySelector(s) as HTMLElement | null;
|
||||||
|
if (el) {
|
||||||
|
el.style.backgroundImage = `url("${u}")`;
|
||||||
|
el.style.backgroundSize = "100% 100%";
|
||||||
|
el.style.backgroundRepeat = "no-repeat";
|
||||||
|
}
|
||||||
|
}, { s: sel, u: stillUrl });
|
||||||
|
log({ event: "frame_still", viewport: vw, frame: cand.idx, url: cand.url.slice(0, 160) });
|
||||||
|
} catch (e) {
|
||||||
|
log({ event: "frame_still_error", viewport: vw, frame: cand.idx, error: String(e).slice(0, 200) });
|
||||||
|
} finally {
|
||||||
|
await page.evaluate(restoreRevealForShot).catch(() => { /* ignore */ });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Element screenshots scroll the target into view; restore scroll 0 before the walk.
|
||||||
|
if (frameShotScrolled) {
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 0));
|
||||||
|
await page.waitForTimeout(80);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll-linked animations (animation-timeline: scroll()/view()) are held at their
|
||||||
|
// end keyframe by `fill-mode:both` after the dwell-scroll pass, even with scroll reset
|
||||||
|
// to 0 — so the walk would bake the frozen END state (e.g. a text-fill stuck at 100%).
|
||||||
|
// Cancel them here so the snapshot records the genuine AT-REST (unscrolled, 0%) values.
|
||||||
|
// Time-based reveals use the default document timeline and are untouched.
|
||||||
|
const scrollAnimsCanceled = await neutralizeScrollTimelineAnimations(page);
|
||||||
|
if (scrollAnimsCanceled) log({ event: "scroll_timeline_anims_canceled", viewport: vw, count: scrollAnimsCanceled });
|
||||||
|
|
||||||
// Bound the in-page DOM walk: page.evaluate has no default timeout, so a
|
// Bound the in-page DOM walk: page.evaluate has no default timeout, so a
|
||||||
// pathologically large/animated DOM (e.g. asana.com) could hang forever.
|
// pathologically large/animated DOM (e.g. asana.com) could hang forever.
|
||||||
const snapshot: PageSnapshot = await Promise.race([
|
const snapshot: PageSnapshot = await Promise.race([
|
||||||
@@ -781,6 +980,13 @@ export async function captureSite(opts: {
|
|||||||
new Promise<never>((_, rej) => setTimeout(() => rej(new Error(`collectPage timeout vp${vw}`)), 60_000)),
|
new Promise<never>((_, rej) => setTimeout(() => rej(new Error(`collectPage timeout vp${vw}`)), 60_000)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Graft the captured frame subtrees into this viewport's snapshot (offset bboxes,
|
||||||
|
// namespaced ids, frame-URL-absolutized src/href — see graft.ts).
|
||||||
|
for (const g of frameGrafts) {
|
||||||
|
const ok = graftFrameIntoSnapshot(snapshot, g.cand, g.snap);
|
||||||
|
log({ event: ok ? "frame_grafted" : "frame_graft_orphaned", viewport: vw, frame: g.cand.idx, url: g.cand.url.slice(0, 160), nodes: g.snap.doc.nodeCount });
|
||||||
|
}
|
||||||
|
|
||||||
// Merge discovered references from the snapshot (DOM + accessible CSS).
|
// Merge discovered references from the snapshot (DOM + accessible CSS).
|
||||||
for (const da of snapshot.domAssets) {
|
for (const da of snapshot.domAssets) {
|
||||||
const t = classifyAsset(da.url, null) ?? (da.kind === "manifest" ? "manifest" : da.kind === "video" ? "video" : da.kind === "svg" ? "svg" : "image");
|
const t = classifyAsset(da.url, null) ?? (da.kind === "manifest" ? "manifest" : da.kind === "video" ? "video" : da.kind === "svg" ? "svg" : "image");
|
||||||
@@ -895,6 +1101,10 @@ export async function captureSite(opts: {
|
|||||||
if (a.storedAs) continue;
|
if (a.storedAs) continue;
|
||||||
if (a.url.startsWith("data:")) continue;
|
if (a.url.startsWith("data:")) continue;
|
||||||
if (!["image", "svg", "video", "font", "lottie", "css", "manifest"].includes(a.type)) continue;
|
if (!["image", "svg", "video", "font", "lottie", "css", "manifest"].includes(a.type)) continue;
|
||||||
|
// One bounded retry for transiently-failed VISUAL assets (network error / 5xx / 429):
|
||||||
|
// a single flaky fetch otherwise degrades an image to the transparent-GIF placeholder.
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
let failStatus: number | null = null;
|
||||||
try {
|
try {
|
||||||
const resp = await fallbackCtx.request.get(a.url, { timeout: 30000 });
|
const resp = await fallbackCtx.request.get(a.url, { timeout: 30000 });
|
||||||
a.status = resp.status();
|
a.status = resp.status();
|
||||||
@@ -907,9 +1117,22 @@ export async function captureSite(opts: {
|
|||||||
recordAsset(u, t, null, null, "css-text");
|
recordAsset(u, t, null, null, "css-text");
|
||||||
});
|
});
|
||||||
if (a.type === "manifest") parseManifestForAssets(buf.toString("utf8"), a.url);
|
if (a.type === "manifest") parseManifestForAssets(buf.toString("utf8"), a.url);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch { /* unreachable/signed — left as skipped */ }
|
failStatus = resp.status();
|
||||||
|
} catch { failStatus = null; /* unreachable/signed — left as skipped */ }
|
||||||
|
if (attempt > 0 || !isRetryableAssetFailure(a.type, failStatus)) break;
|
||||||
|
log({ event: "asset_retry", url: a.url, type: a.type, status: failStatus });
|
||||||
|
await new Promise((r) => setTimeout(r, ASSET_RETRY_DELAY_MS));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Every visual asset that ultimately failed, in one machine-readable event: these are
|
||||||
|
// the boxes that will paint as placeholders (image → transparent GIF, video → blank).
|
||||||
|
const visualMissing = [...assetMap.values()]
|
||||||
|
.filter((a) => !a.storedAs && !a.url.startsWith("data:") && (a.type === "image" || a.type === "svg" || a.type === "video"))
|
||||||
|
.map((a) => ({ url: a.url, type: a.type, status: a.status }))
|
||||||
|
.sort((x, y) => x.url.localeCompare(y.url));
|
||||||
|
if (visualMissing.length) log({ event: "visual_assets_missing", count: visualMissing.length, assets: visualMissing });
|
||||||
const seoResources: SeoResource[] = [];
|
const seoResources: SeoResource[] = [];
|
||||||
const fetchedSeo = new Set<string>();
|
const fetchedSeo = new Set<string>();
|
||||||
const fetchSeoResource = async (url: string, kind: SeoResource["kind"]): Promise<void> => {
|
const fetchSeoResource = async (url: string, kind: SeoResource["kind"]): Promise<void> => {
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import type { PageSnapshot, RawNode, RawChild } from "./walker.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-origin iframe subtree graft (Stage 2). The in-page walker cannot see into a
|
||||||
|
* cross-origin iframe (Klaviyo/newsletter embeds), so the capture used to record an
|
||||||
|
* empty box and the clone painted a blank frame. Playwright CAN evaluate inside
|
||||||
|
* cross-origin frames from Node, so capture.ts runs the SAME collectPage in each
|
||||||
|
* meaningful frame and this module merges the returned subtree into the page snapshot
|
||||||
|
* as ordinary children of the iframe node:
|
||||||
|
* - bboxes shift by the iframe's content-box origin (frame-doc → page-doc coords);
|
||||||
|
* - id/for/#href are namespaced `f<idx>-…` so two frames (or frame + page) can't
|
||||||
|
* collide on DOM ids in the clone;
|
||||||
|
* - src/srcset/href absolutize against the FRAME document's URL (generation resolves
|
||||||
|
* attrs against the main page URL, which would break frame-relative paths);
|
||||||
|
* - the grafted <body> becomes a <div>, and the iframe node clips (overflow:hidden)
|
||||||
|
* exactly like a real frame viewport.
|
||||||
|
* Downstream the iframe-with-children renders as a positioned <div> (app.ts), so the
|
||||||
|
* embed's form paints as real, styleable DOM. Frames that can't be grafted fall back
|
||||||
|
* to an element screenshot recorded as the iframe's background (capture.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Determinism/perf caps: at most this many grafted frames per page snapshot… */
|
||||||
|
export const MAX_GRAFT_FRAMES = 10;
|
||||||
|
/** …and a per-frame walker node budget (a fraction of the main document's 12000). */
|
||||||
|
export const FRAME_GRAFT_MAX_NODES = 2000;
|
||||||
|
/** Minimum rendered size (both axes) for a frame to carry meaningful content. */
|
||||||
|
export const MIN_FRAME_DIM = 48;
|
||||||
|
|
||||||
|
export type FramePlan = "skip" | "still" | "graft";
|
||||||
|
|
||||||
|
// Ad/analytics/consent plumbing frames render nothing a visitor values — skip entirely.
|
||||||
|
const FRAME_SKIP_RE =
|
||||||
|
/(?:doubleclick\.net|googlesyndication\.com|googletagmanager\.com|google-analytics\.com|googleadservices\.com|adservice\.google|facebook\.com\/tr|connect\.facebook\.net|\brecaptcha\b|hcaptcha\.com|challenges\.cloudflare\.com|adsrvr\.org|amazon-adsystem\.com)/i;
|
||||||
|
// Media players are JS-built canvases/videos whose DOM graft is meaningless; an element
|
||||||
|
// screenshot (the poster frame + play chrome) is the faithful static paint.
|
||||||
|
const FRAME_STILL_RE =
|
||||||
|
/(?:youtube(?:-nocookie)?\.com\/embed|player\.vimeo\.com|\bwistia\b|fast\.wistia|players?\.brightcove|open\.spotify\.com|w\.soundcloud\.com|google\.com\/maps\/embed)/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How to materialize a frame's content, from its URL alone (deterministic).
|
||||||
|
*
|
||||||
|
* NOTE on blank/empty-src frames: a frame with no `src` (or `about:blank`/`javascript:`)
|
||||||
|
* is NOT inert — many form/widget embeds (Klaviyo lightbox signup, loyalty popups) mount a
|
||||||
|
* same-origin blank iframe and inject their rendered DOM into it via script, so the element
|
||||||
|
* carries real, sized content with no navigable URL. Those must GRAFT (the frame document is
|
||||||
|
* same-origin, so collectPage evaluates in it directly). The dead pixels that also use a blank
|
||||||
|
* src (analytics sandboxes, 0×0 tracking iframes) are filtered upstream by the `cand.visible`
|
||||||
|
* gate in capture.ts — a blank frame only reaches a graft when it actually rendered at
|
||||||
|
* ≥ MIN_FRAME_DIM on both axes, which a tracking pixel never does.
|
||||||
|
*/
|
||||||
|
export function planForFrameUrl(url: string): FramePlan {
|
||||||
|
const u = (url || "").trim();
|
||||||
|
if (u.startsWith("javascript:")) return "skip";
|
||||||
|
if (!u || u === "about:blank") return "graft"; // JS-populated same-origin frame (visibility-gated)
|
||||||
|
if (FRAME_SKIP_RE.test(u)) return "skip";
|
||||||
|
if (FRAME_STILL_RE.test(u)) return "still";
|
||||||
|
return "graft";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FrameCandidate = {
|
||||||
|
idx: number; // stable per-page index, stamped as data-ditto-frame on the element
|
||||||
|
url: string; // the frame element's src (absolute)
|
||||||
|
visible: boolean; // rendered, ≥ MIN_FRAME_DIM on both axes
|
||||||
|
// Content-box origin in page document coordinates: the offset every frame-doc bbox
|
||||||
|
// shifts by (the child browsing context fills the iframe's content box).
|
||||||
|
contentX: number;
|
||||||
|
contentY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamp every iframe with a stable `data-ditto-frame` index (DOM order; idempotent so
|
||||||
|
* later viewports reuse the same identity) and report each frame's geometry/visibility.
|
||||||
|
* Serialized into the page via page.evaluate — must stay self-contained.
|
||||||
|
*/
|
||||||
|
export function enumerateFramesInPage(): FrameCandidate[] {
|
||||||
|
const frames = Array.from(document.querySelectorAll("iframe"));
|
||||||
|
let next = 0;
|
||||||
|
for (const f of frames) {
|
||||||
|
const cur = f.getAttribute("data-ditto-frame");
|
||||||
|
if (cur !== null) next = Math.max(next, parseInt(cur, 10) + 1);
|
||||||
|
}
|
||||||
|
const sx = window.scrollX, sy = window.scrollY;
|
||||||
|
const out: FrameCandidate[] = [];
|
||||||
|
for (const f of frames) {
|
||||||
|
let idxAttr = f.getAttribute("data-ditto-frame");
|
||||||
|
if (idxAttr === null) { idxAttr = String(next++); f.setAttribute("data-ditto-frame", idxAttr); }
|
||||||
|
const cs = getComputedStyle(f);
|
||||||
|
const r = f.getBoundingClientRect();
|
||||||
|
const visible = cs.display !== "none" && cs.visibility !== "hidden" &&
|
||||||
|
parseFloat(cs.opacity || "1") > 0 && r.width >= 48 && r.height >= 48;
|
||||||
|
out.push({
|
||||||
|
idx: parseInt(idxAttr, 10),
|
||||||
|
url: (f as HTMLIFrameElement).src || "",
|
||||||
|
visible,
|
||||||
|
contentX: Math.round((r.x + sx + parseFloat(cs.borderLeftWidth || "0") + parseFloat(cs.paddingLeft || "0")) * 100) / 100,
|
||||||
|
contentY: Math.round((r.y + sy + parseFloat(cs.borderTopWidth || "0") + parseFloat(cs.paddingTop || "0")) * 100) / 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.idx - b.idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextRaw(c: RawChild): c is { text: string } {
|
||||||
|
return (c as { text?: string }).text !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does the frame's captured tree paint anything (a visible element or real text)? */
|
||||||
|
export function frameHasRenderableContent(root: RawNode | undefined): boolean {
|
||||||
|
if (!root) return false;
|
||||||
|
const visit = (n: RawNode): boolean => {
|
||||||
|
for (const c of n.children) {
|
||||||
|
if (isTextRaw(c)) { if (c.text.trim().length > 0) return true; continue; }
|
||||||
|
if (c.visible || c.rawHTML) return true;
|
||||||
|
if (visit(c)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
return visit(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the iframe RawNode stamped with the given data-ditto-frame index. */
|
||||||
|
export function findFrameNode(root: RawNode, idx: number): RawNode | null {
|
||||||
|
if (root.tag === "iframe" && root.attrs?.["data-ditto-frame"] === String(idx)) return root;
|
||||||
|
for (const c of root.children) {
|
||||||
|
if (isTextRaw(c)) continue;
|
||||||
|
const hit = findFrameNode(c, idx);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ABS_URL_ATTRS = ["src", "poster", "data-lazy-src", "data-src", "data-original", "data-ll-src"];
|
||||||
|
const ABS_SRCSET_ATTRS = ["srcset", "data-lazy-srcset", "data-srcset"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge one frame snapshot into the page snapshot as children of its iframe node.
|
||||||
|
* Returns true when the graft landed. Mutates `snapshot` (offsets, namespacing and the
|
||||||
|
* iframe's clipping are applied to the frame subtree copy embedded in it).
|
||||||
|
*/
|
||||||
|
export function graftFrameIntoSnapshot(
|
||||||
|
snapshot: PageSnapshot,
|
||||||
|
frame: { idx: number; contentX: number; contentY: number },
|
||||||
|
frameSnap: PageSnapshot,
|
||||||
|
): boolean {
|
||||||
|
const host = findFrameNode(snapshot.root, frame.idx);
|
||||||
|
if (!host || !frameSnap?.root) return false;
|
||||||
|
if (!frameHasRenderableContent(frameSnap.root)) return false;
|
||||||
|
|
||||||
|
const prefix = `f${frame.idx}-`;
|
||||||
|
const frameUrl = frameSnap.doc?.url || "";
|
||||||
|
const abs = (u: string): string => {
|
||||||
|
try { return new URL(u, frameUrl).href; } catch { return u; }
|
||||||
|
};
|
||||||
|
const absSrcset = (v: string): string =>
|
||||||
|
v.split(",").map((part) => {
|
||||||
|
const bits = part.trim().split(/\s+/);
|
||||||
|
if (bits[0] && !bits[0].startsWith("data:")) bits[0] = abs(bits[0]);
|
||||||
|
return bits.join(" ");
|
||||||
|
}).join(", ");
|
||||||
|
const nsIdList = (v: string): string =>
|
||||||
|
v.split(/\s+/).filter(Boolean).map((id) => prefix + id).join(" ");
|
||||||
|
|
||||||
|
const visit = (n: RawNode): void => {
|
||||||
|
n.bbox = {
|
||||||
|
...n.bbox,
|
||||||
|
x: Math.round((n.bbox.x + frame.contentX) * 100) / 100,
|
||||||
|
y: Math.round((n.bbox.y + frame.contentY) * 100) / 100,
|
||||||
|
};
|
||||||
|
const a = n.attrs ?? {};
|
||||||
|
delete a["data-cid-cap"]; // capture-ids belong to the main document only
|
||||||
|
if (a.id) a.id = prefix + a.id;
|
||||||
|
if (a.for) a.for = prefix + a.for;
|
||||||
|
for (const k of ["aria-labelledby", "aria-describedby", "aria-controls", "aria-owns", "aria-activedescendant"]) {
|
||||||
|
if (a[k]) a[k] = nsIdList(a[k]!);
|
||||||
|
}
|
||||||
|
if (a.href) a.href = a.href.startsWith("#") ? "#" + prefix + a.href.slice(1) : abs(a.href);
|
||||||
|
for (const k of ABS_URL_ATTRS) if (a[k] && !a[k]!.startsWith("data:")) a[k] = abs(a[k]!);
|
||||||
|
for (const k of ABS_SRCSET_ATTRS) if (a[k]) a[k] = absSrcset(a[k]!);
|
||||||
|
for (const c of n.children) if (!isTextRaw(c)) visit(c);
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = frameSnap.root; // the frame's <body>
|
||||||
|
visit(wrapper);
|
||||||
|
wrapper.tag = "div"; // a nested <body> is not valid; the box/styles replay identically
|
||||||
|
|
||||||
|
host.children = [wrapper];
|
||||||
|
// A real frame viewport clips its document; the replacement <div> must too, at every
|
||||||
|
// captured viewport (each per-viewport snapshot is grafted independently).
|
||||||
|
host.computed = { ...host.computed, overflow: "hidden", overflowX: "hidden", overflowY: "hidden" };
|
||||||
|
// An iframe is a REPLACED element: `display:inline` (the default) still honors its
|
||||||
|
// width/height. The <div> that replaces it at generation is not — inline would collapse
|
||||||
|
// the box — so translate to the behavior-equivalent inline-block.
|
||||||
|
if ((host.computed.display || "inline") === "inline") host.computed.display = "inline-block";
|
||||||
|
// Diagnostics only (nodeCount is logged, never gated).
|
||||||
|
snapshot.doc.nodeCount += frameSnap.doc?.nodeCount ?? 0;
|
||||||
|
// @keyframes referenced by grafted nodes must exist in the page snapshot's set.
|
||||||
|
if (frameSnap.keyframes?.length) snapshot.keyframes.push(...frameSnap.keyframes);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -41,7 +41,16 @@ export type RevealSpec = {
|
|||||||
cap: string; // data-cid-cap of a scroll-revealed element
|
cap: string; // data-cid-cap of a scroll-revealed element
|
||||||
opacity: string; // its hidden (pre-reveal) opacity, e.g. "0"
|
opacity: string; // its hidden (pre-reveal) opacity, e.g. "0"
|
||||||
transform: string; // its hidden transform (slide/scale offset), or "none"
|
transform: string; // its hidden transform (slide/scale offset), or "none"
|
||||||
transition: string; // the transition to animate the reveal with
|
transition: string; // the transition to animate the reveal with ("" for the visibility family)
|
||||||
|
// visibility+entrance-class family (Elementor/WOW/AOS): hidden via `visibility:hidden`
|
||||||
|
// pre-scroll, revealed by a class swap that applies a keyframe animation. The clone
|
||||||
|
// re-hides with visibility (JS-applied, so non-JS/SSR still shows content) and replays
|
||||||
|
// the named animation when scrolled into view.
|
||||||
|
visibility?: "hidden";
|
||||||
|
animationName?: string; // entrance @keyframes name in the revealed state (e.g. fadeInUp)
|
||||||
|
animationDuration?: string; // e.g. "1.25s"
|
||||||
|
animationDelay?: string; // e.g. "0s"
|
||||||
|
animationTiming?: string; // e.g. "ease" / "cubic-bezier(...)"
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MarqueeSpec = {
|
export type MarqueeSpec = {
|
||||||
@@ -62,26 +71,41 @@ export type MotionCapture = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stage 5 (scroll reveals) — pre-scroll probe. Scroll-triggered reveals start hidden
|
* Stage 5 (scroll reveals) — pre-scroll probe. Scroll-triggered reveals start hidden and
|
||||||
* (opacity:0 / transform offset) and animate in when scrolled into view; by the time the
|
* animate in when scrolled into view; by the time the settled snapshot is taken (after
|
||||||
* settled snapshot is taken (after `autoScroll` has walked the page) they are already
|
* the reveal-settling pass has walked the page) they are already revealed, so their
|
||||||
* revealed, so their hidden state must be sampled BEFORE the first scroll. Records, on
|
* hidden state must be sampled BEFORE the first scroll. Records, on `window.__cloneReveal`,
|
||||||
* `window.__cloneReveal`, the pre-scroll opacity/transform/transition of every tagged
|
* two candidate families over tagged elements — the set `captureMotion` later confirms
|
||||||
* element that is hidden-but-boxed with a real transition — the candidate reveal set
|
* (kept only if the element ends up visible):
|
||||||
* `captureMotion` later confirms (kept only if the element ends up visible). Idempotent;
|
* - **transition** — opacity≈0 with a real opacity/transform transition (the reveal
|
||||||
* call once at the first viewport, after settle, before `autoScroll`.
|
* animates via the transition already on the element);
|
||||||
|
* - **visibility** — `visibility:hidden` with a real box (Elementor `.elementor-invisible`,
|
||||||
|
* WOW/AOS wrappers), revealed by a class swap that APPLIES a keyframe animation. The
|
||||||
|
* entrance animation only exists post-swap, so `captureMotion` reads it at confirm
|
||||||
|
* time from the revealed computed style. Only the OUTERMOST hidden element is recorded
|
||||||
|
* (visibility inherits — descendants are covered by the wrapper's reveal).
|
||||||
|
* Idempotent; call once at the canonical width, after settle, before the first scroll.
|
||||||
*/
|
*/
|
||||||
export async function probeReveals(page: Page): Promise<void> {
|
export async function probeReveals(page: Page): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const out: Record<string, { opacity: string; transform: string; transition: string }> = {};
|
const out: Record<string, { opacity: string; transform: string; transition: string; family?: "visibility" }> = {};
|
||||||
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) {
|
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) {
|
||||||
const cap = el.getAttribute("data-cid-cap"); if (!cap) continue;
|
const cap = el.getAttribute("data-cid-cap"); if (!cap) continue;
|
||||||
let cs: CSSStyleDeclaration; try { cs = getComputedStyle(el); } catch { 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();
|
const r = (el as HTMLElement).getBoundingClientRect();
|
||||||
if (r.width < 8 || r.height < 8) continue; // must occupy real space (not a 0-box hidden node)
|
if (r.width < 8 || r.height < 8) continue; // must occupy real space (not a 0-box hidden node)
|
||||||
|
if (cs.visibility === "hidden") {
|
||||||
|
// visibility family: record only the reveal ROOT (parent not also hidden).
|
||||||
|
const p = (el as HTMLElement).parentElement;
|
||||||
|
let parentHidden = false;
|
||||||
|
try { parentHidden = !!p && getComputedStyle(p).visibility === "hidden"; } catch { /* ignore */ }
|
||||||
|
if (parentHidden) continue;
|
||||||
|
out[cap] = { opacity: cs.opacity || "1", transform: "none", transition: "", family: "visibility" };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const op = parseFloat(cs.opacity || "1");
|
||||||
|
if (op > 0.05) continue; // only currently-hidden elements are reveal candidates
|
||||||
// must have a transition on opacity/transform/all (so the reveal animates, not snaps)
|
// must have a transition on opacity/transform/all (so the reveal animates, not snaps)
|
||||||
const tp = (cs.transitionProperty || "").toLowerCase();
|
const tp = (cs.transitionProperty || "").toLowerCase();
|
||||||
const td = cs.transitionDuration || "0s";
|
const td = cs.transitionDuration || "0s";
|
||||||
@@ -311,17 +335,41 @@ export async function captureMotion(page: Page, opts?: { observeMs?: number; log
|
|||||||
|
|
||||||
// ---- Scroll reveals: confirm the pre-scroll candidates (probeReveals) that are
|
// ---- Scroll reveals: confirm the pre-scroll candidates (probeReveals) that are
|
||||||
// NOW visible — those genuinely revealed on scroll (vs. elements that stayed hidden).
|
// NOW visible — those genuinely revealed on scroll (vs. elements that stayed hidden).
|
||||||
const reveals: Array<{ cap: string; opacity: string; transform: string; transition: string }> = [];
|
const reveals: Array<{
|
||||||
|
cap: string; opacity: string; transform: string; transition: string;
|
||||||
|
visibility?: "hidden"; animationName?: string; animationDuration?: string; animationDelay?: string; animationTiming?: string;
|
||||||
|
}> = [];
|
||||||
try {
|
try {
|
||||||
const probed = (window as unknown as { __cloneReveal?: Record<string, { opacity: string; transform: string; transition: string }> }).__cloneReveal || {};
|
const probed = (window as unknown as { __cloneReveal?: Record<string, { opacity: string; transform: string; transition: string; family?: "visibility" }> }).__cloneReveal || {};
|
||||||
|
// First value of a comma-joined animation longhand; timing functions carry inner
|
||||||
|
// commas (cubic-bezier/steps), so take the whole leading function when present.
|
||||||
|
const first = (v: string): string => (/^\s*(cubic-bezier\([^)]*\)|steps\([^)]*\)|[^,]+)/.exec(v || "")?.[1] ?? "").trim();
|
||||||
for (const cap of Object.keys(probed)) {
|
for (const cap of Object.keys(probed)) {
|
||||||
const el = document.querySelector(`[data-cid-cap="${cap}"]`);
|
const el = document.querySelector(`[data-cid-cap="${cap}"]`);
|
||||||
if (!el) continue;
|
if (!el) continue;
|
||||||
const cs = getComputedStyle(el);
|
const cs = getComputedStyle(el);
|
||||||
if (parseFloat(cs.opacity || "1") <= 0.05) continue; // still hidden → not a reveal, just hidden
|
const p = probed[cap]!;
|
||||||
const r = (el as HTMLElement).getBoundingClientRect();
|
const r = (el as HTMLElement).getBoundingClientRect();
|
||||||
if (r.width < 8 || r.height < 8) continue;
|
if (r.width < 8 || r.height < 8) continue;
|
||||||
reveals.push({ cap, ...probed[cap]! });
|
if (p.family === "visibility") {
|
||||||
|
if (cs.visibility === "hidden") continue; // never revealed → genuinely hidden content
|
||||||
|
// Revealed via class swap. The swap's entrance animation is now in the computed
|
||||||
|
// style (libraries keep the animated class); record it for replay.
|
||||||
|
const name = first(cs.animationName || "none");
|
||||||
|
reveals.push({
|
||||||
|
cap, opacity: p.opacity, transform: "none", transition: "",
|
||||||
|
visibility: "hidden",
|
||||||
|
...(name && name !== "none" ? {
|
||||||
|
animationName: name,
|
||||||
|
animationDuration: first(cs.animationDuration) || "1s",
|
||||||
|
animationDelay: first(cs.animationDelay) || "0s",
|
||||||
|
animationTiming: first(cs.animationTimingFunction) || "ease",
|
||||||
|
} : {}),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (parseFloat(cs.opacity || "1") <= 0.05) continue; // still hidden → not a reveal, just hidden
|
||||||
|
reveals.push({ cap, opacity: p.opacity, transform: p.transform, transition: p.transition });
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,472 @@
|
|||||||
|
import type { Page } from "playwright";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-snapshot stabilization (Stage 2). Two dynamic behaviors otherwise make the
|
||||||
|
* per-viewport snapshots disagree with each other and with what a settled visitor sees:
|
||||||
|
*
|
||||||
|
* - **Lazy-loader placeholders** (WP Rocket / lazysizes): the real URL lives in a data
|
||||||
|
* attribute while `src` holds a 0-size placeholder; `autoScroll` outruns their
|
||||||
|
* IntersectionObserver, so snapshots record collapsed sections — and the interaction
|
||||||
|
* pass can trigger the swap midway, leaving viewports INCONSISTENT (cropin's "Global
|
||||||
|
* presence" map: 0×0 at 375/768/1280, 898px at 1920). Promoting the data attrs to
|
||||||
|
* real ones ONCE, before any snapshot, makes every viewport measure the stable
|
||||||
|
* post-reveal size (validated against the live site).
|
||||||
|
* - **Autoplaying carousels** (Splide/Swiper-style transform tracks): each viewport
|
||||||
|
* snapshot freezes a DIFFERENT translateX offset which the generator bakes into
|
||||||
|
* per-band CSS (ooni's splide02: -375/0/-1280/-1920 across the four widths). Settling
|
||||||
|
* — autoplay paused, track at the home slide — before EVERY snapshot makes all
|
||||||
|
* viewports (including the post-interaction 1920 pass) see one canonical state.
|
||||||
|
*
|
||||||
|
* Scope guard for motion capture (motion.ts contract): carousel settling touches ONLY
|
||||||
|
* elements matching the named-library selectors below, and pauses only the track's own
|
||||||
|
* WAAPI/CSS animations. rAF-driven marquees (Framer Motion tickers) match none of these
|
||||||
|
* selectors and keep running, so `detectMarquees` still observes their velocity; paused
|
||||||
|
* WAAPI animations remain in `document.getAnimations()` with keyframes/timing intact.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---- Lazy-media promotion (runs in the page) ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promote lazy-loader data attributes to real ones and wait (bounded) for the newly-real
|
||||||
|
* images to decode, so bboxes are measured loaded. Only values that look like URLs are
|
||||||
|
* promoted (some themes stash JSON/flags in data-src-like attrs); an `src` already equal
|
||||||
|
* to the target is left alone, so the pass is idempotent. Returns the number of elements
|
||||||
|
* changed. Serialized into the page via page.evaluate — must stay self-contained.
|
||||||
|
*/
|
||||||
|
export async function promoteLazyMediaInPage(): Promise<number> {
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const urlish = (v: string | null | undefined): v is string => {
|
||||||
|
if (!v) return false;
|
||||||
|
const s = v.trim();
|
||||||
|
if (!s || s.length > 4096 || /[<>"'\s]/.test(s)) return false;
|
||||||
|
return /^(?:https?:)?\/\//i.test(s) || s.startsWith("/") || s.startsWith("./") || s.startsWith("../") ||
|
||||||
|
/^data:image\//i.test(s) || /^[\w][^:]*\.[a-z0-9]{2,5}(?:[?#]|$)/i.test(s);
|
||||||
|
};
|
||||||
|
const srcsetish = (v: string | null | undefined): v is string => {
|
||||||
|
if (!v) return false;
|
||||||
|
const first = v.split(",")[0]?.trim().split(/\s+/)[0];
|
||||||
|
return urlish(first);
|
||||||
|
};
|
||||||
|
const setAttr = (el: Element, name: string, value: string): boolean => {
|
||||||
|
if (el.getAttribute(name) === value) return false;
|
||||||
|
el.setAttribute(name, value);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const promotedImgs: HTMLImageElement[] = [];
|
||||||
|
let count = 0;
|
||||||
|
const SEL =
|
||||||
|
"img[data-lazy-src],img[data-src],img[data-lazy-srcset],img[data-srcset],img[data-lazy-sizes],img[data-sizes]," +
|
||||||
|
"source[data-lazy-srcset],source[data-srcset],iframe[data-lazy-src],iframe[data-src],[data-bg]";
|
||||||
|
for (const el of Array.from(document.querySelectorAll(SEL))) {
|
||||||
|
let changed = false;
|
||||||
|
const tag = el.tagName;
|
||||||
|
if (tag === "IMG" || tag === "IFRAME") {
|
||||||
|
const src = el.getAttribute("data-lazy-src") ?? el.getAttribute("data-src");
|
||||||
|
if (urlish(src)) changed = setAttr(el, "src", src.trim()) || changed;
|
||||||
|
}
|
||||||
|
if (tag === "IMG" || tag === "SOURCE") {
|
||||||
|
const srcset = el.getAttribute("data-lazy-srcset") ?? el.getAttribute("data-srcset");
|
||||||
|
if (srcsetish(srcset)) changed = setAttr(el, "srcset", srcset.trim()) || changed;
|
||||||
|
// lazysizes' `data-sizes="auto"` is a computed-at-swap flag, not a real sizes value.
|
||||||
|
const sizes = (el.getAttribute("data-lazy-sizes") ?? el.getAttribute("data-sizes"))?.trim();
|
||||||
|
if (sizes && sizes !== "auto") changed = setAttr(el, "sizes", sizes) || changed;
|
||||||
|
}
|
||||||
|
const bg = el.getAttribute("data-bg");
|
||||||
|
if (bg) {
|
||||||
|
// data-bg carries either a raw URL (lazysizes) or a full url(...) (WP Rocket).
|
||||||
|
const inner = bg.trim().replace(/^url\(\s*(['"]?)(.*?)\1\s*\)$/i, "$2").trim();
|
||||||
|
if (urlish(inner)) {
|
||||||
|
const want = `url("${inner}")`;
|
||||||
|
const st = (el as HTMLElement).style;
|
||||||
|
if (st.backgroundImage !== want) { st.backgroundImage = want; changed = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
count++;
|
||||||
|
if (tag === "IMG") { el.setAttribute("loading", "eager"); promotedImgs.push(el as HTMLImageElement); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (promotedImgs.length) {
|
||||||
|
await Promise.race([
|
||||||
|
Promise.all(promotedImgs.map((img) => (typeof img.decode === "function" ? img.decode() : Promise.resolve()).catch(() => { /* broken URL — bbox stays as-is */ }))),
|
||||||
|
sleep(4000),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Node-side wrapper: bounded + never fatal (a hung page just skips promotion). */
|
||||||
|
export async function promoteLazyMedia(page: Page): Promise<number> {
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
page.evaluate(promoteLazyMediaInPage),
|
||||||
|
new Promise<number>((res) => setTimeout(() => res(0), 8000)),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Scroll-reveal settling (runs in the page) ----
|
||||||
|
|
||||||
|
/** Fixed dwell per scroll step. Reveal libraries fire from IntersectionObserver callbacks
|
||||||
|
* or throttled scroll handlers (WOW ~100ms, AOS 99ms debounce); the plain autoScroll's
|
||||||
|
* 60ms cadence outruns them, leaving reveal wrappers baked `visibility:hidden` in the
|
||||||
|
* snapshot. 400ms per step reliably clears every observed library. Deterministic constant. */
|
||||||
|
export const REVEAL_DWELL_MS = 400;
|
||||||
|
/** Bound the dwell walk for pathological/endless pages (~80 × 0.75 viewport ≈ 60 screens). */
|
||||||
|
export const REVEAL_MAX_STEPS = 80;
|
||||||
|
/** Bounded wait for FINITE entrance animations started by the walk to finish, so no
|
||||||
|
* viewport snapshot freezes a mid-fade frame (the ~5%-opacity ghost state). */
|
||||||
|
export const REVEAL_ANIMATION_WAIT_MS = 3000;
|
||||||
|
|
||||||
|
export type RevealSettleResult = { steps: number; animationsAwaited: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic dwell-scroll so every one-shot scroll reveal (Elementor waypoints,
|
||||||
|
* WOW/AOS class swaps, IntersectionObserver entrances) fires BEFORE any viewport
|
||||||
|
* snapshot — the same class of one-shot load-state as lazy media, settled the same way
|
||||||
|
* (once, before the viewport loop). Steps 0.75×viewport with a fixed dwell through the
|
||||||
|
* full scrollHeight, waits for the entrance animations it started to complete (infinite
|
||||||
|
* iterations excluded — they never finish by design), then restores scroll 0.
|
||||||
|
* Serialized into the page via page.evaluate — must stay self-contained.
|
||||||
|
*/
|
||||||
|
export async function settleScrollRevealsInPage(cfg: { dwellMs: number; maxSteps: number; animWaitMs: number }): Promise<RevealSettleResult> {
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const step = Math.max(Math.round(window.innerHeight * 0.75), 200);
|
||||||
|
const maxScroll = () => document.documentElement.scrollHeight - window.innerHeight;
|
||||||
|
let y = 0;
|
||||||
|
let steps = 0;
|
||||||
|
while (y < maxScroll() && steps < cfg.maxSteps) {
|
||||||
|
y += step;
|
||||||
|
window.scrollTo(0, y);
|
||||||
|
await sleep(cfg.dwellMs);
|
||||||
|
steps++;
|
||||||
|
}
|
||||||
|
let animationsAwaited = 0;
|
||||||
|
try {
|
||||||
|
const anims = (document.getAnimations?.() ?? []).filter((a) => {
|
||||||
|
try {
|
||||||
|
if (a.playState !== "running") return false;
|
||||||
|
const t = (a.effect as KeyframeEffect | null)?.getTiming();
|
||||||
|
return t != null && t.iterations !== Infinity;
|
||||||
|
} catch { return false; }
|
||||||
|
});
|
||||||
|
animationsAwaited = anims.length;
|
||||||
|
await Promise.race([
|
||||||
|
Promise.allSettled(anims.map((a) => a.finished)),
|
||||||
|
sleep(cfg.animWaitMs),
|
||||||
|
]);
|
||||||
|
} catch { /* getAnimations unsupported — the fixed post-wait still applies */ }
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
await sleep(250); // fixed post-wait: let scroll-position-dependent styles re-settle at top
|
||||||
|
return { steps, animationsAwaited };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Node-side wrapper: bounded + never fatal (a hung page just skips settling). */
|
||||||
|
export async function settleScrollReveals(page: Page): Promise<RevealSettleResult> {
|
||||||
|
const empty: RevealSettleResult = { steps: 0, animationsAwaited: 0 };
|
||||||
|
const cfg = { dwellMs: REVEAL_DWELL_MS, maxSteps: REVEAL_MAX_STEPS, animWaitMs: REVEAL_ANIMATION_WAIT_MS };
|
||||||
|
const bound = cfg.maxSteps * cfg.dwellMs + cfg.animWaitMs + 8000;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
page.evaluate(settleScrollRevealsInPage, cfg),
|
||||||
|
new Promise<RevealSettleResult>((res) => setTimeout(() => res(empty), bound)),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defensive follow-up to the dwell walk: elements STILL carrying a known pre-reveal
|
||||||
|
* marker (far below fold past the step bound, or keyed to a non-scroll trigger) are
|
||||||
|
* moved to the library's OWN revealed state — the classes the library would add/remove
|
||||||
|
* — never raw style overrides, so the captured computed styles are the library's
|
||||||
|
* genuine post-reveal values. Known-library allowlist only (same philosophy as the
|
||||||
|
* carousel selectors below); elements hidden for real reasons are untouched.
|
||||||
|
* Serialized into the page via page.evaluate — must stay self-contained.
|
||||||
|
*/
|
||||||
|
export function neutralizePreRevealInPage(): number {
|
||||||
|
const stillHidden = (el: Element): boolean => {
|
||||||
|
try {
|
||||||
|
const cs = getComputedStyle(el);
|
||||||
|
return cs.visibility === "hidden" || parseFloat(cs.opacity || "1") <= 0.05;
|
||||||
|
} catch { return false; }
|
||||||
|
};
|
||||||
|
let n = 0;
|
||||||
|
// Elementor waypoint reveals: `.elementor-invisible` is removed and `animated` + the
|
||||||
|
// entrance-animation class (data-settings._animation / .animation) added on reveal.
|
||||||
|
const elementorReveal = (el: Element): void => {
|
||||||
|
let anim = "";
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(el.getAttribute("data-settings") || "{}") as Record<string, unknown>;
|
||||||
|
const v = s["_animation"] ?? s["animation"];
|
||||||
|
if (typeof v === "string") anim = v.trim();
|
||||||
|
} catch { /* malformed settings — reveal without the animation class */ }
|
||||||
|
el.classList.remove("elementor-invisible");
|
||||||
|
el.classList.add("animated");
|
||||||
|
if (anim && anim !== "none") el.classList.add(anim);
|
||||||
|
};
|
||||||
|
for (const el of Array.from(document.querySelectorAll(".elementor-invisible"))) { elementorReveal(el); n++; }
|
||||||
|
// Elementor variants where the invisible class was renamed but the entrance setting
|
||||||
|
// remains: still-hidden elements whose data-settings configure an animation.
|
||||||
|
for (const el of Array.from(document.querySelectorAll('[data-settings*="animation"]'))) {
|
||||||
|
if (!stillHidden(el)) continue;
|
||||||
|
elementorReveal(el);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
// WOW.js: init() sets inline visibility:hidden; reveal sets it visible + adds `animated`
|
||||||
|
// (the keyframe class, e.g. fadeInUp, is already in the element's class list).
|
||||||
|
for (const el of Array.from(document.querySelectorAll(".wow:not(.animated)")) as HTMLElement[]) {
|
||||||
|
el.classList.add("animated");
|
||||||
|
el.style.visibility = "visible";
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
// AOS: [data-aos] elements are hidden by attribute selectors until `.aos-animate` lands.
|
||||||
|
for (const el of Array.from(document.querySelectorAll("[data-aos]:not(.aos-animate)"))) {
|
||||||
|
el.classList.add("aos-animate");
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Node-side wrapper: bounded + never fatal. */
|
||||||
|
export async function neutralizePreReveal(page: Page): Promise<number> {
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
page.evaluate(neutralizePreRevealInPage),
|
||||||
|
new Promise<number>((res) => setTimeout(() => res(0), 8000)),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel scroll/view-timeline-driven CSS animations so the snapshot records the AT-REST
|
||||||
|
* (unscrolled) computed style rather than a frozen mid/end-timeline value.
|
||||||
|
*
|
||||||
|
* A scroll-linked text-fill (e.g. `animation-timeline: view(); animation-fill-mode: both`)
|
||||||
|
* progresses with scroll position, not time. The dwell-scroll pass runs the page to the
|
||||||
|
* bottom, and `fill-mode: both` HOLDS the animation's end keyframe even after we restore
|
||||||
|
* scroll to 0 — because the element's view-timeline range has already been exited. The DOM
|
||||||
|
* walk then reads the FROZEN end value (e.g. `background-position: 100%`, fully filled),
|
||||||
|
* baking the end state into the clone when the live at-rest state is the start (0%).
|
||||||
|
*
|
||||||
|
* Fix: before the snapshot, cancel every running CSS animation whose timeline is NOT the
|
||||||
|
* default (document) timeline — i.e. a ScrollTimeline / ViewTimeline, or (fallback) an
|
||||||
|
* animation whose resolved effect duration is not finite. Canceling drops the animation's
|
||||||
|
* fill so getComputedStyle reports the underlying (unanimated) property values.
|
||||||
|
*
|
||||||
|
* Scoped to scroll/view timelines only: time-based entrance animations (reveals) use the
|
||||||
|
* default document timeline with a finite duration and are left untouched. */
|
||||||
|
export function neutralizeScrollTimelineAnimationsInPage(): number {
|
||||||
|
let n = 0;
|
||||||
|
try {
|
||||||
|
const docTimeline = (document as unknown as { timeline?: unknown }).timeline;
|
||||||
|
const anims = (document as unknown as { getAnimations?: () => Animation[] }).getAnimations?.() ?? [];
|
||||||
|
for (const a of anims) {
|
||||||
|
try {
|
||||||
|
const tl = a.timeline as unknown;
|
||||||
|
// Default (document) timeline → time-based; leave it. Anything else (scroll/view
|
||||||
|
// timeline) or a null timeline with a non-finite effect duration → scroll-linked.
|
||||||
|
const isDefaultTimeline = tl === docTimeline;
|
||||||
|
const ctorName: string = (tl ? (tl as { constructor?: { name?: string } }).constructor?.name : "") || "";
|
||||||
|
const isScrollLinked =
|
||||||
|
!isDefaultTimeline && /Scroll|View/.test(ctorName);
|
||||||
|
// Fallback: resolved effect duration is not a finite number (scroll-timeline
|
||||||
|
// animations report `auto`/non-finite computed duration, e.g. `animation-duration:auto`).
|
||||||
|
let nonFiniteDuration = false;
|
||||||
|
try {
|
||||||
|
const timing = (a.effect as unknown as { getComputedTiming?: () => { duration?: number | string } })?.getComputedTiming?.();
|
||||||
|
const dur = timing?.duration;
|
||||||
|
nonFiniteDuration = typeof dur === "number" ? !isFinite(dur) : dur === "auto";
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
if (isScrollLinked || (!isDefaultTimeline && nonFiniteDuration)) {
|
||||||
|
a.cancel();
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
} catch { /* per-animation errors are non-fatal */ }
|
||||||
|
}
|
||||||
|
} catch { /* getAnimations unsupported — no-op */ }
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Node-side wrapper: bounded + never fatal. */
|
||||||
|
export async function neutralizeScrollTimelineAnimations(page: Page): Promise<number> {
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
page.evaluate(neutralizeScrollTimelineAnimationsInPage),
|
||||||
|
new Promise<number>((res) => setTimeout(() => res(0), 5000)),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Carousel settling (runs in the page) ----
|
||||||
|
|
||||||
|
export type CarouselSettleResult = { roots: number; normalized: number; neutralizedAnims: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministically settle recognizable library carousels: engage the library's own
|
||||||
|
* pause path, neutralize any animation driving the track, and navigate to the REAL first
|
||||||
|
* slide (home). Navigation preference:
|
||||||
|
* 1. the exposed Swiper instance (`el.swiper`) — stop autoplay + slideTo(Loop)(0, 0);
|
||||||
|
* 2. the first pagination bullet (libraries resolve loop-mode clones themselves);
|
||||||
|
* 3. prev-arrow clicks back from the marked active slide's real index;
|
||||||
|
* 4. inline `translateX(0)` — only for a non-loop track with no controls (loop mode
|
||||||
|
* prepends clones, so 0 is not home there; such a track is left paused as-is).
|
||||||
|
* Autoplay pause is best-effort-deterministic: Swiper via its instance; Splide/Slick/Glide
|
||||||
|
* pause on pointer-enter by default, so a synthetic mouseenter/mouseover latches them
|
||||||
|
* (nothing dispatches the matching mouseleave). Ends with a bounded wait for every track
|
||||||
|
* transform to stop changing so the caller snapshots a settled frame.
|
||||||
|
*/
|
||||||
|
export async function settleCarouselsInPage(): Promise<CarouselSettleResult> {
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const ROOT_SEL = ".splide, .swiper, .swiper-container, .slick-slider, .glide";
|
||||||
|
const TRACK_SEL = ".splide__list, .swiper-wrapper, .slick-track, .glide__slides";
|
||||||
|
const BULLET_SEL = ".splide__pagination__bullet, .swiper-pagination-bullet, .slick-dots button, .glide__bullet";
|
||||||
|
const PREV_SEL = ".splide__arrow--prev, .swiper-button-prev, .slick-prev, [data-glide-dir='<']";
|
||||||
|
const ACTIVE_SEL = ".is-active, .swiper-slide-active, .slick-current, .glide__slide--active";
|
||||||
|
const CLONE_SEL = ".splide__slide--clone, .swiper-slide-duplicate, .slick-cloned, .glide__slide--clone";
|
||||||
|
const txOf = (el: Element): number => {
|
||||||
|
try { return new DOMMatrixReadOnly(getComputedStyle(el).transform).m41; } catch { return 0; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const roots = Array.from(document.querySelectorAll(ROOT_SEL));
|
||||||
|
const tracks: Element[] = [];
|
||||||
|
let normalized = 0;
|
||||||
|
let neutralizedAnims = 0;
|
||||||
|
for (const root of roots) {
|
||||||
|
const track = Array.from(root.querySelectorAll(TRACK_SEL)).find((t) => t.closest(ROOT_SEL) === root);
|
||||||
|
if (!track) continue;
|
||||||
|
tracks.push(track);
|
||||||
|
// pause-on-hover latch (Splide/Slick default-on; Glide's hoverpause): synthetic
|
||||||
|
// pointer-enter with no matching leave keeps autoplay paused for the snapshot.
|
||||||
|
for (const t of [root, track.parentElement, track]) {
|
||||||
|
if (!t) continue;
|
||||||
|
try {
|
||||||
|
t.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false }));
|
||||||
|
t.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
// Neutralize the track's OWN animations only (scoped so motion.ts's marquee/rotator
|
||||||
|
// capture is untouched). CSS transitions/animations are CANCELED, not paused: pausing
|
||||||
|
// a CSSTransition disassociates it from style and it then HOLDS its frozen mid-flight
|
||||||
|
// transform, overriding the home navigation below (the declarative path reconstructs
|
||||||
|
// CSS motion from the IR, so canceling loses nothing). Pure WAAPI is PAUSED so it
|
||||||
|
// stays in getAnimations() with keyframes/timing intact for motion.ts to record.
|
||||||
|
try {
|
||||||
|
for (const a of (track as HTMLElement).getAnimations?.() ?? []) {
|
||||||
|
try {
|
||||||
|
const ctor = a.constructor.name;
|
||||||
|
if (ctor === "CSSTransition" || ctor === "CSSAnimation") a.cancel(); else a.pause();
|
||||||
|
neutralizedAnims++;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
let home = false;
|
||||||
|
// 1) Swiper exposes its instance on the container.
|
||||||
|
const sw = (root as { swiper?: { autoplay?: { stop?: () => void }; slideTo?: (i: number, ms?: number) => void; slideToLoop?: (i: number, ms?: number) => void } }).swiper;
|
||||||
|
if (sw) {
|
||||||
|
try { sw.autoplay?.stop?.(); } catch { /* ignore */ }
|
||||||
|
try {
|
||||||
|
if (sw.slideToLoop) { sw.slideToLoop(0, 0); home = true; }
|
||||||
|
else if (sw.slideTo) { sw.slideTo(0, 0); home = true; }
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
// 2) First pagination bullet (clicked even if hidden at this width — a destroyed
|
||||||
|
// breakpoint variant just ignores the click).
|
||||||
|
if (!home) {
|
||||||
|
const bullet = Array.from(root.querySelectorAll(BULLET_SEL)).find((b) => b.closest(ROOT_SEL) === root) as HTMLElement | undefined;
|
||||||
|
if (bullet) { try { bullet.click(); home = true; } catch { /* ignore */ } }
|
||||||
|
}
|
||||||
|
// 3) Step back from the marked active slide's real index with the prev arrow.
|
||||||
|
if (!home) {
|
||||||
|
const prev = Array.from(root.querySelectorAll(PREV_SEL)).find((b) => b.closest(ROOT_SEL) === root) as HTMLElement | undefined;
|
||||||
|
const active = Array.from(track.children).find((s) => s.matches(ACTIVE_SEL));
|
||||||
|
if (prev && active) {
|
||||||
|
const real = Array.from(track.children).filter((s) => !s.matches(CLONE_SEL));
|
||||||
|
const idx = real.indexOf(active);
|
||||||
|
if (idx >= 0) {
|
||||||
|
for (let k = 0; k < Math.min(idx, 30); k++) { try { prev.click(); } catch { break; } await sleep(90); }
|
||||||
|
home = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 4) No controls: pin translateX(0) — home for a non-loop track. Loop mode prepends
|
||||||
|
// clones (0 shows a clone), so a control-less loop track is left paused as-is.
|
||||||
|
if (!home) {
|
||||||
|
const isLoop = /--loop\b/.test(root.className) || track.querySelector(CLONE_SEL) != null;
|
||||||
|
if (!isLoop && Math.abs(txOf(track)) > 0.5) {
|
||||||
|
(track as HTMLElement).style.transform = "translateX(0px)";
|
||||||
|
home = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (home) normalized++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounded wait for the navigation transitions to land (and confirm nothing is still
|
||||||
|
// auto-advancing): every track transform stable for 3 consecutive samples.
|
||||||
|
if (tracks.length) {
|
||||||
|
let prevSig = tracks.map((t) => Math.round(txOf(t))).join(",");
|
||||||
|
let stable = 0;
|
||||||
|
for (let i = 0; i < 14 && stable < 3; i++) {
|
||||||
|
await sleep(140);
|
||||||
|
const sig = tracks.map((t) => Math.round(txOf(t))).join(",");
|
||||||
|
if (sig === prevSig) stable++; else { stable = 0; prevSig = sig; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { roots: tracks.length, normalized, neutralizedAnims };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Node-side wrapper: bounded + never fatal. */
|
||||||
|
export async function settleCarousels(page: Page): Promise<CarouselSettleResult> {
|
||||||
|
const empty: CarouselSettleResult = { roots: 0, normalized: 0, neutralizedAnims: 0 };
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
page.evaluate(settleCarouselsInPage),
|
||||||
|
new Promise<CarouselSettleResult>((res) => setTimeout(() => res(empty), 10_000)),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Force-reveal for element screenshots (runs in the page) ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A visibility:hidden video (entrance animation not yet fired) passes the size gate but
|
||||||
|
* `locator.screenshot` auto-waits for visibility and times out. Force the element and any
|
||||||
|
* hidden ancestor visible for the shot, recording each prior inline value so
|
||||||
|
* `restoreRevealForShot` puts everything back exactly. Returns how many were forced.
|
||||||
|
*/
|
||||||
|
export function forceRevealForShot(sel: string): number {
|
||||||
|
const el = document.querySelector(sel) as HTMLElement | null;
|
||||||
|
if (!el) return 0;
|
||||||
|
let n = 0;
|
||||||
|
for (let cur: HTMLElement | null = el; cur; cur = cur.parentElement) {
|
||||||
|
if (getComputedStyle(cur).visibility !== "hidden") continue;
|
||||||
|
const prev = cur.style.getPropertyValue("visibility");
|
||||||
|
const prio = cur.style.getPropertyPriority("visibility");
|
||||||
|
cur.setAttribute("data-clone-vis-restore", `${prio}|${prev}`);
|
||||||
|
cur.style.setProperty("visibility", "visible", "important");
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Undo forceRevealForShot exactly (inline value + priority, or absence). */
|
||||||
|
export function restoreRevealForShot(): void {
|
||||||
|
for (const el of Array.from(document.querySelectorAll("[data-clone-vis-restore]")) as HTMLElement[]) {
|
||||||
|
const raw = el.getAttribute("data-clone-vis-restore") ?? "|";
|
||||||
|
el.removeAttribute("data-clone-vis-restore");
|
||||||
|
const i = raw.indexOf("|");
|
||||||
|
const prio = raw.slice(0, i);
|
||||||
|
const prev = raw.slice(i + 1);
|
||||||
|
if (prev) el.style.setProperty("visibility", prev, prio);
|
||||||
|
else el.style.removeProperty("visibility");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,9 @@ export type RawNode = {
|
|||||||
sizing?: RawSizing;
|
sizing?: RawSizing;
|
||||||
before?: RawStyle;
|
before?: RawStyle;
|
||||||
after?: RawStyle;
|
after?: RawStyle;
|
||||||
|
// ::placeholder computed style for input/textarea with placeholder text. Without it the
|
||||||
|
// clone renders the browser's default gray, losing the authored placeholder color/type.
|
||||||
|
placeholder?: RawStyle;
|
||||||
rawHTML?: string; // set for inline <svg>
|
rawHTML?: string; // set for inline <svg>
|
||||||
children: RawChild[];
|
children: RawChild[];
|
||||||
};
|
};
|
||||||
@@ -100,7 +103,9 @@ export type PageSnapshot = {
|
|||||||
keyframes: string[]; // raw @keyframes blocks from accessible sheets
|
keyframes: string[]; // raw @keyframes blocks from accessible sheets
|
||||||
};
|
};
|
||||||
|
|
||||||
export function collectPage(): PageSnapshot {
|
// `| void` keeps the no-arg `page.evaluate(collectPage)` call sites type-compatible
|
||||||
|
// (Playwright types the missing argument as void); frame grafts pass { maxNodes }.
|
||||||
|
export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||||
// NOTE: This function is serialized and run in the browser via page.evaluate,
|
// NOTE: This function is serialized and run in the browser via page.evaluate,
|
||||||
// so every constant/helper it uses must be declared INSIDE it (no module-scope
|
// so every constant/helper it uses must be declared INSIDE it (no module-scope
|
||||||
// closure is available in the page).
|
// closure is available in the page).
|
||||||
@@ -167,16 +172,45 @@ export function collectPage(): PageSnapshot {
|
|||||||
"overflow", "objectFit",
|
"overflow", "objectFit",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ::placeholder property set: the visual identity of placeholder text. Kept small —
|
||||||
|
// it inherits everything else from the input itself.
|
||||||
|
const PLACEHOLDER_PROPS: string[] = [
|
||||||
|
"color", "opacity", "fontFamily", "fontSize", "fontWeight", "fontStyle",
|
||||||
|
"letterSpacing", "textTransform",
|
||||||
|
];
|
||||||
|
|
||||||
const SKIP_TAGS = new Set([
|
const SKIP_TAGS = new Set([
|
||||||
"script", "style", "link", "meta", "noscript", "template", "base", "title", "head",
|
"script", "style", "link", "meta", "noscript", "template", "base", "title", "head",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const MAX_NODES = 12000;
|
// Text-level tags whose whitespace-only text still renders even as the FIRST/ONLY
|
||||||
|
// child (the lone-space case below); a block container's stray whitespace does not.
|
||||||
|
const INLINE_TEXT_TAGS = new Set([
|
||||||
|
"span", "strong", "em", "b", "i", "a", "u", "small", "sub", "sup", "code", "abbr", "time", "label",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Frame grafts pass a lower cap so one pathological embed can't dominate the snapshot.
|
||||||
|
const MAX_NODES = (opts && opts.maxNodes) || 12000;
|
||||||
|
|
||||||
const round2 = (n: number): number => Math.round((n || 0) * 100) / 100;
|
const round2 = (n: number): number => Math.round((n || 0) * 100) / 100;
|
||||||
const scrollX = window.scrollX;
|
const scrollX = window.scrollX;
|
||||||
const scrollY = window.scrollY;
|
const scrollY = window.scrollY;
|
||||||
|
|
||||||
|
// Viewport + page extents used by isVisible's off-screen test. bbox coords are in
|
||||||
|
// DOCUMENT space (r + scroll), so the visible window on each axis is
|
||||||
|
// [scroll, scroll + inner]. A box whose border box lies wholly outside that window
|
||||||
|
// on a NON-scrollable axis is unreachable and paints nothing to the user.
|
||||||
|
const vpW = window.innerWidth;
|
||||||
|
const vpH = window.innerHeight;
|
||||||
|
const scrollEl = document.scrollingElement || document.documentElement;
|
||||||
|
// Horizontal scrolling is legitimate when the page is wider than the viewport (RTL
|
||||||
|
// carousels, horizontal galleries). In that case content parked to the RIGHT is
|
||||||
|
// reachable by scrolling, so we only reject boxes fully off the LEFT edge (x <= 0
|
||||||
|
// start-of-page, never reachable). Vertical always scrolls, so we never reject
|
||||||
|
// in-flow content below the fold — only position:fixed boxes, which do NOT scroll
|
||||||
|
// with the page and so are truly gone if parked above/below the viewport.
|
||||||
|
const horizScrollable = round2(scrollEl.scrollWidth) > vpW + 1;
|
||||||
|
|
||||||
// Resolve `line-height: normal` to a concrete px value by probing the actual
|
// Resolve `line-height: normal` to a concrete px value by probing the actual
|
||||||
// line-box height for each (font-family, font-size, font-weight, font-style).
|
// line-box height for each (font-family, font-size, font-weight, font-style).
|
||||||
// getComputedStyle reports the keyword "normal", which renders font-metric-
|
// getComputedStyle reports the keyword "normal", which renders font-metric-
|
||||||
@@ -215,6 +249,11 @@ export function collectPage(): PageSnapshot {
|
|||||||
|
|
||||||
const isVisible = (el: Element, cs: CSSStyleDeclaration, bbox: RawBBox): boolean => {
|
const isVisible = (el: Element, cs: CSSStyleDeclaration, bbox: RawBBox): boolean => {
|
||||||
if (cs.display === "none") return false;
|
if (cs.display === "none") return false;
|
||||||
|
// getComputedStyle already resolves `visibility` inheritance: a descendant that
|
||||||
|
// sets visibility:visible inside a hidden ancestor reports "visible" here (and is
|
||||||
|
// genuinely painted), so this test is exactly CSS computed semantics — no separate
|
||||||
|
// ancestor walk is needed. The off-screen test below is what catches un-hidden
|
||||||
|
// content parked outside the viewport (e.g. a slide-in drawer's inner nodes).
|
||||||
if (cs.visibility === "hidden" || cs.visibility === "collapse") return false;
|
if (cs.visibility === "hidden" || cs.visibility === "collapse") return false;
|
||||||
if (parseFloat(cs.opacity || "1") === 0) return false;
|
if (parseFloat(cs.opacity || "1") === 0) return false;
|
||||||
if (bbox.width === 0 && bbox.height === 0) {
|
if (bbox.width === 0 && bbox.height === 0) {
|
||||||
@@ -222,6 +261,28 @@ export function collectPage(): PageSnapshot {
|
|||||||
// as not visible for matching purposes.
|
// as not visible for matching purposes.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Off-screen test. bbox is document-space (x/y already include scroll). The box is
|
||||||
|
// invisible only when it lies WHOLLY outside a non-scrollable axis window — a box
|
||||||
|
// that merely straddles an edge (negative-margin / overflow-hidden decoration
|
||||||
|
// peeking in) still paints and stays visible.
|
||||||
|
//
|
||||||
|
// Horizontal: the page never scrolls left of origin, so anything whose right edge
|
||||||
|
// is at/left of 0 is unreachable. When the page is NOT horizontally scrollable we
|
||||||
|
// also reject boxes whose left edge is at/right of the viewport width; when it IS
|
||||||
|
// scrollable (wide/RTL/carousel pages), right-parked content is reachable, so only
|
||||||
|
// the fully-left case counts.
|
||||||
|
const rightEdge = bbox.x + bbox.width;
|
||||||
|
if (rightEdge <= 0) return false;
|
||||||
|
if (!horizScrollable && bbox.x >= vpW) return false;
|
||||||
|
// Vertical: the page scrolls, so below-/above-fold in-flow content is reachable and
|
||||||
|
// must stay visible. Only position:fixed boxes are pinned to the viewport and do NOT
|
||||||
|
// scroll into view — a fixed box parked entirely above or below the viewport is gone.
|
||||||
|
if (cs.position === "fixed") {
|
||||||
|
const top = bbox.y - scrollY;
|
||||||
|
const bottom = top + bbox.height;
|
||||||
|
if (bottom <= 0) return false;
|
||||||
|
if (top >= vpH) return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -358,6 +419,13 @@ export function collectPage(): PageSnapshot {
|
|||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// ::placeholder: only meaningful on a control that shows placeholder text.
|
||||||
|
if ((tag === "input" || tag === "textarea") && (attrs.placeholder || "").trim()) {
|
||||||
|
try {
|
||||||
|
node.placeholder = grabStyle(window.getComputedStyle(el, "::placeholder"), PLACEHOLDER_PROPS);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
// Inline SVG → raw markup, no recursion.
|
// Inline SVG → raw markup, no recursion.
|
||||||
if (tag === "svg") {
|
if (tag === "svg") {
|
||||||
node.rawHTML = el.outerHTML;
|
node.rawHTML = el.outerHTML;
|
||||||
@@ -379,6 +447,11 @@ export function collectPage(): PageSnapshot {
|
|||||||
} else if (t.length > 0 && node.children.length > 0) {
|
} else if (t.length > 0 && node.children.length > 0) {
|
||||||
// Preserve a single significant space between inline elements.
|
// Preserve a single significant space between inline elements.
|
||||||
node.children.push({ text: " " });
|
node.children.push({ text: " " });
|
||||||
|
} else if (t.length > 0 && (/^inline/.test(cs.display) || INLINE_TEXT_TAGS.has(tag))) {
|
||||||
|
// Whitespace that is the FIRST/ONLY child of an inline element still renders
|
||||||
|
// (`of<strong> </strong>the` keeps its space); dropping it fuses the adjacent
|
||||||
|
// text runs. Scoped to inline parents so block containers stay empty.
|
||||||
|
node.children.push({ text: " " });
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-4
@@ -38,6 +38,9 @@ export type CloneResult = {
|
|||||||
/** A stable, timestamp-free path to the app (via the `runs/<site>/latest` symlink),
|
/** A stable, timestamp-free path to the app (via the `runs/<site>/latest` symlink),
|
||||||
* present in runs-layout mode when the symlink could be created. */
|
* present in runs-layout mode when the symlink could be created. */
|
||||||
stableAppDir?: string;
|
stableAppDir?: string;
|
||||||
|
/** Visual assets (image/svg/video) that could not be downloaded — those boxes render
|
||||||
|
* as placeholders. Surfaced in the CLI summary; details in generated/assets.json. */
|
||||||
|
visualAssetsMissing?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function siteIdFromUrl(url: string): string {
|
export function siteIdFromUrl(url: string): string {
|
||||||
@@ -455,7 +458,8 @@ export async function runClone(opts: CloneOptions): Promise<CloneResult> {
|
|||||||
const gen = generateAll({ sourceDir, capture, viewports, sampleViewports: captureViewports, url: opts.url, outDir: generatedDir });
|
const gen = generateAll({ sourceDir, capture, viewports, sampleViewports: captureViewports, url: opts.url, outDir: generatedDir });
|
||||||
logBoth({ event: "ir_built", nodes: gen.ir.doc.nodeCount });
|
logBoth({ event: "ir_built", nodes: gen.ir.doc.nodeCount });
|
||||||
logBoth({ event: "inferred", sections: gen.sections.length, assets: gen.assetGraph.entries.length, fonts: gen.fontGraph.entries.length });
|
logBoth({ event: "inferred", sections: gen.sections.length, assets: gen.assetGraph.entries.length, fonts: gen.fontGraph.entries.length });
|
||||||
logBoth({ event: "generated", assetsCopied: gen.assetsCopied, assetsMissing: gen.assetsMissing.length });
|
const visualAssetsMissing = gen.assetGraph.entries.filter((e) => e.impact === "visual_missing").length;
|
||||||
|
logBoth({ event: "generated", assetsCopied: gen.assetsCopied, assetsMissing: gen.assetsMissing.length, visualAssetsMissing });
|
||||||
|
|
||||||
// When the source capture lacks native probe flags (this sandbox can't reach the
|
// When the source capture lacks native probe flags (this sandbox can't reach the
|
||||||
// live site through the egress proxy), optionally iterate render→regen so the LOCAL clone-probe
|
// live site through the egress proxy), optionally iterate render→regen so the LOCAL clone-probe
|
||||||
@@ -479,7 +483,7 @@ export async function runClone(opts: CloneOptions): Promise<CloneResult> {
|
|||||||
stableAppDir = writeLatestPointer(runsDir, siteId, runDir);
|
stableAppDir = writeLatestPointer(runsDir, siteId, runDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { runDir, sourceDir, appDir: out ? out.appDir : appDir, sourceUrl: opts.url, stableAppDir };
|
return { runDir, sourceDir, appDir: out ? out.appDir : appDir, sourceUrl: opts.url, stableAppDir, visualAssetsMissing };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Record the newest run for a site in the runs layout: a `latest.json` breadcrumb (used by
|
/** Record the newest run for a site in the runs layout: a `latest.json` breadcrumb (used by
|
||||||
@@ -576,11 +580,11 @@ async function main(): Promise<void> {
|
|||||||
// --serve installs deps + starts the dev server after cloning; --open also launches the browser.
|
// --serve installs deps + starts the dev server after cloning; --open also launches the browser.
|
||||||
const open = hasAnyFlag(args, ["--open"]);
|
const open = hasAnyFlag(args, ["--open"]);
|
||||||
const serve = open || hasAnyFlag(args, ["--serve"]);
|
const serve = open || hasAnyFlag(args, ["--serve"]);
|
||||||
const finish = async (res: { appDir: string; stableAppDir?: string }) => {
|
const finish = async (res: { appDir: string; stableAppDir?: string; visualAssetsMissing?: number }) => {
|
||||||
if (serve) {
|
if (serve) {
|
||||||
await serveApp(res.appDir, { open });
|
await serveApp(res.appDir, { open });
|
||||||
} else {
|
} else {
|
||||||
process.stderr.write(doneSummary({ url, appDir: res.appDir, framework, stableAppDir: res.stableAppDir }));
|
process.stderr.write(doneSummary({ url, appDir: res.appDir, framework, stableAppDir: res.stableAppDir, visualAssetsMissing: res.visualAssetsMissing }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const vpArg = firstFlagValue(args, ["--dev-viewports", "--viewports"]);
|
const vpArg = firstFlagValue(args, ["--dev-viewports", "--viewports"]);
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export type DoneSummaryInput = {
|
|||||||
framework: "next" | "vite";
|
framework: "next" | "vite";
|
||||||
/** Stable path (a `runs/<site>/latest` symlink target) preferred as the `cd` target when present. */
|
/** Stable path (a `runs/<site>/latest` symlink target) preferred as the `cd` target when present. */
|
||||||
stableAppDir?: string;
|
stableAppDir?: string;
|
||||||
|
/** Visual assets (image/svg/video) that failed to download — those boxes paint as
|
||||||
|
* placeholders in the clone. 0/undefined → line omitted. */
|
||||||
|
visualAssetsMissing?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Double-quote a path for a POSIX shell so spaces and wrapping don't break copy-paste. */
|
/** Double-quote a path for a POSIX shell so spaces and wrapping don't break copy-paste. */
|
||||||
@@ -46,6 +49,12 @@ export function doneSummary(input: DoneSummaryInput): string {
|
|||||||
" Or re-run with --serve to install deps and start the dev server for you",
|
" Or re-run with --serve to install deps and start the dev server for you",
|
||||||
" (add --open to launch the browser too).",
|
" (add --open to launch the browser too).",
|
||||||
];
|
];
|
||||||
|
if (input.visualAssetsMissing) {
|
||||||
|
const n = input.visualAssetsMissing;
|
||||||
|
lines.push("");
|
||||||
|
lines.push(` ⚠ ${n} visual asset${n === 1 ? "" : "s"} could not be downloaded and will render as`);
|
||||||
|
lines.push(` placeholders — see generated/assets.json (classification "skipped").`);
|
||||||
|
}
|
||||||
if (input.stableAppDir && input.stableAppDir !== input.appDir) {
|
if (input.stableAppDir && input.stableAppDir !== input.appDir) {
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(` The path above is a stable pointer to the newest clone. This exact run:`);
|
lines.push(` The path above is a stable pointer to the newest clone. This exact run:`);
|
||||||
|
|||||||
@@ -195,6 +195,35 @@ function resolveUrl(url: string, base: string): string {
|
|||||||
try { return new URL(url, base).href; } catch { return url; }
|
try { return new URL(url, base).href; } catch { return url; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Srcset candidates rewritten to materialized local assets, order preserved;
|
||||||
|
* candidates whose URL did not materialize are dropped (never placeholders —
|
||||||
|
* srcset wins over src, so a placeholder would beat the rewritten fallback). */
|
||||||
|
function keptSrcsetCandidates(value: string, assetMap: Map<string, string>, sourceUrl: string): string[] {
|
||||||
|
return value.split(",").map((p) => p.trim()).filter(Boolean).map((seg) => {
|
||||||
|
const sp = seg.split(/\s+/);
|
||||||
|
const abs = resolveUrl(sp[0] ?? "", sourceUrl);
|
||||||
|
const local = assetMap.get(abs);
|
||||||
|
return local ? [local, ...sp.slice(1)].join(" ") : null;
|
||||||
|
}).filter((x): x is string => x !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a <video>'s own src or any child <source src> materialized locally —
|
||||||
|
* the clone can then ship the real file instead of the poster-only fallback. */
|
||||||
|
function videoHasLocalSource(node: IRNode, assetMap: Map<string, string>, sourceUrl: string): boolean {
|
||||||
|
if (node.attrs.src && assetMap.get(resolveUrl(node.attrs.src, sourceUrl))) return true;
|
||||||
|
return node.children.some((c) => !isTextChild(c) && c.tag === "source"
|
||||||
|
&& !!c.attrs.src && !!assetMap.get(resolveUrl(c.attrs.src, sourceUrl)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a <source> element still points at anything after asset rewriting
|
||||||
|
* (materialized src, or ≥1 surviving srcset candidate). A source with none is
|
||||||
|
* omitted rather than emitted pointing at placeholders. */
|
||||||
|
function sourceHasLocalCandidate(c: IRNode, assetMap: Map<string, string>, sourceUrl: string): boolean {
|
||||||
|
if (c.attrs.src && assetMap.get(resolveUrl(c.attrs.src, sourceUrl))) return true;
|
||||||
|
if (c.attrs.srcset && keptSrcsetCandidates(c.attrs.srcset, assetMap, sourceUrl).length > 0) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** Default single-page link rewrite: a clone is self-contained, so a link that points
|
/** Default single-page link rewrite: a clone is self-contained, so a link that points
|
||||||
* back to the SOURCE origin is rewritten to an app-relative path (`/enterprise`) instead
|
* back to the SOURCE origin is rewritten to an app-relative path (`/enterprise`) instead
|
||||||
* of the absolute source URL (`https://www.source.com/enterprise`) — otherwise every nav
|
* of the absolute source URL (`https://www.source.com/enterprise`) — otherwise every nav
|
||||||
@@ -284,39 +313,48 @@ export function propsList(node: IRNode, assetMap: Map<string, string>, sourceUrl
|
|||||||
const prim = ctx?.primitives?.get(node.id);
|
const prim = ctx?.primitives?.get(node.id);
|
||||||
if (prim) props.push(['"data-component"', JSON.stringify(prim)]);
|
if (prim) props.push(['"data-component"', JSON.stringify(prim)]);
|
||||||
|
|
||||||
// Stage 2: a <video> is rendered as its (first-frame) poster — a streamed source
|
// A <video> whose file materialized locally ships it, mirroring the captured
|
||||||
// has no deterministic frame and its request aborts at snapshot time. Drop the
|
// playback attrs (autoplay/loop/muted/playsInline) faithfully. Otherwise it is
|
||||||
// streaming src + autoplay so only the poster paints; keep the poster (rewritten
|
// rendered as its (first-frame) poster — a streamed source has no deterministic
|
||||||
// to a local still below). <source>/<track> children are dropped in renderNode.
|
// frame and its request aborts at snapshot time — dropping the streaming src +
|
||||||
|
// autoplay so only the poster paints; keep the poster (rewritten to a local
|
||||||
|
// still below). <source>/<track> children are filtered in emitChildren.
|
||||||
const isVideo = node.tag === "video";
|
const isVideo = node.tag === "video";
|
||||||
// An <iframe> embeds a third-party, non-deterministic document; reproducing it
|
const videoLocal = isVideo && videoHasLocalSource(node, assetMap, sourceUrl);
|
||||||
|
// An <iframe> embeds a third-party, non-deterministic document; reproducing it live
|
||||||
// would break self-containment (rubric Gate 2) and can't be deterministic anyway.
|
// would break self-containment (rubric Gate 2) and can't be deterministic anyway.
|
||||||
// Keep the element as a placeholder sized by its captured box, but drop the
|
// When capture grafted the embedded document's subtree (graft.ts) the node renders as
|
||||||
// document-loading attrs so it paints an empty frame instead of pulling content.
|
// a <div> with those children (see resolveTag) — drop the frame-only attrs entirely
|
||||||
|
// (width/height would be invalid on a div; CSS keys the box). Otherwise keep the
|
||||||
|
// element as a placeholder sized by its captured box, minus the document-loading
|
||||||
|
// attrs, so it paints an empty frame instead of pulling content.
|
||||||
const isIframe = node.tag === "iframe";
|
const isIframe = node.tag === "iframe";
|
||||||
|
const iframeGrafted = isIframe && node.children.some((c) => !isTextChild(c));
|
||||||
const attrKeys = Object.keys(node.attrs).sort();
|
const attrKeys = Object.keys(node.attrs).sort();
|
||||||
for (const key of attrKeys) {
|
for (const key of attrKeys) {
|
||||||
let value = node.attrs[key]!;
|
let value = node.attrs[key]!;
|
||||||
if (key === "class" || key === "style" || key === "data-cid-cap") continue;
|
if (key === "class" || key === "style" || key === "data-cid-cap") continue;
|
||||||
if (isVideo && (key === "src" || key === "autoplay" || key === "loop" || key === "preload")) continue;
|
if (isVideo && !videoLocal && (key === "src" || key === "autoplay" || key === "loop" || key === "preload")) continue;
|
||||||
if (isIframe && (key === "src" || key === "srcdoc" || key === "name")) continue;
|
if (isIframe && (key === "src" || key === "srcdoc" || key === "name")) continue;
|
||||||
|
if (iframeGrafted && (key === "width" || key === "height")) continue;
|
||||||
|
|
||||||
if (ASSET_ATTRS.has(key)) {
|
if (ASSET_ATTRS.has(key)) {
|
||||||
const abs = resolveUrl(value, sourceUrl);
|
const abs = resolveUrl(value, sourceUrl);
|
||||||
const local = assetMap.get(abs);
|
const local = assetMap.get(abs);
|
||||||
value = local ?? TRANSPARENT_GIF; // never point back to a remote origin
|
// A poster that missed the asset map is DROPPED — a guaranteed-blank overlay
|
||||||
|
// hides the element's own background, strictly worse than showing it. A missed
|
||||||
|
// video/source src likewise (a placeholder is not playable; a local <source>
|
||||||
|
// child may still carry the file). Only <img> keeps the transparent-GIF
|
||||||
|
// fallback: never point back to a remote origin.
|
||||||
|
if (!local && (key === "poster" || node.tag === "video" || node.tag === "source")) continue;
|
||||||
|
value = local ?? TRANSPARENT_GIF;
|
||||||
} else if (key === "srcset") {
|
} else if (key === "srcset") {
|
||||||
// Keep only candidates we actually materialized; drop the rest. Lazy-load
|
// Keep only candidates we actually materialized; drop the rest. Lazy-load
|
||||||
// libraries seed srcset with 1x1 placeholders (data: GIFs) and swap in the
|
// libraries seed srcset with 1x1 placeholders (data: GIFs) and swap in the
|
||||||
// real URLs via JS — replaying those placeholders would beat the rewritten
|
// real URLs via JS — replaying those placeholders would beat the rewritten
|
||||||
// `src` (srcset wins over src) and paint a blank box. If nothing survives,
|
// `src` (srcset wins over src) and paint a blank box. If nothing survives,
|
||||||
// omit srcset so the browser falls back to the real local `src`.
|
// omit srcset so the browser falls back to the real local `src`.
|
||||||
const kept = value.split(",").map((p) => p.trim()).filter(Boolean).map((seg) => {
|
const kept = keptSrcsetCandidates(value, assetMap, sourceUrl);
|
||||||
const sp = seg.split(/\s+/);
|
|
||||||
const abs = resolveUrl(sp[0] ?? "", sourceUrl);
|
|
||||||
const local = assetMap.get(abs);
|
|
||||||
return local ? [local, ...sp.slice(1)].join(" ") : null;
|
|
||||||
}).filter((x): x is string => x !== null);
|
|
||||||
if (kept.length === 0) continue;
|
if (kept.length === 0) continue;
|
||||||
value = kept.join(", ");
|
value = kept.join(", ");
|
||||||
} else if (key === "href") {
|
} else if (key === "href") {
|
||||||
@@ -340,7 +378,7 @@ export function propsList(node: IRNode, assetMap: Map<string, string>, sourceUrl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isVideo && !props.some(([k]) => k === "preload")) props.push(["preload", JSON.stringify("none")]);
|
if (isVideo && !videoLocal && !props.some(([k]) => k === "preload")) props.push(["preload", JSON.stringify("none")]);
|
||||||
|
|
||||||
if (node.rawHTML && node.tag === "svg") {
|
if (node.rawHTML && node.tag === "svg") {
|
||||||
// Strip the Stage-4 capture-id (`data-cid-cap`) the interaction pass stamps on
|
// Strip the Stage-4 capture-id (`data-cid-cap`) the interaction pass stamps on
|
||||||
@@ -504,6 +542,10 @@ export function resolveTag(node: IRNode, insideInteractive: boolean, insideTable
|
|||||||
const disp = (node.computedByVp[1280] ?? Object.values(node.computedByVp)[0])?.display ?? "";
|
const disp = (node.computedByVp[1280] ?? Object.values(node.computedByVp)[0])?.display ?? "";
|
||||||
tag = /inline(?!-block|-flex|-grid)/.test(disp) ? "span" : "div";
|
tag = /inline(?!-block|-flex|-grid)/.test(disp) ? "span" : "div";
|
||||||
}
|
}
|
||||||
|
// An iframe with grafted children (capture/graft.ts) is a real subtree, not an embed:
|
||||||
|
// children inside <iframe> are unrendered fallback content, so emit a <div> container
|
||||||
|
// carrying the iframe's box/styles (CSS is keyed by cid, so the geometry is identical).
|
||||||
|
if (tag === "iframe" && node.children.some((c) => !isTextChild(c))) tag = "div";
|
||||||
if (TABLE_SCOPED.has(tag) && !insideTable) tag = "div"; // orphan table element → neutral box
|
if (TABLE_SCOPED.has(tag) && !insideTable) tag = "div"; // orphan table element → neutral box
|
||||||
if (violatesContentModel(node, tag)) tag = "div";
|
if (violatesContentModel(node, tag)) tag = "div";
|
||||||
return tag;
|
return tag;
|
||||||
@@ -579,7 +621,11 @@ function emitChildren(children: IRChild[], parentTag: string | null, assetMap: M
|
|||||||
textBuf += c.text;
|
textBuf += c.text;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (parentTag === "video" && (c.tag === "source" || c.tag === "track")) continue;
|
if (parentTag === "video" && c.tag === "track") continue; // caption files are not captured
|
||||||
|
// A <picture>/<video> `<source>` is emitted only when a candidate materialized
|
||||||
|
// locally: a video source otherwise falls back to poster-only rendering, a
|
||||||
|
// picture source must never point its media band at a placeholder.
|
||||||
|
if ((parentTag === "video" || parentTag === "picture") && c.tag === "source" && !sourceHasLocalCandidate(c, assetMap, sourceUrl)) continue;
|
||||||
// Section split: a section-root child is hoisted into its own module and replaced
|
// Section split: a section-root child is hoisted into its own module and replaced
|
||||||
// by a `<HeroSection />` placeholder. Rendered once (subtree → module body); the
|
// by a `<HeroSection />` placeholder. Rendered once (subtree → module body); the
|
||||||
// composed DOM is identical to inlining (same tags/cids/classes).
|
// composed DOM is identical to inlining (same tags/cids/classes).
|
||||||
@@ -1117,8 +1163,11 @@ function emitVariantSkeleton(componentName: string, instances: IRNode[], variant
|
|||||||
instances.forEach((n, k) => { runText![k] += (n.children[i] as IRTextNode).text; });
|
instances.forEach((n, k) => { runText![k] += (n.children[i] as IRTextNode).text; });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (tag === "video" && (repr.children[i] as IRNode).tag === "source") continue;
|
|
||||||
if (tag === "video" && (repr.children[i] as IRNode).tag === "track") continue;
|
if (tag === "video" && (repr.children[i] as IRNode).tag === "track") continue;
|
||||||
|
// Mirror emitChildren: a <source> child survives only with a materialized candidate
|
||||||
|
// (judged on the representative — instances share the capture's asset set).
|
||||||
|
if ((tag === "video" || tag === "picture") && (repr.children[i] as IRNode).tag === "source"
|
||||||
|
&& !sourceHasLocalCandidate(repr.children[i] as IRNode, assetMap, sourceUrl)) continue;
|
||||||
flushText();
|
flushText();
|
||||||
const subNodes = instances.map((n) => n.children[i] as IRNode);
|
const subNodes = instances.map((n) => n.children[i] as IRNode);
|
||||||
const sub = emitVariantSkeleton(componentName, subNodes, variants, childInteractive, indent + 1, dataRows, styleRows, cids, gen, styleGen, slots, assetMap, sourceUrl, ctx, childTable, [...ancestors, repr.tag]);
|
const sub = emitVariantSkeleton(componentName, subNodes, variants, childInteractive, indent + 1, dataRows, styleRows, cids, gen, styleGen, slots, assetMap, sourceUrl, ctx, childTable, [...ancestors, repr.tag]);
|
||||||
@@ -1498,8 +1547,11 @@ function emitSkeleton(instances: IRNode[], insideInteractive: boolean, indent: n
|
|||||||
instances.forEach((n, k) => { runText![k] += (n.children[i] as IRTextNode).text; });
|
instances.forEach((n, k) => { runText![k] += (n.children[i] as IRTextNode).text; });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (tag === "video" && (repr.children[i] as IRNode).tag === "source") continue;
|
|
||||||
if (tag === "video" && (repr.children[i] as IRNode).tag === "track") continue;
|
if (tag === "video" && (repr.children[i] as IRNode).tag === "track") continue;
|
||||||
|
// Mirror emitChildren: a <source> child survives only with a materialized candidate
|
||||||
|
// (judged on the representative — instances share the capture's asset set).
|
||||||
|
if ((tag === "video" || tag === "picture") && (repr.children[i] as IRNode).tag === "source"
|
||||||
|
&& !sourceHasLocalCandidate(repr.children[i] as IRNode, assetMap, sourceUrl)) continue;
|
||||||
flushText();
|
flushText();
|
||||||
const sub = emitSkeleton(instances.map((n) => n.children[i] as IRNode), childInteractive, indent + 1, dataRows, styleRows, cids, gen, styleGen, assetMap, sourceUrl, ctx, childTable, [...ancestors, repr.tag]);
|
const sub = emitSkeleton(instances.map((n) => n.children[i] as IRNode), childInteractive, indent + 1, dataRows, styleRows, cids, gen, styleGen, assetMap, sourceUrl, ctx, childTable, [...ancestors, repr.tag]);
|
||||||
if (sub === null) return null;
|
if (sub === null) return null;
|
||||||
@@ -2001,7 +2053,10 @@ export function sectionFiles(sreg: SectionRegistry | undefined, reg: ComponentRe
|
|||||||
let body = jsx;
|
let body = jsx;
|
||||||
for (const v of usedData) {
|
for (const v of usedData) {
|
||||||
const p = dataProps.get(v) ?? camelVar(v);
|
const p = dataProps.get(v) ?? camelVar(v);
|
||||||
if (p !== v) body = body.split(`${v}.map(`).join(`${p}.map(`);
|
// Word-boundary rewrite: a plain substring replace of `${v}.map(` would also match
|
||||||
|
// inside a longer var (e.g. `Tile2_data` is a suffix of `MediaTile2_data`), corrupting
|
||||||
|
// it. Anchor on a non-identifier char before the name so only the whole var is renamed.
|
||||||
|
if (p !== v) body = body.replace(new RegExp(`(^|[^\\w$])${escapeRegExp(v)}\\.map\\(`, "g"), `$1${p}.map(`);
|
||||||
const binding = contentBindings.get(v);
|
const binding = contentBindings.get(v);
|
||||||
if (binding) {
|
if (binding) {
|
||||||
const alias = safeIdent(`${p}Content`, "contentData");
|
const alias = safeIdent(`${p}Content`, "contentData");
|
||||||
@@ -2478,6 +2533,7 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
|
|||||||
const globals = tailwindGlobalsCss({
|
const globals = tailwindGlobalsCss({
|
||||||
reset: RESET_CSS, fontCss: fontGraph.css, tokensCss: tokensCss + (tw.colorDefsCss ? "\n" + tw.colorDefsCss : ""),
|
reset: RESET_CSS, fontCss: fontGraph.css, tokensCss: tokensCss + (tw.colorDefsCss ? "\n" + tw.colorDefsCss : ""),
|
||||||
htmlBg, bodyFont: SYSTEM_FALLBACK, clip, colorTokens: tw.colorTokens, viewports: ir.doc.viewports,
|
htmlBg, bodyFont: SYSTEM_FALLBACK, clip, colorTokens: tw.colorTokens, viewports: ir.doc.viewports,
|
||||||
|
canonical: ir.doc.canonicalViewport,
|
||||||
});
|
});
|
||||||
writeText(join(rootDir, "globals.css"), framework === "vite" ? viteGlobalsCss(globals) : globals);
|
writeText(join(rootDir, "globals.css"), framework === "vite" ? viteGlobalsCss(globals) : globals);
|
||||||
} else {
|
} else {
|
||||||
@@ -2685,6 +2741,8 @@ const nextConfig = {
|
|||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
eslint: { ignoreDuringBuilds: true },
|
eslint: { ignoreDuringBuilds: true },
|
||||||
typescript: { ignoreBuildErrors: true },
|
typescript: { ignoreBuildErrors: true },
|
||||||
|
// The dev-tools badge would leak into reviewer/validator screenshots.
|
||||||
|
devIndicators: false,
|
||||||
};
|
};
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function signature(nr: NodeRule): string {
|
|||||||
for (const b of nr.bands) s += `@${b.media}{${serDecls(b.decls)}}`;
|
for (const b of nr.bands) s += `@${b.media}{${serDecls(b.decls)}}`;
|
||||||
if (nr.before) { s += "::before{" + serDecls(nr.before.base); for (const b of nr.before.bands) s += `@${b.media}{${serDecls(b.decls)}}`; s += "}"; }
|
if (nr.before) { s += "::before{" + serDecls(nr.before.base); for (const b of nr.before.bands) s += `@${b.media}{${serDecls(b.decls)}}`; s += "}"; }
|
||||||
if (nr.after) { s += "::after{" + serDecls(nr.after.base); for (const b of nr.after.bands) s += `@${b.media}{${serDecls(b.decls)}}`; s += "}"; }
|
if (nr.after) { s += "::after{" + serDecls(nr.after.base); for (const b of nr.after.bands) s += `@${b.media}{${serDecls(b.decls)}}`; s += "}"; }
|
||||||
|
if (nr.placeholder) { s += "::placeholder{" + serDecls(nr.placeholder.base); for (const b of nr.placeholder.bands) s += `@${b.media}{${serDecls(b.decls)}}`; s += "}"; }
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +126,7 @@ function splitRule(nr: NodeRule): { typo: NodeRule; layout: NodeRule; box: NodeR
|
|||||||
const b0 = partition(nr.base);
|
const b0 = partition(nr.base);
|
||||||
const typo: NodeRule = { base: b0.typo, bands: [] };
|
const typo: NodeRule = { base: b0.typo, bands: [] };
|
||||||
const layout: NodeRule = { base: b0.layout, bands: [] };
|
const layout: NodeRule = { base: b0.layout, bands: [] };
|
||||||
const box: NodeRule = { base: b0.box, bands: [], before: nr.before, after: nr.after };
|
const box: NodeRule = { base: b0.box, bands: [], before: nr.before, after: nr.after, placeholder: nr.placeholder };
|
||||||
for (const band of nr.bands) {
|
for (const band of nr.bands) {
|
||||||
const bs = partition(band.decls);
|
const bs = partition(band.decls);
|
||||||
if (bs.typo.size) typo.bands.push({ media: band.media, decls: bs.typo });
|
if (bs.typo.size) typo.bands.push({ media: band.media, decls: bs.typo });
|
||||||
|
|||||||
+310
-20
@@ -91,6 +91,11 @@ const GENERIC: Array<{ prop: string; def: string | string[] }> = [
|
|||||||
{ prop: "bottom", def: "auto" }, { prop: "left", def: "auto" },
|
{ prop: "bottom", def: "auto" }, { prop: "left", def: "auto" },
|
||||||
{ prop: "float", def: "none" }, { prop: "clear", def: "none" },
|
{ prop: "float", def: "none" }, { prop: "clear", def: "none" },
|
||||||
{ prop: "zIndex", def: "auto" },
|
{ prop: "zIndex", def: "auto" },
|
||||||
|
// visibility is INHERITED, so the parent-equality skip keeps descendants of a
|
||||||
|
// hidden subtree clean; without this entry a node hidden at the canonical
|
||||||
|
// viewport could never emit `visibility:hidden` at all (the only other path is
|
||||||
|
// per-band and gated on being shown at base).
|
||||||
|
{ prop: "visibility", def: "visible" },
|
||||||
{ prop: "opacity", def: "1" }, { prop: "isolation", def: "auto" },
|
{ prop: "opacity", def: "1" }, { prop: "isolation", def: "auto" },
|
||||||
{ prop: "mixBlendMode", def: "normal" },
|
{ prop: "mixBlendMode", def: "normal" },
|
||||||
{ prop: "minWidth", def: ["0px", "auto"] }, { prop: "maxWidth", def: "none" },
|
{ prop: "minWidth", def: ["0px", "auto"] }, { prop: "maxWidth", def: "none" },
|
||||||
@@ -289,7 +294,7 @@ function isFluidFillItem(node: IRNode, layoutParent: IRNode | undefined, viewpor
|
|||||||
* captured viewport (so gates 0–6, measured only at those widths, are unmoved) yet scales
|
* captured viewport (so gates 0–6, measured only at those widths, are unmoved) yet scales
|
||||||
* fluidly everywhere else — the same generalisation `isFluidFullBleed` already applies to
|
* fluidly everywhere else — the same generalisation `isFluidFullBleed` already applies to
|
||||||
* full-viewport elements, extended to "fills/centres within its container". */
|
* full-viewport elements, extended to "fills/centres within its container". */
|
||||||
export type WidthPlan = { kind: "fixed" } | { kind: "auto" } | { kind: "percent"; pct: string } | { kind: "percentVp"; pctByVp: Record<number, string> } | { kind: "flexfill" } | { kind: "fill" } | { kind: "fillcap"; cap: string } | { kind: "basis"; px: string };
|
export type WidthPlan = { kind: "fixed" } | { kind: "auto" } | { kind: "percent"; pct: string } | { kind: "percentVp"; pctByVp: Record<number, string> } | { kind: "flexfill" } | { kind: "fill" } | { kind: "fillcap"; cap: string } | { kind: "basis"; px: string } | { kind: "basisFull" };
|
||||||
const PLAN_FIXED: WidthPlan = { kind: "fixed" };
|
const PLAN_FIXED: WidthPlan = { kind: "fixed" };
|
||||||
|
|
||||||
const pf = (v: string | undefined): number => { const n = parseFloat(v ?? ""); return Number.isFinite(n) ? n : 0; };
|
const pf = (v: string | undefined): number => { const n = parseFloat(v ?? ""); return Number.isFinite(n) ? n : 0; };
|
||||||
@@ -525,6 +530,35 @@ function gridAutoTrackMask(node: IRNode, vps: number[], count: number): boolean[
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fluidGridColumns(node: IRNode, viewports: number[]): Map<number, string> | null {
|
function fluidGridColumns(node: IRNode, viewports: number[]): Map<number, string> | null {
|
||||||
|
// A SINGLE fixed-px grid track that FILLS its container at every sampled viewport is a full-bleed
|
||||||
|
// fill column baked as `grid-cols-[Npx]` (a full-viewport hero carousel `uwp-carousel` with one
|
||||||
|
// track = the viewport width, re-baked per breakpoint). It freezes at the nearest band's px, so the
|
||||||
|
// grid — and everything it lays out — over-widens at any unsampled width (the splide02 hero measuring
|
||||||
|
// 768px inside a 572px window: the whole hero subtree inherits the frozen track). Re-express the lone
|
||||||
|
// track as `minmax(0,1fr)` (fills, reproduces the captured px exactly — gate-neutral). This is proven
|
||||||
|
// purely from the box geometry (track == content), so it is safe even on a REPLACED/custom-element
|
||||||
|
// grid, which the general solver below (it walks children) correctly still skips.
|
||||||
|
{
|
||||||
|
const filled: { vp: number; content: number }[] = [];
|
||||||
|
for (const vp of viewports) {
|
||||||
|
const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp];
|
||||||
|
if (!cs || !nb) continue;
|
||||||
|
if (!/^(grid|inline-grid)$/.test(cs.display || "")) continue;
|
||||||
|
const tracks = parseTracks(cs.gridTemplateColumns);
|
||||||
|
if (!tracks || tracks.length !== 1) { filled.length = 0; break; } // must be single-track at EVERY grid vp
|
||||||
|
const gap = pf(cs.columnGap && cs.columnGap !== "normal" ? cs.columnGap : cs.gap);
|
||||||
|
if (gap !== 0) { filled.length = 0; break; }
|
||||||
|
const content = nb.width - pf(cs.paddingLeft) - pf(cs.paddingRight) - pf(cs.borderLeftWidth) - pf(cs.borderRightWidth);
|
||||||
|
if (content <= 0) { filled.length = 0; break; }
|
||||||
|
if (Math.abs(tracks[0]! - content) > Math.max(1.5, 0.01 * content)) { filled.length = 0; break; } // track must FILL
|
||||||
|
filled.push({ vp, content });
|
||||||
|
}
|
||||||
|
if (filled.length >= 2) {
|
||||||
|
const m = new Map<number, string>();
|
||||||
|
for (const f of filled) m.set(f.vp, "minmax(0, 1fr)");
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (REPLACED.has(node.tag) || node.tag.includes("-")) return null;
|
if (REPLACED.has(node.tag) || node.tag.includes("-")) return null;
|
||||||
type S = { vp: number; tracks: number[]; gap: number; content: number };
|
type S = { vp: number; tracks: number[]; gap: number; content: number };
|
||||||
// Replace a solved template's fixed `Npx` tracks with `auto` where the column provably sizes to its
|
// Replace a solved template's fixed `Npx` tracks with `auto` where the column provably sizes to its
|
||||||
@@ -580,10 +614,17 @@ function fluidGridColumns(node: IRNode, viewports: number[]): Map<number, string
|
|||||||
for (const s of perVp) { const c = s.tracks.length; const g = byCount.get(c); if (g) g.push(s); else byCount.set(c, [s]); }
|
for (const s of perVp) { const c = s.tracks.length; const g = byCount.get(c); if (g) g.push(s); else byCount.set(c, [s]); }
|
||||||
const result = new Map<number, string>();
|
const result = new Map<number, string>();
|
||||||
for (const [count, samples] of byCount) {
|
for (const [count, samples] of byCount) {
|
||||||
// Every sample in the regime has ~equal tracks ⇒ a clean `repeat(N, 1fr)`. (1fr divides the
|
// Every sample in the regime has ~equal tracks ⇒ a candidate `repeat(N, 1fr)`. (1fr divides the
|
||||||
// container content evenly, reproducing the equal baked px at each captured width.)
|
// container content evenly, reproducing the equal baked px at each captured width.)
|
||||||
const allEqual = samples.every((s) => Math.max(...s.tracks) - Math.min(...s.tracks) <= Math.max(1.5, 0.02 * Math.max(...s.tracks)));
|
const allEqual = samples.every((s) => Math.max(...s.tracks) - Math.min(...s.tracks) <= Math.max(1.5, 0.02 * Math.max(...s.tracks)));
|
||||||
const tmpl = allEqual ? `repeat(${count}, minmax(0, 1fr))` : (samples.length >= 2 ? frTemplate(samples, count) : null);
|
// `repeat(N,1fr)` divides the CONTAINER content among the tracks — valid only when the tracks +
|
||||||
|
// gaps actually FILL the container (the same fill law frTemplate enforces before solving an fr
|
||||||
|
// model). A fixed-track scrolling list (`overflow-x:auto` with `grid-cols-[173.5px…]` × 50 summing
|
||||||
|
// to 8675px inside a 1066px container) has equal tracks too, but rewriting it as `repeat(50,1fr)`
|
||||||
|
// shrinks every slide to viewport/50 and kills the horizontal scroll. When the equal tracks
|
||||||
|
// OVERFLOW the container, keep the baked fixed-px template (fall through, leaving these vps unset).
|
||||||
|
const fillsContainer = samples.every((s) => Math.abs(s.tracks.reduce((a, b) => a + b, 0) + (count - 1) * s.gap - s.content) <= Math.max(2, 0.01 * s.content));
|
||||||
|
const tmpl = (allEqual && fillsContainer) ? `repeat(${count}, minmax(0, 1fr))` : (samples.length >= 2 ? frTemplate(samples, count) : null);
|
||||||
if (tmpl) { const t2 = withAuto(tmpl, samples); for (const s of samples) result.set(s.vp, t2); continue; }
|
if (tmpl) { const t2 = withAuto(tmpl, samples); for (const s of samples) result.set(s.vp, t2); continue; }
|
||||||
// Rescue a MIXED-track regime where frTemplate bailed only because the column PROPORTIONS
|
// Rescue a MIXED-track regime where frTemplate bailed only because the column PROPORTIONS
|
||||||
// change at a breakpoint — the ridge footer signup grid is 70/30 (`2.333fr 1fr`) at ≥768 but
|
// change at a breakpoint — the ridge footer signup grid is 70/30 (`2.333fr 1fr`) at ≥768 but
|
||||||
@@ -905,6 +946,90 @@ function isFlexFillItem(node: IRNode, parentNode: IRNode | undefined, viewports:
|
|||||||
return widths.length >= 2 && Math.max(...widths) - Math.min(...widths) > 8;
|
return widths.length >= 2 && Math.max(...widths) - Math.min(...widths) > 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A CIRCULAR-DEPENDENCY flex/grid item whose width has NO in-flow source and would collapse to 0.
|
||||||
|
*
|
||||||
|
* A Splide (and similar JS carousel) slide is a `shrink-0` flex item whose width is set only by the
|
||||||
|
* library's INJECTED inline `width:Npx`. The sizing probe therefore reads it as content/fill-sized
|
||||||
|
* (`wAuto`/`wFill` both true), so generation drops the width. But the slide's only in-flow children
|
||||||
|
* FILL it (`w-full h-full`, often `aspect-square`) — they take their width FROM the slide. With the
|
||||||
|
* slide's width dropped and every child filling, nothing establishes a definite width: the slide, the
|
||||||
|
* fill children, and the track all resolve to 0×0 (the "Explore Our Oven Range" carousel collapsing
|
||||||
|
* to a −640px hole).
|
||||||
|
*
|
||||||
|
* Detect exactly that circular case and pin the captured px so the fill children have a definite basis:
|
||||||
|
* - the node is an IN-FLOW flex or grid ITEM with `flex-shrink:0` (a carousel slide, never shrinks),
|
||||||
|
* - it has a definite positive captured width at every painted viewport,
|
||||||
|
* - it has NO in-flow width source: every in-flow ELEMENT child either FILLS it
|
||||||
|
* (probe `wFill && !wAuto` → width derives from the slide) or is absolutely/fixed positioned,
|
||||||
|
* and at least one such fill child exists (the thing that would collapse).
|
||||||
|
* Requires probe data (older captures return false → inert). Scoped hard so it fires only on the
|
||||||
|
* genuine circular collapse, never on a normal content-sized shrink-0 item. */
|
||||||
|
function isCircularShrinkSlide(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
|
||||||
|
if (!parentNode || REPLACED.has(node.tag) || node.tag.includes("-")) return false;
|
||||||
|
let painted = 0;
|
||||||
|
for (const vp of viewports) {
|
||||||
|
const cs = node.computedByVp[vp]; const pcs = parentNode.computedByVp[vp]; const nb = node.bboxByVp[vp];
|
||||||
|
if (!cs || !pcs || !nb) continue;
|
||||||
|
if (!node.visibleByVp[vp] || (cs.display || "") === "none") continue; // judge only where painted
|
||||||
|
if (!/^(flex|inline-flex|grid|inline-grid)$/.test(pcs.display || "")) return false; // parent must lay it out as a flex/grid item
|
||||||
|
const pos = cs.position || "static";
|
||||||
|
if (pos !== "static" && pos !== "relative") return false; // in-flow item only
|
||||||
|
if ((cs.float || "none") !== "none") return false;
|
||||||
|
if (pf(cs.flexShrink) !== 0) return false; // a slide is shrink-0 (never collapses to content)
|
||||||
|
if (!(nb.width > 0) || !(pf(cs.width) > 0)) return false; // need a definite captured px to pin
|
||||||
|
// No in-flow width source: every in-flow element child fills the slide (its width derives from the
|
||||||
|
// slide) or is out of flow; at least one fill child must exist (else nothing collapses).
|
||||||
|
let fillChildren = 0;
|
||||||
|
for (const c of node.children) {
|
||||||
|
if (isTextChild(c)) continue;
|
||||||
|
const ccs = c.computedByVp[vp]; const cb = c.bboxByVp[vp];
|
||||||
|
if (!ccs || !cb || !c.visibleByVp[vp] || (ccs.display || "") === "none") continue;
|
||||||
|
const cpos = ccs.position || "static";
|
||||||
|
if (cpos === "absolute" || cpos === "fixed") continue; // out of flow → no width contribution
|
||||||
|
const csz = c.sizingByVp?.[vp];
|
||||||
|
if (csz && csz.wFill === true && csz.wAuto === false) { fillChildren++; continue; } // fills the slide → derives width from it
|
||||||
|
return false; // a genuine in-flow width source → not circular
|
||||||
|
}
|
||||||
|
if (fillChildren === 0) return false;
|
||||||
|
painted++;
|
||||||
|
}
|
||||||
|
return painted >= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A FULL-WIDTH carousel slide: a `shrink-0` flex-ROW item whose border box FILLS its flex
|
||||||
|
* container's content width at every painted viewport (a full-viewport hero slide in a Splide list;
|
||||||
|
* the slides sit side by side and the track clips via overflow). The naive emission `width:100%` on
|
||||||
|
* a `shrink-0` flex item does NOT stay at 100%: flex sizes the item from its MAX-CONTENT (the
|
||||||
|
* shrink-0 item can't drop below it), and a full-bleed slide's max-content (its absolutely-positioned
|
||||||
|
* aspect-ratio media) freezes the whole list wider than the container at any unsampled width (the
|
||||||
|
* splide02 hero list measuring 768px inside a 572px window). Emitting `flex-basis:100%` gives the
|
||||||
|
* slide a DEFINITE main size = the container content width, so the flex row stays exactly one
|
||||||
|
* container wide at every width (gate-neutral at samples, correct in between). Distinct from
|
||||||
|
* isCircularShrinkSlide (a FIXED-width sub-container slide that pins its captured px). */
|
||||||
|
function isFullWidthShrinkSlide(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
|
||||||
|
if (!parentNode || REPLACED.has(node.tag) || node.tag.includes("-")) return false;
|
||||||
|
let painted = 0;
|
||||||
|
for (const vp of viewports) {
|
||||||
|
const cs = node.computedByVp[vp]; const pcs = parentNode.computedByVp[vp];
|
||||||
|
const nb = node.bboxByVp[vp]; const pb = parentNode.bboxByVp[vp];
|
||||||
|
if (!cs || !pcs || !nb || !pb) continue;
|
||||||
|
if (!node.visibleByVp[vp] || (cs.display || "") === "none") continue;
|
||||||
|
if (!/^(flex|inline-flex)$/.test(pcs.display || "")) return false; // flex parent
|
||||||
|
const dir = pcs.flexDirection || "row";
|
||||||
|
if (dir !== "row" && dir !== "row-reverse") return false; // width is the main axis
|
||||||
|
const pos = cs.position || "static";
|
||||||
|
if (pos !== "static" && pos !== "relative") return false;
|
||||||
|
if ((cs.float || "none") !== "none") return false;
|
||||||
|
if (pf(cs.flexShrink) !== 0) return false; // a slide is shrink-0
|
||||||
|
if (pf(cs.marginLeft) !== 0 || pf(cs.marginRight) !== 0) return false; // margins make the width load-bearing
|
||||||
|
if ((cs.boxSizing || "border-box") === "content-box") return false; // basis:100% would overflow by padding
|
||||||
|
const content = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
|
||||||
|
if (Math.abs(nb.width - content) > 1.5) return false; // must actually FILL the flex container
|
||||||
|
painted++;
|
||||||
|
}
|
||||||
|
return painted >= 1;
|
||||||
|
}
|
||||||
|
|
||||||
/** A flex/grid ITEM whose border box fills its container's content width at every viewport —
|
/** A flex/grid ITEM whose border box fills its container's content width at every viewport —
|
||||||
* cross-axis stretch in a column, a sole/spanning item in a row, a full-span grid cell. We were
|
* cross-axis stretch in a column, a sole/spanning item in a row, a full-span grid cell. We were
|
||||||
* baking the resolved fill width per viewport, which freezes it AND (the px differs per width)
|
* baking the resolved fill width per viewport, which freezes it AND (the px differs per width)
|
||||||
@@ -1867,6 +1992,12 @@ function heightFlows(node: IRNode, parentNode: IRNode | undefined, viewports: nu
|
|||||||
// child's trailing margin-bottom, because whether that margin extends the box (height includes
|
// child's trailing margin-bottom, because whether that margin extends the box (height includes
|
||||||
// it) or collapses through depends on the box; we accept the box if its height matches EITHER.
|
// it) or collapses through depends on the box; we accept the box if its height matches EITHER.
|
||||||
let bottom = nb.y + pf(cs.paddingTop) + pf(cs.borderTopWidth); let bottomMargin = bottom; let hasChild = false;
|
let bottom = nb.y + pf(cs.paddingTop) + pf(cs.borderTopWidth); let bottomMargin = bottom; let hasChild = false;
|
||||||
|
// Are ALL in-flow element children FILL children (height:100% / probe hFill) whose height DERIVES
|
||||||
|
// from THIS box? Then their extent reaching the box bottom is not content evidence — it is the
|
||||||
|
// box's own authored height reflected back (a `h-full aspect-video` hero image inside a fixed-height
|
||||||
|
// header). Dropping the height then lets the fill child re-derive from its aspect ratio and inflate
|
||||||
|
// (a 240px hero → 720px @16/9). Do NOT flow such a box's height.
|
||||||
|
let allInflowFill = true;
|
||||||
for (const c of node.children) {
|
for (const c of node.children) {
|
||||||
if (isTextChild(c)) continue;
|
if (isTextChild(c)) continue;
|
||||||
const ccs = c.computedByVp[vp]; const cb = c.bboxByVp[vp];
|
const ccs = c.computedByVp[vp]; const cb = c.bboxByVp[vp];
|
||||||
@@ -1874,9 +2005,15 @@ function heightFlows(node: IRNode, parentNode: IRNode | undefined, viewports: nu
|
|||||||
if (ccs.position === "absolute" || ccs.position === "fixed") continue;
|
if (ccs.position === "absolute" || ccs.position === "fixed") continue;
|
||||||
if ((ccs.float || "none") !== "none") continue;
|
if ((ccs.float || "none") !== "none") continue;
|
||||||
hasChild = true;
|
hasChild = true;
|
||||||
|
const csz = c.sizingByVp?.[vp];
|
||||||
|
if (!(csz && csz.hFill === true && csz.hAuto === false)) allInflowFill = false;
|
||||||
bottom = Math.max(bottom, cb.y + cb.height);
|
bottom = Math.max(bottom, cb.y + cb.height);
|
||||||
bottomMargin = Math.max(bottomMargin, cb.y + cb.height + Math.max(0, pf(ccs.marginBottom)));
|
bottomMargin = Math.max(bottomMargin, cb.y + cb.height + Math.max(0, pf(ccs.marginBottom)));
|
||||||
}
|
}
|
||||||
|
// Authored height whose only in-flow children fill it (circular): keep the height (don't flow).
|
||||||
|
// Guarded by the parent's own probe: hAuto===false means auto did NOT reproduce the box → real
|
||||||
|
// authored height, not a content coincidence.
|
||||||
|
if (hasChild && allInflowFill && node.sizingByVp?.[vp]?.hAuto === false) return false;
|
||||||
const pad = pf(cs.paddingBottom) + pf(cs.borderBottomWidth);
|
const pad = pf(cs.paddingBottom) + pf(cs.borderBottomWidth);
|
||||||
const contentH = bottom - nb.y + pad; const contentHMargin = bottomMargin - nb.y + pad;
|
const contentH = bottom - nb.y + pad; const contentHMargin = bottomMargin - nb.y + pad;
|
||||||
// Content-driven when the box is exactly its in-flow children's extent — with OR without their
|
// Content-driven when the box is exactly its in-flow children's extent — with OR without their
|
||||||
@@ -2092,6 +2229,18 @@ function declsForViewport(
|
|||||||
const ov = cs.overflowY || cs.overflow || "visible";
|
const ov = cs.overflowY || cs.overflow || "visible";
|
||||||
const parentDisp = parentComputed?.display || "";
|
const parentDisp = parentComputed?.display || "";
|
||||||
const isFlexGridItem = /flex|grid/.test(parentDisp);
|
const isFlexGridItem = /flex|grid/.test(parentDisp);
|
||||||
|
// A chip in a horizontally-scrollable flex strip (an overflow-x:auto/scroll flex parent) relies on
|
||||||
|
// the CSS default `min-width:auto` to stay at its content width so the strip scrolls. The base
|
||||||
|
// viewport can report `min-width:0px` on such an item (e.g. a mobile-only strip collapsed to 0 at
|
||||||
|
// desktop), which would emit `min-w-0` and let the item shrink below its content — collapsing the
|
||||||
|
// strip and colliding its nowrap text. Suppress `min-w-0` for a nowrap flex item whose flex parent
|
||||||
|
// scrolls horizontally. This is scoped away from legitimate `min-w-0` truncation (overflow:hidden +
|
||||||
|
// ellipsis on the item itself, whose parent does NOT scroll-x), so it is safe.
|
||||||
|
const parentOverflowX = parentComputed ? (parentComputed.overflowX || parentComputed.overflow || "visible") : "visible";
|
||||||
|
const inScrollXFlexStrip =
|
||||||
|
/flex/.test(parentDisp) &&
|
||||||
|
/^(auto|scroll)$/.test(parentOverflowX) &&
|
||||||
|
(cs.whiteSpace || "normal") === "nowrap";
|
||||||
const isLeaf = !hasElementChild(node);
|
const isLeaf = !hasElementChild(node);
|
||||||
const hasText = node.children.some((c) => isTextChild(c) && c.text.trim() !== "");
|
const hasText = node.children.some((c) => isTextChild(c) && c.text.trim() !== "");
|
||||||
const isTextLeaf = isLeaf && hasText && !REPLACED.has(tag) && tag !== "canvas" && !tag.includes("-");
|
const isTextLeaf = isLeaf && hasText && !REPLACED.has(tag) && tag !== "canvas" && !tag.includes("-");
|
||||||
@@ -2110,6 +2259,9 @@ function declsForViewport(
|
|||||||
// The band-delta machinery collapses equal neighbours, so equal regimes emit a single rule.
|
// The band-delta machinery collapses equal neighbours, so equal regimes emit a single rule.
|
||||||
else if (widthPlan.kind === "percentVp") { const p = widthPlan.pctByVp[vp]; if (p) out.set("width", p); }
|
else if (widthPlan.kind === "percentVp") { const p = widthPlan.pctByVp[vp]; if (p) out.set("width", p); }
|
||||||
else if (widthPlan.kind === "flexfill") { out.set("flex-grow", "1"); out.set("flex-basis", "0%"); }
|
else if (widthPlan.kind === "flexfill") { out.set("flex-grow", "1"); out.set("flex-basis", "0%"); }
|
||||||
|
// A full-width shrink-0 carousel slide: definite main size = container content width, so the flex
|
||||||
|
// row stays exactly one container wide (no max-content over-widening). Keeps flex-shrink:0 (source).
|
||||||
|
else if (widthPlan.kind === "basisFull") { out.set("flex-basis", "100%"); out.set("flex-shrink", "0"); }
|
||||||
// A flex-line item's RECOVERED natural width (its flex base size), emitted as a constant at
|
// A flex-line item's RECOVERED natural width (its flex base size), emitted as a constant at
|
||||||
// every viewport so the captured per-viewport px collapse to one value — flex-grow/shrink then
|
// every viewport so the captured per-viewport px collapse to one value — flex-grow/shrink then
|
||||||
// re-derives the resolved widths. Verified to reproduce every sample before being chosen.
|
// re-derives the resolved widths. Verified to reproduce every sample before being chosen.
|
||||||
@@ -2147,6 +2299,11 @@ function declsForViewport(
|
|||||||
let contentBottom = top; // includes trailing margins (for taller detection)
|
let contentBottom = top; // includes trailing margins (for taller detection)
|
||||||
let borderBottom = top; // border-box extent only (for overflow detection)
|
let borderBottom = top; // border-box extent only (for overflow detection)
|
||||||
let inflowCount = 0;
|
let inflowCount = 0;
|
||||||
|
// Are ALL the in-flow element children fill children whose height DERIVES from this
|
||||||
|
// node (height:100% / probe hFill)? If so their measured extent equals this box's
|
||||||
|
// height only BECAUSE they fill it — it is not content-derived evidence, so it must
|
||||||
|
// not be allowed to "explain away" an authored height below.
|
||||||
|
let allInflowFill = true;
|
||||||
for (const c of node.children) {
|
for (const c of node.children) {
|
||||||
if (isTextChild(c)) continue;
|
if (isTextChild(c)) continue;
|
||||||
const ccs = c.computedByVp[vp]; const cb = c.bboxByVp[vp];
|
const ccs = c.computedByVp[vp]; const cb = c.bboxByVp[vp];
|
||||||
@@ -2157,6 +2314,10 @@ function declsForViewport(
|
|||||||
if (ccs.position === "absolute" || ccs.position === "fixed") continue;
|
if (ccs.position === "absolute" || ccs.position === "fixed") continue;
|
||||||
if ((ccs.float || "none") !== "none") continue;
|
if ((ccs.float || "none") !== "none") continue;
|
||||||
inflowCount++;
|
inflowCount++;
|
||||||
|
const csz = c.sizingByVp?.[vp];
|
||||||
|
// A fill child: the probe measured height:100% reproduces AND auto does not (hFill && !hAuto).
|
||||||
|
// No probe data (older captures) ⇒ treat as real content (conservative: leave allInflowFill off).
|
||||||
|
if (!(csz && csz.hFill === true && csz.hAuto === false)) allInflowFill = false;
|
||||||
contentBottom = Math.max(contentBottom, cb.y + cb.height + (parseFloat(ccs.marginBottom || "0") || 0));
|
contentBottom = Math.max(contentBottom, cb.y + cb.height + (parseFloat(ccs.marginBottom || "0") || 0));
|
||||||
borderBottom = Math.max(borderBottom, cb.y + cb.height);
|
borderBottom = Math.max(borderBottom, cb.y + cb.height);
|
||||||
}
|
}
|
||||||
@@ -2164,6 +2325,13 @@ function declsForViewport(
|
|||||||
const contentHeight = contentBottom - nb.y + padBottom;
|
const contentHeight = contentBottom - nb.y + padBottom;
|
||||||
if (nb.height > contentHeight + 2) explicitHeight = true;
|
if (nb.height > contentHeight + 2) explicitHeight = true;
|
||||||
if (inflowCount === 0 && nb.height > 2) explicitHeight = true;
|
if (inflowCount === 0 && nb.height > 2) explicitHeight = true;
|
||||||
|
// Authored height whose only in-flow children FILL it (height:100%). The child extent measured
|
||||||
|
// the parent's own height back at it (circular), so the "taller than content" test above can never
|
||||||
|
// fire — yet dropping the height lets each fill child re-derive its height from content/aspect
|
||||||
|
// (a `h-full aspect-video` child then resolves 16/9 of its width, inflating a 240px hero to 720px).
|
||||||
|
// Keep the authored height so the fill chain has a definite basis. Probe hFill (not `!hAuto` on the
|
||||||
|
// parent) is the guard: hAuto:false on the parent means auto did NOT reproduce, i.e. real authored.
|
||||||
|
if (inflowCount > 0 && allInflowFill && node.sizingByVp?.[vp]?.hAuto === false && nb.height > 2) explicitHeight = true;
|
||||||
// Overflow case: the box is meaningfully SHORTER than its in-flow content's
|
// Overflow case: the box is meaningfully SHORTER than its in-flow content's
|
||||||
// border boxes. Content cannot shrink a box below its own size, so the height
|
// border boxes. Content cannot shrink a box below its own size, so the height
|
||||||
// is authored (e.g. html,body{height:100%} with overflowing content). Compare
|
// is authored (e.g. html,body{height:100%} with overflowing content). Compare
|
||||||
@@ -2261,7 +2429,16 @@ function declsForViewport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasTransform = cs.transform && cs.transform !== "none";
|
const hasTransform = cs.transform && cs.transform !== "none";
|
||||||
const hasAnimation = cs.animationName && cs.animationName !== "none";
|
// A scroll/view-timeline-driven animation reports its resolved `animation-duration` as
|
||||||
|
// `auto` (the duration is derived from the timeline range, not a time). The clone has no
|
||||||
|
// scroll timeline, so emitting such an animation makes it jump straight to its END keyframe
|
||||||
|
// (`fill-mode:both` + a 0s time-based duration), freezing the element at the fully-progressed
|
||||||
|
// state (e.g. a scroll-linked text-fill stuck 100% filled). We do NOT replay scroll-linked
|
||||||
|
// animations; the goal is the correct AT-REST render. So suppress the animation-* props for
|
||||||
|
// an animation whose duration is `auto`, and let the captured static properties (now recorded
|
||||||
|
// at-rest, scroll reset + timeline animations canceled before the snapshot) stand.
|
||||||
|
const isScrollTimelineAnim = (cs.animationDuration || "").split(",").some((d) => d.trim() === "auto");
|
||||||
|
const hasAnimation = cs.animationName && cs.animationName !== "none" && !isScrollTimelineAnim;
|
||||||
|
|
||||||
for (const { prop, def } of GENERIC) {
|
for (const { prop, def } of GENERIC) {
|
||||||
const value = cs[prop];
|
const value = cs[prop];
|
||||||
@@ -2297,7 +2474,7 @@ function declsForViewport(
|
|||||||
if (!/^(ul|ol|li|menu)$/.test(tag)) continue;
|
if (!/^(ul|ol|li|menu)$/.test(tag)) continue;
|
||||||
// list reset is none; emit whatever the source uses (incl. none).
|
// list reset is none; emit whatever the source uses (incl. none).
|
||||||
} else if (prop === "minWidth") {
|
} else if (prop === "minWidth") {
|
||||||
if (value === "auto" || (value === "0px" && !isFlexGridItem)) continue;
|
if (value === "auto" || (value === "0px" && !isFlexGridItem) || (value === "0px" && inScrollXFlexStrip)) continue;
|
||||||
} else if (def === "__never__") {
|
} else if (def === "__never__") {
|
||||||
// always emit (display/color/fontFamily/fontSize handled below for inherit)
|
// always emit (display/color/fontFamily/fontSize handled below for inherit)
|
||||||
} else if (isDefault(def, value)) {
|
} else if (isDefault(def, value)) {
|
||||||
@@ -2305,7 +2482,14 @@ function declsForViewport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Inherited: skip when equal to parent's value (inheritance handles it).
|
// Inherited: skip when equal to parent's value (inheritance handles it).
|
||||||
if (INHERITED.has(prop) && parentComputed && parentComputed[prop] === value) continue;
|
// Exception: the reset (`ul, ol, menu { list-style: none; }`) breaks the list-marker
|
||||||
|
// inheritance chain on those tags, so parent-equality is not a safe elision there —
|
||||||
|
// a source <ul> with `disc` equals its parent's initial `disc` yet must still emit
|
||||||
|
// or the reset erases the markers. <li> inherits from the ul, which now emits.
|
||||||
|
if (INHERITED.has(prop) && parentComputed && parentComputed[prop] === value) {
|
||||||
|
const listMarkerReset = (prop === "listStyleType" || prop === "listStylePosition") && /^(ul|ol|menu)$/.test(tag);
|
||||||
|
if (!listMarkerReset) continue;
|
||||||
|
}
|
||||||
|
|
||||||
let outValue = value;
|
let outValue = value;
|
||||||
if (prop === "backgroundImage" || prop === "maskImage" || prop === "filter" || prop === "clipPath") {
|
if (prop === "backgroundImage" || prop === "maskImage" || prop === "filter" || prop === "clipPath") {
|
||||||
@@ -2586,7 +2770,41 @@ function pseudoDecls(style: StyleMap, assetMap: Map<string, string>): Map<string
|
|||||||
// the grouped class produces identical computed styles (fidelity-neutral).
|
// the grouped class produces identical computed styles (fidelity-neutral).
|
||||||
export type BandRule = { media: string; decls: Map<string, string> };
|
export type BandRule = { media: string; decls: Map<string, string> };
|
||||||
export type PseudoRule = { base: Map<string, string>; bands: BandRule[] };
|
export type PseudoRule = { base: Map<string, string>; bands: BandRule[] };
|
||||||
export type NodeRule = { base: Map<string, string>; bands: BandRule[]; before?: PseudoRule; after?: PseudoRule };
|
export type NodeRule = { base: Map<string, string>; bands: BandRule[]; before?: PseudoRule; after?: PseudoRule; placeholder?: PseudoRule };
|
||||||
|
|
||||||
|
/** ::placeholder declarations for a form control. Color always emits (the UA default is
|
||||||
|
* its own gray, NOT inherited from the input, so equality with the host proves nothing);
|
||||||
|
* font/spacing props DO inherit from the input inside the pseudo, so they emit only when
|
||||||
|
* they differ from the host's own computed value. */
|
||||||
|
function placeholderDecls(style: StyleMap, host: StyleMap | undefined): Map<string, string> {
|
||||||
|
const decls = new Map<string, string>();
|
||||||
|
if (style.color) decls.set("color", style.color);
|
||||||
|
if (style.opacity && style.opacity !== "1") decls.set("opacity", style.opacity);
|
||||||
|
for (const prop of ["fontFamily", "fontSize", "fontWeight", "fontStyle", "letterSpacing", "textTransform"]) {
|
||||||
|
const v = style[prop];
|
||||||
|
if (!v || (host && host[prop] === v)) continue;
|
||||||
|
decls.set(kebab(prop), v);
|
||||||
|
}
|
||||||
|
return decls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Banded ::placeholder rule (mirrors collectPseudoRule's base+delta shape). */
|
||||||
|
function collectPlaceholderRule(styleByVp: Record<number, StyleMap>, hostByVp: Record<number, StyleMap>, baseVp: number, bands: Band[], tokenResolver?: TokenResolver): PseudoRule | undefined {
|
||||||
|
const baseStyle = styleByVp[baseVp] ?? Object.values(styleByVp)[0];
|
||||||
|
if (!baseStyle) return undefined;
|
||||||
|
const out: PseudoRule = { base: finalizeDecls(placeholderDecls(baseStyle, hostByVp[baseVp]), tokenResolver), bands: [] };
|
||||||
|
for (const b of bands) {
|
||||||
|
if (!b.media) continue;
|
||||||
|
const st = styleByVp[b.vp];
|
||||||
|
if (!st) continue; // control not rendered at this width — the host rule already hides it
|
||||||
|
const vpDecls = finalizeDecls(placeholderDecls(st, hostByVp[b.vp]), tokenResolver);
|
||||||
|
const delta = new Map<string, string>();
|
||||||
|
for (const [k, v] of vpDecls) if (out.base.get(k) !== v) delta.set(k, v);
|
||||||
|
for (const [k] of out.base) if (!vpDecls.has(k)) delta.set(k, resetValue(k));
|
||||||
|
if (delta.size > 0) out.bands.push({ media: b.media, decls: delta });
|
||||||
|
}
|
||||||
|
return out.base.size > 0 ? out : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** Collect a pseudo-element's banded rule (its size/position can be responsive —
|
/** Collect a pseudo-element's banded rule (its size/position can be responsive —
|
||||||
* e.g. flex spacer pseudo-elements in horizontal scrollers). `hostContentWidthByVp`
|
* e.g. flex spacer pseudo-elements in horizontal scrollers). `hostContentWidthByVp`
|
||||||
@@ -2804,7 +3022,15 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
|
|||||||
// track. A `flex gap w-1/3` equal-fill row item: re-express as flex:1 1 0 so it scales.
|
// track. A `flex gap w-1/3` equal-fill row item: re-express as flex:1 1 0 so it scales.
|
||||||
const gridFill = isGridItemFill(node, parentNode, sampleVps);
|
const gridFill = isGridItemFill(node, parentNode, sampleVps);
|
||||||
const flexFill = !gridFill && isFlexFillItem(node, parentNode, sampleVps);
|
const flexFill = !gridFill && isFlexFillItem(node, parentNode, sampleVps);
|
||||||
const fillsContainer = !gridFill && !flexFill && isFillsContainerWidth(node, parentNode, sampleVps);
|
// A carousel slide (`shrink-0` flex/grid item) whose width was set only by the library's injected
|
||||||
|
// inline px and whose children all FILL it → the probe reads it as fill/content and drops the
|
||||||
|
// width, collapsing the slide + its fill children + the track to 0×0. Pin the captured px so the
|
||||||
|
// fill chain has a definite basis. Wins over the probe/fill detectors below (it IS the width source).
|
||||||
|
const circularSlide = !isContents && !gridFill && !flexFill && isCircularShrinkSlide(node, parentNode, sampleVps);
|
||||||
|
// A full-VIEWPORT-width shrink-0 carousel slide → flex-basis:100% (definite main size) instead of
|
||||||
|
// width:100%, so the flex row can't over-widen from the slide's max-content (the splide02 hero).
|
||||||
|
const fullWidthSlide = !isContents && !gridFill && !flexFill && !circularSlide && isFullWidthShrinkSlide(node, parentNode, sampleVps);
|
||||||
|
const fillsContainer = !gridFill && !flexFill && !circularSlide && !fullWidthSlide && isFillsContainerWidth(node, parentNode, sampleVps);
|
||||||
const replacedFill = replacedFillsContainer(node, parentNode, sampleVps); // an img/video filling its cell → w-full
|
const replacedFill = replacedFillsContainer(node, parentNode, sampleVps); // an img/video filling its cell → w-full
|
||||||
const sourceFill = sourceWidthFillIntent(node);
|
const sourceFill = sourceWidthFillIntent(node);
|
||||||
// A full-bleed banner image (width ≈ viewport, grows) baked to per-vp px → w-full so it keeps
|
// A full-bleed banner image (width ≈ viewport, grows) baked to per-vp px → w-full so it keeps
|
||||||
@@ -2818,6 +3044,18 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
|
|||||||
const blockFill = !gridFill && !flexFill && !fillsContainer && !maxWidthFill && fillsBlockContainer(node, parentNode, sampleVps);
|
const blockFill = !gridFill && !flexFill && !fillsContainer && !maxWidthFill && fillsBlockContainer(node, parentNode, sampleVps);
|
||||||
// An absolute box pinned by both left+right insets → drop width (auto stretches between them).
|
// An absolute box pinned by both left+right insets → drop width (auto stretches between them).
|
||||||
const insetSpanned = !isContents && insetSpannedAbsolute(node, parentNode, sampleVps);
|
const insetSpanned = !isContents && insetSpannedAbsolute(node, parentNode, sampleVps);
|
||||||
|
// If that inset-spanned box ALSO carries an authored aspect-ratio (a full-bleed hero slide:
|
||||||
|
// `absolute inset-x-0 aspect-video min-h-[…]`), `width:auto` does NOT stay pinned to the insets:
|
||||||
|
// with a definite height (min-height) and a definite aspect-ratio, the width BACK-COMPUTES from
|
||||||
|
// height × aspect and over-widens at any width the capture didn't sample (the splide02 hero
|
||||||
|
// measuring 768/641px wide inside a 572px window — the responsive full-bleed violations). Emit an
|
||||||
|
// explicit `width:100%` instead: it fills the containing block (identical to the inset-0 span at
|
||||||
|
// every sampled width, gate-neutral) and, being definite, WINS the width axis so the aspect-ratio
|
||||||
|
// drives only the height. Scoped to inset-spanned boxes that actually have an aspect-ratio.
|
||||||
|
const insetSpannedAspect = insetSpanned && sampleVps.some((vp) => {
|
||||||
|
const ar = node.computedByVp[vp]?.aspectRatio;
|
||||||
|
return !!ar && ar !== "auto" && node.visibleByVp[vp];
|
||||||
|
});
|
||||||
// Combine: the full-bleed chain wins (it proved the box spans the viewport), then grid/flex
|
// Combine: the full-bleed chain wins (it proved the box spans the viewport), then grid/flex
|
||||||
// fill, then "fills its container" / "fills under a max-width cap" (→ width:100%), then the
|
// fill, then "fills its container" / "fills under a max-width cap" (→ width:100%), then the
|
||||||
// inferred generalisations.
|
// inferred generalisations.
|
||||||
@@ -2845,6 +3083,9 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
|
|||||||
const widthPlan: WidthPlan =
|
const widthPlan: WidthPlan =
|
||||||
fillCap ? { kind: "fillcap", cap: fillCap }
|
fillCap ? { kind: "fillcap", cap: fillCap }
|
||||||
: sourceFixedSize ? inferred.plan
|
: sourceFixedSize ? inferred.plan
|
||||||
|
: circularSlide ? { kind: "fixed" }
|
||||||
|
: fullWidthSlide ? { kind: "basisFull" }
|
||||||
|
: insetSpannedAspect ? { kind: "fill" }
|
||||||
: probe === "auto" ? { kind: "auto" }
|
: probe === "auto" ? { kind: "auto" }
|
||||||
: probe === "fill" ? { kind: "fill" }
|
: probe === "fill" ? { kind: "fill" }
|
||||||
: (!lockWidth && autoWidthFlex.has(node.id)) ? { kind: "auto" }
|
: (!lockWidth && autoWidthFlex.has(node.id)) ? { kind: "auto" }
|
||||||
@@ -2940,24 +3181,68 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
|
|||||||
// Node was not in the observed DOM at this width (responsive conditional
|
// Node was not in the observed DOM at this width (responsive conditional
|
||||||
// rendering): hide it so the clone matches the source at this viewport.
|
// rendering): hide it so the clone matches the source at this viewport.
|
||||||
if (!node.computedByVp[b.vp]) { nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) }); continue; }
|
if (!node.computedByVp[b.vp]) { nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) }); continue; }
|
||||||
// The node isn't PAINTED at this width — either it goes `display:none` itself, or an ancestor
|
// The node isn't PAINTED at this width. HOW it is hidden decides what to emit, because only
|
||||||
// hides the whole subtree. Its box renders nothing, so any width/height/inset/padding override
|
// `display:none` takes the box out of layout — a `visibility:hidden` box still occupies space
|
||||||
// here is invisible: pure breakpoint noise. Emit ONLY the hide, and only when the node ITSELF
|
// and can extend the scrollable area. The base rule bakes CANONICAL geometry unconditionally,
|
||||||
// is the one turning off (was shown at base); if an ancestor hides it, emit nothing at all —
|
// so skipping every override here can park e.g. a desktop `left:548px` slider arrow inside a
|
||||||
// that ancestor's own `display:none` already removes this node with it.
|
// 375px viewport: invisible, but +210px of sideways scroll.
|
||||||
const ownNone = (node.computedByVp[b.vp]?.display || "") === "none";
|
const vpCs = node.computedByVp[b.vp]!;
|
||||||
if (ownNone || !node.visibleByVp[b.vp]) {
|
const ownNone = (vpCs.display || "") === "none";
|
||||||
|
// The node ITSELF is visibility:hidden here (its parent isn't → not inherited from an
|
||||||
|
// ancestor whose own rule already carries the hide).
|
||||||
|
const ownHidden = !ownNone && /^(hidden|collapse)$/.test(vpCs.visibility || "") &&
|
||||||
|
!/^(hidden|collapse)$/.test(parentNode?.computedByVp[b.vp]?.visibility || "");
|
||||||
|
if (ownHidden) {
|
||||||
|
const bb = node.bboxByVp[b.vp];
|
||||||
|
if (!bb || bb.width <= 0 || bb.height <= 0) {
|
||||||
|
// The hidden box occupied NOTHING in the capture (0×0 — e.g. an uninitialised swiper
|
||||||
|
// arrow collapsed by its container). `display:none` reproduces "invisible, takes no
|
||||||
|
// space" exactly and guarantees it cannot extend scroll bounds at this width.
|
||||||
|
nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Non-zero bbox: the invisible box occupies real layout space. Fall through to the normal
|
||||||
|
// per-viewport delta so it sits where the capture measured it at THIS width — not at the
|
||||||
|
// canonical geometry the base rule baked. The hide itself rides along in the delta
|
||||||
|
// (declsForViewport emits `visibility:hidden`; parent-equality keeps it own-only).
|
||||||
|
} else if (ownNone || !node.visibleByVp[b.vp]) {
|
||||||
const shownAtBase = node.visibleByVp[baseVp] && (node.computedByVp[baseVp]?.display || "") !== "none";
|
const shownAtBase = node.visibleByVp[baseVp] && (node.computedByVp[baseVp]?.display || "") !== "none";
|
||||||
if (ownNone && shownAtBase) nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) });
|
// Hidden by an ANCESTOR's visibility here AND at base (the ancestor's own rule carries
|
||||||
else if (shownAtBase) {
|
// the hide at every width) — but a visibility:hidden box still PARTICIPATES in layout,
|
||||||
const vpCs = node.computedByVp[b.vp];
|
// and the base rule bakes CANONICAL geometry. Same policy as the own-hidden path above:
|
||||||
|
// a 0x0 box gets display:none; an occupying box falls through to the per-viewport delta
|
||||||
|
// so it sits where the capture measured it at THIS width — not parked at e.g. a desktop
|
||||||
|
// left:548px inside a 375px viewport (the cropin.com/cotton slider arrow, +210px of
|
||||||
|
// sideways scroll at 375 with the hide inherited from an elementor-widget ancestor).
|
||||||
|
const ancestorHiddenHere = !ownNone && /^(hidden|collapse)$/.test(vpCs.visibility || "");
|
||||||
|
if (ancestorHiddenHere && !shownAtBase) {
|
||||||
|
const bb = node.bboxByVp[b.vp];
|
||||||
|
if (!bb || bb.width <= 0 || bb.height <= 0) {
|
||||||
|
nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Occupying: fall through to the normal per-viewport delta below.
|
||||||
|
} else {
|
||||||
|
if (ownNone) {
|
||||||
|
// Own display:none removes the box from layout entirely. Emit the hide even when the node
|
||||||
|
// is ALSO hidden at base — a visibility:hidden base still bakes an OCCUPYING box (see
|
||||||
|
// above), so without this band the canonical geometry would render at this width. Skip
|
||||||
|
// only when the base itself is display:none (the band would be redundant).
|
||||||
|
if ((node.computedByVp[baseVp]?.display || "") !== "none") {
|
||||||
|
nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) });
|
||||||
|
}
|
||||||
|
} else if (shownAtBase) {
|
||||||
|
// Hidden by an ancestor (or zero-size / opacity:0) but visible at base: geometry
|
||||||
|
// overrides are breakpoint noise — the ancestor's own hide (or the reveal replay,
|
||||||
|
// for scroll-reveal opacity) covers it. Emit only the hide the node itself carries.
|
||||||
const hide = new Map<string, string>();
|
const hide = new Map<string, string>();
|
||||||
if (vpCs && pf(vpCs.opacity) === 0 && !animOwned.has("opacity")) hide.set("opacity", "0");
|
if (pf(vpCs.opacity) === 0 && !animOwned.has("opacity")) hide.set("opacity", "0");
|
||||||
if (vpCs && /^(hidden|collapse)$/.test(vpCs.visibility || "")) hide.set("visibility", "hidden");
|
if (/^(hidden|collapse)$/.test(vpCs.visibility || "")) hide.set("visibility", "hidden");
|
||||||
if (hide.size) nr.bands.push({ media: b.media, decls: hide });
|
if (hide.size) nr.bands.push({ media: b.media, decls: hide });
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const centeredVp = stableCenter || (layoutParent ? centeredAtVp(node, layoutParent, b.vp) : false);
|
const centeredVp = stableCenter || (layoutParent ? centeredAtVp(node, layoutParent, b.vp) : false);
|
||||||
const vpDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[b.vp], b.vp, assetMap, centeredVp, colorVar, ir.doc.perViewport[b.vp]?.scrollHeight, widthPlan, gridColsByVp?.get(b.vp), gridRowsByVp?.get(b.vp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth), tokenResolver);
|
const vpDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[b.vp], b.vp, assetMap, centeredVp, colorVar, ir.doc.perViewport[b.vp]?.scrollHeight, widthPlan, gridColsByVp?.get(b.vp), gridRowsByVp?.get(b.vp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth), tokenResolver);
|
||||||
const delta = new Map<string, string>();
|
const delta = new Map<string, string>();
|
||||||
@@ -2997,6 +3282,9 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
|
|||||||
nr.after = collectPseudoRule(node.afterByVp, baseVp, bands, assetMap, tokenResolver, hostCW, padW, padH);
|
nr.after = collectPseudoRule(node.afterByVp, baseVp, bands, assetMap, tokenResolver, hostCW, padW, padH);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (node.placeholderByVp) {
|
||||||
|
nr.placeholder = collectPlaceholderRule(node.placeholderByVp, node.computedByVp, baseVp, bands, tokenResolver);
|
||||||
|
}
|
||||||
rules.set(node.id, nr);
|
rules.set(node.id, nr);
|
||||||
|
|
||||||
for (const c of node.children) if (!isTextChild(c)) walk(c, node, childFluid, childLayoutParent, childCb, childDefiniteHeight);
|
for (const c of node.children) if (!isTextChild(c)) walk(c, node, childFluid, childLayoutParent, childCb, childDefiniteHeight);
|
||||||
@@ -3026,9 +3314,11 @@ export function assembleCss(
|
|||||||
if (nr.base.size > 0) baseRules.push(formatRule(sel, nr.base));
|
if (nr.base.size > 0) baseRules.push(formatRule(sel, nr.base));
|
||||||
if (nr.before) baseRules.push(formatRule(`${sel}::before`, nr.before.base));
|
if (nr.before) baseRules.push(formatRule(`${sel}::before`, nr.before.base));
|
||||||
if (nr.after) baseRules.push(formatRule(`${sel}::after`, nr.after.base));
|
if (nr.after) baseRules.push(formatRule(`${sel}::after`, nr.after.base));
|
||||||
|
if (nr.placeholder) baseRules.push(formatRule(`${sel}::placeholder`, nr.placeholder.base));
|
||||||
for (const b of nr.bands) bandRules.get(b.media)?.push(formatRule(sel, b.decls));
|
for (const b of nr.bands) bandRules.get(b.media)?.push(formatRule(sel, b.decls));
|
||||||
if (nr.before) for (const b of nr.before.bands) bandRules.get(b.media)?.push(formatRule(`${sel}::before`, b.decls));
|
if (nr.before) for (const b of nr.before.bands) bandRules.get(b.media)?.push(formatRule(`${sel}::before`, b.decls));
|
||||||
if (nr.after) for (const b of nr.after.bands) bandRules.get(b.media)?.push(formatRule(`${sel}::after`, b.decls));
|
if (nr.after) for (const b of nr.after.bands) bandRules.get(b.media)?.push(formatRule(`${sel}::after`, b.decls));
|
||||||
|
if (nr.placeholder) for (const b of nr.placeholder.bands) bandRules.get(b.media)?.push(formatRule(`${sel}::placeholder`, b.decls));
|
||||||
}
|
}
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (keyframes) parts.push(keyframes);
|
if (keyframes) parts.push(keyframes);
|
||||||
|
|||||||
+110
-15
@@ -19,7 +19,13 @@ import type { MotionCapture, WaapiAnim, RotatorSpec, RevealSpec, MarqueeSpec } f
|
|||||||
|
|
||||||
export type RTWaapi = { cid: string; keyframes: Array<Record<string, string | number>>; duration: number; delay: number; easing: string; iterations: number; direction: string; fill: string };
|
export type RTWaapi = { cid: string; keyframes: Array<Record<string, string | number>>; duration: number; delay: number; easing: string; iterations: number; direction: string; fill: string };
|
||||||
export type RTRotator = { cid: string; texts: string[]; intervalMs: number };
|
export type RTRotator = { cid: string; texts: string[]; intervalMs: number };
|
||||||
export type RTReveal = { cid: string; opacity: string; transform: string; transition: string };
|
// Reveal families: transition (hidden via opacity/transform, revealed by the element's own
|
||||||
|
// transition) and visibility+entrance-class (hidden via visibility, revealed with a named
|
||||||
|
// keyframe animation — Elementor/WOW/AOS; the @keyframes ship in the page CSS).
|
||||||
|
export type RTReveal = {
|
||||||
|
cid: string; opacity: string; transform: string; transition: string;
|
||||||
|
visibility?: "hidden"; animationName?: string; animationDuration?: string; animationDelay?: string; animationTiming?: string;
|
||||||
|
};
|
||||||
export type RTMarquee = { cid: string; pxPerSec: number; periodPx: number };
|
export type RTMarquee = { cid: string; pxPerSec: number; periodPx: number };
|
||||||
export type MotionSpec = { waapi: RTWaapi[]; rotators: RTRotator[]; reveals: RTReveal[]; marquees: RTMarquee[] };
|
export type MotionSpec = { waapi: RTWaapi[]; rotators: RTRotator[]; reveals: RTReveal[]; marquees: RTMarquee[] };
|
||||||
|
|
||||||
@@ -58,7 +64,16 @@ export function buildMotionSpec(ir: IR, motion: MotionCapture | undefined, inclu
|
|||||||
for (const rv of (motion.reveals ?? []) as RevealSpec[]) {
|
for (const rv of (motion.reveals ?? []) as RevealSpec[]) {
|
||||||
const cid = map.get(rv.cap);
|
const cid = map.get(rv.cap);
|
||||||
if (!ok(cid)) continue;
|
if (!ok(cid)) continue;
|
||||||
reveals.push({ cid, opacity: rv.opacity, transform: rv.transform, transition: rv.transition });
|
reveals.push({
|
||||||
|
cid, opacity: rv.opacity, transform: rv.transform, transition: rv.transition,
|
||||||
|
...(rv.visibility === "hidden" ? { visibility: rv.visibility } : {}),
|
||||||
|
...(rv.animationName ? {
|
||||||
|
animationName: rv.animationName,
|
||||||
|
animationDuration: rv.animationDuration,
|
||||||
|
animationDelay: rv.animationDelay,
|
||||||
|
animationTiming: rv.animationTiming,
|
||||||
|
} : {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const marquees: RTMarquee[] = [];
|
const marquees: RTMarquee[] = [];
|
||||||
for (const m of (motion.marquees ?? []) as MarqueeSpec[]) {
|
for (const m of (motion.marquees ?? []) as MarqueeSpec[]) {
|
||||||
@@ -92,12 +107,45 @@ import { useEffect } from "react";
|
|||||||
|
|
||||||
type RTWaapi = { cid: string; keyframes: Array<Record<string, string | number>>; duration: number; delay: number; easing: string; iterations: number; direction: string; fill: string };
|
type RTWaapi = { cid: string; keyframes: Array<Record<string, string | number>>; duration: number; delay: number; easing: string; iterations: number; direction: string; fill: string };
|
||||||
type RTRotator = { cid: string; texts: string[]; intervalMs: number };
|
type RTRotator = { cid: string; texts: string[]; intervalMs: number };
|
||||||
type RTReveal = { cid: string; opacity: string; transform: string; transition: string };
|
type RTReveal = {
|
||||||
|
cid: string; opacity: string; transform: string; transition: string;
|
||||||
|
visibility?: "hidden"; animationName?: string; animationDuration?: string; animationDelay?: string; animationTiming?: string;
|
||||||
|
};
|
||||||
type RTMarquee = { cid: string; pxPerSec: number; periodPx: number };
|
type RTMarquee = { cid: string; pxPerSec: number; periodPx: number };
|
||||||
export type MotionSpec = { waapi: RTWaapi[]; rotators: RTRotator[]; reveals: RTReveal[]; marquees: RTMarquee[] };
|
export type MotionSpec = { waapi: RTWaapi[]; rotators: RTRotator[]; reveals: RTReveal[]; marquees: RTMarquee[] };
|
||||||
|
|
||||||
const byCid = (cid: string): HTMLElement | null => document.querySelector('[data-cid="' + cid + '"]');
|
const byCid = (cid: string): HTMLElement | null => document.querySelector('[data-cid="' + cid + '"]');
|
||||||
|
|
||||||
|
// Reveal-replay pacing caps. On the live site an entrance stagger plays ONCE, on first load,
|
||||||
|
// while the whole group is already in view. Our replay re-hides each element and replays its
|
||||||
|
// entrance on scroll-into-view, preserving the captured per-element delay AND duration. A grid
|
||||||
|
// of tiles then either staggers over a long window (captured delays) or each tile plays a long
|
||||||
|
// (e.g. 1.25s) entrance as it scrolls in — so a fast scroll / full-page screenshot catches most
|
||||||
|
// tiles mid-entrance, unpainted. Cap the replayed delay AND duration so each tile paints promptly
|
||||||
|
// after it enters the viewport, keeping relative order. (The validator settles via
|
||||||
|
// __dittoMotionStop and is unaffected — this only bounds the live, un-stopped replay.)
|
||||||
|
const REVEAL_MAX_DELAY_MS = 300;
|
||||||
|
const REVEAL_MAX_DURATION_MS = 600;
|
||||||
|
const parseTimeMs = (raw: string | undefined): number => {
|
||||||
|
if (!raw) return 0;
|
||||||
|
const first = String(raw).split(",")[0]!.trim();
|
||||||
|
const m = /^(-?[0-9.]+)(ms|s)?$/.exec(first);
|
||||||
|
if (!m) return 0;
|
||||||
|
let ms = parseFloat(m[1]!);
|
||||||
|
if (m[2] !== "ms") ms *= 1000;
|
||||||
|
return isFinite(ms) ? ms : 0;
|
||||||
|
};
|
||||||
|
const clampDelay = (raw: string | undefined): string => {
|
||||||
|
const ms = parseTimeMs(raw);
|
||||||
|
if (ms <= 0) return "0s";
|
||||||
|
return Math.min(ms, REVEAL_MAX_DELAY_MS) + "ms";
|
||||||
|
};
|
||||||
|
const clampDuration = (raw: string | undefined): string => {
|
||||||
|
const ms = parseTimeMs(raw);
|
||||||
|
if (ms <= 0) return raw && String(raw).trim() ? String(raw) : "1s";
|
||||||
|
return Math.min(ms, REVEAL_MAX_DURATION_MS) + "ms";
|
||||||
|
};
|
||||||
|
|
||||||
/** Replays captured motion the stylesheet can't express: WAAPI animations (re-issued via
|
/** Replays captured motion the stylesheet can't express: WAAPI animations (re-issued via
|
||||||
* element.animate), rotating text (interval-cycled), and scroll-triggered reveals (start
|
* element.animate), rotating text (interval-cycled), and scroll-triggered reveals (start
|
||||||
* hidden, transition in when scrolled into view). Starts on mount. Installs
|
* hidden, transition in when scrolled into view). Starts on mount. Installs
|
||||||
@@ -111,9 +159,11 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
|
|||||||
const intervals: ReturnType<typeof setInterval>[] = [];
|
const intervals: ReturnType<typeof setInterval>[] = [];
|
||||||
const rotators: Array<{ el: HTMLElement; original: string | null }> = [];
|
const rotators: Array<{ el: HTMLElement; original: string | null }> = [];
|
||||||
const anims: Animation[] = [];
|
const anims: Animation[] = [];
|
||||||
const revealed: Array<() => void> = []; // per-reveal "show now" fns (also the cleanup)
|
// per-reveal "show now" fns (also the cleanup); animate=false jumps to the settled frame
|
||||||
|
const revealed: Array<(animate: boolean) => void> = [];
|
||||||
let io: IntersectionObserver | null = null;
|
let io: IntersectionObserver | null = null;
|
||||||
let forceTimer: ReturnType<typeof setTimeout> | null = null;
|
let forceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const settleTimers: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
|
||||||
for (const w of spec.waapi) {
|
for (const w of spec.waapi) {
|
||||||
const el = byCid(w.cid);
|
const el = byCid(w.cid);
|
||||||
@@ -153,29 +203,72 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
|
|||||||
intervals.push(setInterval(() => { i = (i + 1) % r.texts.length; el.textContent = r.texts[i]!; }, Math.max(400, r.intervalMs)));
|
intervals.push(setInterval(() => { i = (i + 1) % r.texts.length; el.textContent = r.texts[i]!; }, Math.max(400, r.intervalMs)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll reveals: hide each element (opacity/transform) with the captured transition,
|
// Scroll reveals: re-hide each element (JS-applied on mount, so non-JS/SSR still shows
|
||||||
// then reveal (clear the inline overrides → transitions to the base CSS) when it scrolls
|
// the content), then reveal when it scrolls into view. Two families:
|
||||||
// into view. A force-reveal timer guarantees nothing stays hidden if the observer misses.
|
// - transition — hide via opacity/transform, reveal by transitioning to full;
|
||||||
|
// - visibility+entrance-class (Elementor/WOW/AOS) — hide via visibility with the baked
|
||||||
|
// entrance animation suppressed, reveal by restarting the captured @keyframes
|
||||||
|
// (they ship in the page CSS under the captured animation-name).
|
||||||
|
// A force-reveal timer guarantees nothing stays hidden if the observer misses.
|
||||||
if (spec.reveals.length) {
|
if (spec.reveals.length) {
|
||||||
// Reveal to the full resting state. Setting 1/none (not clearing to base) is correct for
|
// Reveal to the full resting state. Setting 1/none (not clearing to base) is correct for
|
||||||
// every reveal — the revealed state is always full + un-offset — and is REQUIRED for
|
// every reveal — the revealed state is always full + un-offset — and is REQUIRED for
|
||||||
// scroll-scrub panels whose captured base CSS is a frozen mid-scrub value (opacity 0.63).
|
// scroll-scrub panels whose captured base CSS is a frozen mid-scrub value (opacity 0.63).
|
||||||
const show = (el: HTMLElement) => { el.style.opacity = "1"; el.style.transform = "none"; };
|
// animate=false (validator settle path) jumps straight to the settled frame so no
|
||||||
const byEl = new Map<Element, HTMLElement>();
|
// measurement can catch a mid-entrance value.
|
||||||
|
const show = (el: HTMLElement, rv: RTReveal, animate: boolean) => {
|
||||||
|
el.style.opacity = "1"; el.style.transform = "none";
|
||||||
|
if (rv.visibility === "hidden") el.style.visibility = "visible";
|
||||||
|
if (rv.animationName) {
|
||||||
|
if (animate) {
|
||||||
|
// restart the entrance from t=0: none -> name starts a fresh animation. Cap the
|
||||||
|
// delay + duration so the tile paints promptly after entering view (a long captured
|
||||||
|
// stagger/entrance would otherwise leave it blank through a fast scroll / screenshot).
|
||||||
|
el.style.animationName = "none";
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.style.animationName = rv.animationName;
|
||||||
|
el.style.animationDuration = clampDuration(rv.animationDuration);
|
||||||
|
el.style.animationDelay = clampDelay(rv.animationDelay);
|
||||||
|
el.style.animationTimingFunction = rv.animationTiming || "ease";
|
||||||
|
el.style.animationFillMode = "both";
|
||||||
|
el.style.animationIterationCount = "1";
|
||||||
|
} else {
|
||||||
|
el.style.animationName = "none"; // settled frame: keep the entrance suppressed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const shows = new Map<Element, (animate: boolean) => void>();
|
||||||
for (const rv of spec.reveals) {
|
for (const rv of spec.reveals) {
|
||||||
const el = byCid(rv.cid);
|
const el = byCid(rv.cid);
|
||||||
if (!el) continue;
|
if (!el) continue;
|
||||||
|
if (rv.visibility === "hidden") {
|
||||||
|
el.style.visibility = "hidden";
|
||||||
|
el.style.animationName = "none"; // don't burn the baked entrance while hidden
|
||||||
|
} else {
|
||||||
el.style.transition = rv.transition;
|
el.style.transition = rv.transition;
|
||||||
el.style.opacity = rv.opacity;
|
el.style.opacity = rv.opacity;
|
||||||
if (rv.transform !== "none") el.style.transform = rv.transform;
|
if (rv.transform !== "none") el.style.transform = rv.transform;
|
||||||
byEl.set(el, el);
|
|
||||||
revealed.push(() => show(el));
|
|
||||||
}
|
}
|
||||||
|
const fn = (animate: boolean) => show(el, rv, animate);
|
||||||
|
shows.set(el, fn);
|
||||||
|
revealed.push(fn);
|
||||||
|
}
|
||||||
|
// Per-element settle: a bounded time after a tile is animated in, force it to its settled
|
||||||
|
// frame (animate=false) so it can't be caught mid-entrance by a fast scroll / screenshot,
|
||||||
|
// regardless of when it entered view. The clamped delay+duration keep this window short.
|
||||||
|
const settleAfter = REVEAL_MAX_DELAY_MS + REVEAL_MAX_DURATION_MS + 100;
|
||||||
io = new IntersectionObserver((entries) => {
|
io = new IntersectionObserver((entries) => {
|
||||||
for (const e of entries) if (e.isIntersecting) { const el = byEl.get(e.target); if (el) { show(el); io!.unobserve(e.target); } }
|
for (const e of entries) if (e.isIntersecting) {
|
||||||
|
const f = shows.get(e.target); if (!f) continue;
|
||||||
|
f(true); io!.unobserve(e.target);
|
||||||
|
settleTimers.push(setTimeout(() => f(false), settleAfter));
|
||||||
|
}
|
||||||
}, { rootMargin: "0px 0px -8% 0px" });
|
}, { rootMargin: "0px 0px -8% 0px" });
|
||||||
for (const el of byEl.keys()) io.observe(el);
|
for (const el of shows.keys()) io.observe(el);
|
||||||
forceTimer = setTimeout(() => { for (const f of revealed) f(); }, 4000);
|
// Global failsafe: settle EVERYTHING (animate=false) so any element the observer never fired
|
||||||
|
// for (never scrolled into view, or a missed callback) is painted, not left hidden. Per-mount
|
||||||
|
// is sufficient because it jumps straight to the settled frame with no residual delay.
|
||||||
|
forceTimer = setTimeout(() => { for (const f of revealed) f(false); }, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stopAll = () => {
|
const stopAll = () => {
|
||||||
@@ -185,7 +278,8 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
|
|||||||
for (const a of anims) { try { a.cancel(); } catch { /* ignore */ } }
|
for (const a of anims) { try { a.cancel(); } catch { /* ignore */ } }
|
||||||
if (io) io.disconnect();
|
if (io) io.disconnect();
|
||||||
if (forceTimer) clearTimeout(forceTimer);
|
if (forceTimer) clearTimeout(forceTimer);
|
||||||
for (const f of revealed) f(); // reveal everything → base CSS settled frame
|
for (const t of settleTimers) clearTimeout(t);
|
||||||
|
for (const f of revealed) f(false); // reveal everything, settled → base CSS graded frame
|
||||||
};
|
};
|
||||||
// Measurement hook: restore the fully-settled/revealed base for grading.
|
// Measurement hook: restore the fully-settled/revealed base for grading.
|
||||||
(window as any).__dittoMotionStop = stopAll;
|
(window as any).__dittoMotionStop = stopAll;
|
||||||
@@ -193,6 +287,7 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
|
|||||||
for (const id of intervals) clearInterval(id);
|
for (const id of intervals) clearInterval(id);
|
||||||
if (io) io.disconnect();
|
if (io) io.disconnect();
|
||||||
if (forceTimer) clearTimeout(forceTimer);
|
if (forceTimer) clearTimeout(forceTimer);
|
||||||
|
for (const t of settleTimers) clearTimeout(t);
|
||||||
try { delete (window as any).__dittoMotionStop; } catch { /* ignore */ }
|
try { delete (window as any).__dittoMotionStop; } catch { /* ignore */ }
|
||||||
};
|
};
|
||||||
}, [spec]);
|
}, [spec]);
|
||||||
|
|||||||
@@ -273,6 +273,15 @@ function iconUrl(icon: SeoInventory["icons"][number]): string {
|
|||||||
return icon.localPath || icon.href;
|
return icon.localPath || icon.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Next validates metadata.openGraph.type against this fixed enum and THROWS at
|
||||||
|
// render on anything else (e.g. Shopify's `product.group`). Unsupported values
|
||||||
|
// are emitted via metadata.other instead so the tag survives without the crash.
|
||||||
|
const NEXT_OG_TYPES = new Set([
|
||||||
|
"website", "article", "book", "profile",
|
||||||
|
"music.song", "music.album", "music.playlist", "music.radio_station",
|
||||||
|
"video.movie", "video.episode", "video.tv_show", "video.other",
|
||||||
|
]);
|
||||||
|
|
||||||
function metadataObject(report: SeoInventory): Record<string, unknown> {
|
function metadataObject(report: SeoInventory): Record<string, unknown> {
|
||||||
const metadata: Record<string, unknown> = { title: report.title || "Cloned Page" };
|
const metadata: Record<string, unknown> = { title: report.title || "Cloned Page" };
|
||||||
if (report.description) metadata.description = report.description;
|
if (report.description) metadata.description = report.description;
|
||||||
@@ -297,9 +306,13 @@ function metadataObject(report: SeoInventory): Record<string, unknown> {
|
|||||||
const ogSiteName = firstValue(ogEntries, "og:site_name");
|
const ogSiteName = firstValue(ogEntries, "og:site_name");
|
||||||
const ogUrl = firstValue(ogEntries, "og:url");
|
const ogUrl = firstValue(ogEntries, "og:url");
|
||||||
const ogImages = ogEntries.filter((entry) => entry.property?.toLowerCase() === "og:image").map((entry) => entry.content);
|
const ogImages = ogEntries.filter((entry) => entry.property?.toLowerCase() === "og:image").map((entry) => entry.content);
|
||||||
|
const other: Record<string, string> = {};
|
||||||
if (ogTitle) og.title = ogTitle;
|
if (ogTitle) og.title = ogTitle;
|
||||||
if (ogDescription) og.description = ogDescription;
|
if (ogDescription) og.description = ogDescription;
|
||||||
if (ogType) og.type = ogType;
|
if (ogType) {
|
||||||
|
if (NEXT_OG_TYPES.has(ogType)) og.type = ogType;
|
||||||
|
else other["og:type"] = ogType;
|
||||||
|
}
|
||||||
if (ogSiteName) og.siteName = ogSiteName;
|
if (ogSiteName) og.siteName = ogSiteName;
|
||||||
if (ogUrl) og.url = ogUrl;
|
if (ogUrl) og.url = ogUrl;
|
||||||
if (ogImages.length) og.images = ogImages;
|
if (ogImages.length) og.images = ogImages;
|
||||||
@@ -335,6 +348,7 @@ function metadataObject(report: SeoInventory): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
if (Object.keys(icons).length) metadata.icons = icons;
|
if (Object.keys(icons).length) metadata.icons = icons;
|
||||||
if (report.manifest) metadata.manifest = report.manifest.localPath || report.manifest.href;
|
if (report.manifest) metadata.manifest = report.manifest.localPath || report.manifest.href;
|
||||||
|
if (Object.keys(other).length) metadata.other = other;
|
||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ const SPACE_SCALE = new Set<string>([
|
|||||||
]);
|
]);
|
||||||
// Props that take 100% → the `-full` named utility (exact: w-full ≡ width:100%).
|
// Props that take 100% → the `-full` named utility (exact: w-full ≡ width:100%).
|
||||||
const PCT_FULL = new Set<string>(["width", "height", "min-width", "max-width", "min-height", "max-height"]);
|
const PCT_FULL = new Set<string>(["width", "height", "min-width", "max-width", "min-height", "max-height"]);
|
||||||
|
// ARB props whose named Tailwind v4 scale really includes a bare `0` step: the spacing-scale props
|
||||||
|
// (w-0, top-0, leading-0, indent-0, …) plus the numeric scales grow/shrink/basis/order/z/opacity.
|
||||||
|
// font-size, letter-spacing, the radius corners and object-position have NO `-0` utility — `text-0`
|
||||||
|
// / `tracking-0` compile to NOTHING in v4 (a silent no-op: a map label captured at font-size:0
|
||||||
|
// painted at the inherited 20px) — so their zeros must stay arbitrary (`text-[0px]`).
|
||||||
|
const ZERO_NAMED = new Set<string>([
|
||||||
|
...SPACE_SCALE, "flex-grow", "flex-shrink", "flex-basis", "order", "z-index", "opacity",
|
||||||
|
]);
|
||||||
const ORIGIN_NAMED: Record<string, string> = {
|
const ORIGIN_NAMED: Record<string, string> = {
|
||||||
center: "origin-center", top: "origin-top", "top right": "origin-top-right", right: "origin-right",
|
center: "origin-center", top: "origin-top", "top right": "origin-top-right", right: "origin-right",
|
||||||
"bottom right": "origin-bottom-right", bottom: "origin-bottom", "bottom left": "origin-bottom-left",
|
"bottom right": "origin-bottom-right", bottom: "origin-bottom", "bottom left": "origin-bottom-left",
|
||||||
@@ -141,8 +149,9 @@ function transformNeedsOrigin(v: string | undefined): boolean {
|
|||||||
return !!v && v !== "none" && translateOffsets(v) === null;
|
return !!v && v !== "none" && translateOffsets(v) === null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Translate ONE computed declaration into a Tailwind utility (no responsive prefix). */
|
/** Translate ONE computed declaration into a Tailwind utility (no responsive prefix).
|
||||||
function declToUtil(prop: string, value: string): string {
|
* Exported for tests (the zero-value gating is load-bearing: an invalid utility is a SILENT no-op). */
|
||||||
|
export function declToUtil(prop: string, value: string): string {
|
||||||
// Colors → named theme tokens when tokenized (bg-primary), else arbitrary value.
|
// Colors → named theme tokens when tokenized (bg-primary), else arbitrary value.
|
||||||
if (prop === "color") { const n = tokenName(value); return n ? `text-${n}` : `text-[color:${arb(value)}]`; }
|
if (prop === "color") { const n = tokenName(value); return n ? `text-${n}` : `text-[color:${arb(value)}]`; }
|
||||||
if (prop === "background-color") { const n = tokenName(value); return n ? `bg-${n}` : `bg-[${arb(value)}]`; }
|
if (prop === "background-color") { const n = tokenName(value); return n ? `bg-${n}` : `bg-[${arb(value)}]`; }
|
||||||
@@ -229,7 +238,11 @@ function declToUtil(prop: string, value: string): string {
|
|||||||
const n = spacingSteps(value);
|
const n = spacingSteps(value);
|
||||||
if (n !== null) return n < 0 ? `-${ARB[prop]}-${-n}` : `${ARB[prop]}-${n}`;
|
if (n !== null) return n < 0 ? `-${ARB[prop]}-${-n}` : `${ARB[prop]}-${n}`;
|
||||||
}
|
}
|
||||||
if (value === "0" || value === "0px" || value === "0rem") return `${ARB[prop]}-0`; // bare/unitless zero
|
// Bare/unitless zero — the named `-0` only where that utility actually exists (ZERO_NAMED);
|
||||||
|
// elsewhere emit the exact arbitrary length so the declaration really compiles.
|
||||||
|
if (value === "0" || value === "0px" || value === "0rem") {
|
||||||
|
return ZERO_NAMED.has(prop) ? `${ARB[prop]}-0` : `${ARB[prop]}-[0px]`;
|
||||||
|
}
|
||||||
return `${ARB[prop]}-[${arb(value)}]`;
|
return `${ARB[prop]}-[${arb(value)}]`;
|
||||||
}
|
}
|
||||||
if (/^border-(top|right|bottom|left)-width$/.test(prop)) {
|
if (/^border-(top|right|bottom|left)-width$/.test(prop)) {
|
||||||
@@ -261,25 +274,47 @@ function declToUtil(prop: string, value: string): string {
|
|||||||
|
|
||||||
/** Responsive variant prefix for a band's media query. For the standard capture ladder
|
/** Responsive variant prefix for a band's media query. For the standard capture ladder
|
||||||
* (375/768/1280/1920, canonical 1280) the per-viewport bands are the midpoints 571/1024/1600,
|
* (375/768/1280/1920, canonical 1280) the per-viewport bands are the midpoints 571/1024/1600,
|
||||||
* which we map to the CONVENTIONAL Tailwind breakpoints a human would author — `max-md:`
|
* which we map to the CONVENTIONAL Tailwind breakpoint NAMES a human would author — `max-md:`
|
||||||
* (mobile, <768), `md:max-lg:` (tablet, 768–1023), `2xl:` (wide, ≥1536) — leaving 1280 as the
|
* (mobile), `md:max-lg:` (tablet), `2xl:` (wide) — leaving 1280 as the unprefixed base. The
|
||||||
* unprefixed base. Each reproduces the captured width EXACTLY (the style gate measures only at
|
* generated @theme redefines those screens to the band boundaries (bandScreens), so every named
|
||||||
* 375/768/1280/1920), while between-width behaviour follows the standard breakpoints instead of
|
* variant flips at EXACTLY the same width as the ditto.css band media query (Tailwind's stock
|
||||||
* arbitrary midpoint pixels. Non-standard viewport sets fall back to the exact arbitrary band. */
|
* 768/1024/1536 would open windows — e.g. 1536–1600 — where utilities and ditto.css rules
|
||||||
function prefixFor(media: string): string {
|
* disagree). Non-standard viewport sets fall back to the exact arbitrary band. */
|
||||||
|
export function prefixFor(media: string): string {
|
||||||
const min = /min-width:\s*(\d+)px/.exec(media);
|
const min = /min-width:\s*(\d+)px/.exec(media);
|
||||||
const max = /max-width:\s*(\d+)px/.exec(media);
|
const max = /max-width:\s*(\d+)px/.exec(media);
|
||||||
const lo = min ? +min[1]! : 0;
|
const lo = min ? +min[1]! : 0;
|
||||||
const hi = max ? +max[1]! : Infinity;
|
const hi = max ? +max[1]! : Infinity;
|
||||||
if (!min && hi === 571) return "max-md:"; // 375 sample → below md (768)
|
if (!min && hi === 571) return "max-md:"; // 375 sample band (md pinned to 572)
|
||||||
if (lo === 572 && hi === 1024) return "md:max-lg:"; // 768 sample → md … below lg (1024)
|
if (lo === 572 && hi === 1024) return "md:max-lg:"; // 768 sample band (lg pinned to 1025)
|
||||||
if (lo === 1601 && !max) return "2xl:"; // 1920 sample → at/above 2xl (1536)
|
if (lo === 1601 && !max) return "2xl:"; // 1920 sample band (2xl pinned to 1601)
|
||||||
let p = "";
|
let p = "";
|
||||||
if (min) p += `min-[${lo}px]:`;
|
if (min) p += `min-[${lo}px]:`;
|
||||||
if (max) p += `max-[${hi}px]:`;
|
// v4 max-* is STRICT (`width < X`) while the band's max-width is inclusive → pin to hi+1.
|
||||||
|
if (max) p += `max-[${hi + 1}px]:`;
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Named-screen overrides for the generated @theme, derived from the SAME bands prefixFor
|
||||||
|
* names. Invariant: for every band, the Tailwind variant prefix and the ditto.css media
|
||||||
|
* query produce the same min/max boundaries. Tailwind v4 compiles `X:` to `width >= X`
|
||||||
|
* and `max-X:` to `width < X`, so a band min pins the screen directly and a band max pins
|
||||||
|
* it to max+1. Bands prefixFor leaves arbitrary (`min-[…px]:`) embed their exact bounds
|
||||||
|
* and need no override. */
|
||||||
|
export function bandScreens(viewports: number[], canonical: number): Array<[string, number]> {
|
||||||
|
const out = new Map<string, number>();
|
||||||
|
for (const b of computeBands(viewports, canonical)) {
|
||||||
|
if (!b.media) continue;
|
||||||
|
const min = /min-width:\s*(\d+)px/.exec(b.media);
|
||||||
|
const max = /max-width:\s*(\d+)px/.exec(b.media);
|
||||||
|
for (const m of prefixFor(b.media).matchAll(/(max-)?(sm|md|lg|xl|2xl):/g)) {
|
||||||
|
out.set(m[2]!, m[1] ? +max![1]! + 1 : +min![1]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const order = ["sm", "md", "lg", "xl", "2xl"];
|
||||||
|
return [...out.entries()].sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]));
|
||||||
|
}
|
||||||
|
|
||||||
function fmtRule(sel: string, decls: Map<string, string>): string {
|
function fmtRule(sel: string, decls: Map<string, string>): string {
|
||||||
return `${sel}{${[...decls].map(([k, v]) => `${k}:${v}`).join(";")}}`;
|
return `${sel}{${[...decls].map(([k, v]) => `${k}:${v}`).join(";")}}`;
|
||||||
}
|
}
|
||||||
@@ -1141,6 +1176,13 @@ export function buildTailwind(ir: IR, assetMap: Map<string, string>, colorVar?:
|
|||||||
classOf.set(cid, dedupeUtils([...(classOf.get(cid) ?? "").split(" ").filter(Boolean), ...filteredPseudoUtils]).join(" "));
|
classOf.set(cid, dedupeUtils([...(classOf.get(cid) ?? "").split(" ").filter(Boolean), ...filteredPseudoUtils]).join(" "));
|
||||||
utilCount += filteredPseudoUtils.length;
|
utilCount += filteredPseudoUtils.length;
|
||||||
}
|
}
|
||||||
|
// ::placeholder → a raw [data-cid] ditto.css rule (rare — only styled form controls),
|
||||||
|
// matching the pseudo fallback path above: exact colors, no Tailwind-escape round-trip.
|
||||||
|
if (nr.placeholder) {
|
||||||
|
const psel = `${sel}::placeholder`;
|
||||||
|
if (nr.placeholder.base.size) extraParts.push(fmtRule(psel, nr.placeholder.base));
|
||||||
|
for (const b of nr.placeholder.bands) extraParts.push(`${b.media} {\n${fmtRule(psel, b.decls)}\n}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage 4 hover/focus → Tailwind variant utilities folded into each node's className (replacing the
|
// Stage 4 hover/focus → Tailwind variant utilities folded into each node's className (replacing the
|
||||||
@@ -1181,9 +1223,14 @@ export function buildTailwind(ir: IR, assetMap: Map<string, string>, colorVar?:
|
|||||||
* so utilities override them. */
|
* so utilities override them. */
|
||||||
export function tailwindGlobalsCss(opts: {
|
export function tailwindGlobalsCss(opts: {
|
||||||
reset: string; fontCss: string; tokensCss: string; htmlBg: string; bodyFont: string;
|
reset: string; fontCss: string; tokensCss: string; htmlBg: string; bodyFont: string;
|
||||||
clip: string; colorTokens: string[]; viewports: number[];
|
clip: string; colorTokens: string[]; viewports: number[]; canonical: number;
|
||||||
}): string {
|
}): string {
|
||||||
const screens = [...opts.viewports].sort((a, b) => a - b).map((v) => ` --breakpoint-vp${v}: ${v}px;`).join("\n");
|
const screens = [
|
||||||
|
...[...opts.viewports].sort((a, b) => a - b).map((v) => ` --breakpoint-vp${v}: ${v}px;`),
|
||||||
|
// Named screens pinned to the ditto.css band boundaries so `md:`/`2xl:` utilities
|
||||||
|
// and the banded rules flip at the same width (see bandScreens).
|
||||||
|
...bandScreens(opts.viewports, opts.canonical).map(([n, px]) => ` --breakpoint-${n}: ${px}px;`),
|
||||||
|
].join("\n");
|
||||||
const colors = opts.colorTokens.map((n) => ` --color-${n}: var(--${n});`).join("\n");
|
const colors = opts.colorTokens.map((n) => ` --color-${n}: var(--${n});`).join("\n");
|
||||||
return `@layer theme, base, components, utilities;
|
return `@layer theme, base, components, utilities;
|
||||||
@import "tailwindcss/theme.css" layer(theme);
|
@import "tailwindcss/theme.css" layer(theme);
|
||||||
|
|||||||
@@ -96,11 +96,9 @@ export function materializeAssets(graph: AssetGraph, sourceDir: string, appPubli
|
|||||||
for (const e of graph.entries) {
|
for (const e of graph.entries) {
|
||||||
if (e.classification !== "downloaded" || !e.storedFile || !e.localPath) continue;
|
if (e.classification !== "downloaded" || !e.storedFile || !e.localPath) continue;
|
||||||
if (e.type === "css") continue; // not served by the app
|
if (e.type === "css") continue; // not served by the app
|
||||||
// A <video> is rendered as its poster still (streaming sources are dropped in
|
// Video files ARE copied: generation emits a local <video src>/<source src>
|
||||||
// generation), so the materialized video file is never referenced — skip
|
// whenever the file materialized (only streaming/undownloaded videos fall back
|
||||||
// copying it to public/ to save disk and build time. Its first-frame poster is
|
// to poster-only rendering).
|
||||||
// a separate image asset that is still materialized.
|
|
||||||
if (e.type === "video") continue;
|
|
||||||
if (seenPublicPaths?.has(e.localPath)) continue;
|
if (seenPublicPaths?.has(e.localPath)) continue;
|
||||||
const src = join(storeDir, e.storedFile);
|
const src = join(storeDir, e.storedFile);
|
||||||
const from = fileExists(src) ? src : join(cssDir, e.storedFile);
|
const from = fileExists(src) ? src : join(cssDir, e.storedFile);
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export type IRNode = {
|
|||||||
sizingByVp?: Record<number, RawSizing>;
|
sizingByVp?: Record<number, RawSizing>;
|
||||||
beforeByVp?: Record<number, StyleMap>;
|
beforeByVp?: Record<number, StyleMap>;
|
||||||
afterByVp?: Record<number, StyleMap>;
|
afterByVp?: Record<number, StyleMap>;
|
||||||
|
// ::placeholder computed style (input/textarea with placeholder text) — emitted as a
|
||||||
|
// `::placeholder` rule so form controls keep their authored placeholder color.
|
||||||
|
placeholderByVp?: Record<number, StyleMap>;
|
||||||
children: IRChild[];
|
children: IRChild[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -156,10 +159,12 @@ function resolveLazyAttrs(attrs: Record<string, string> | undefined): Record<str
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `<iframe>` is intentionally NOT noise: it is kept as a sized placeholder box (its
|
// `<iframe>` is intentionally NOT noise: capture grafts an embedded document's subtree
|
||||||
// external document is dropped at generation — see propsList — so the clone stays
|
// as the iframe's children (capture/graft.ts) so form embeds (Klaviyo et al) render as
|
||||||
// self-contained while preserving the layout the embed occupied). Truly invisible
|
// real content — generation then emits the iframe as a positioned <div>. When the graft
|
||||||
// iframes (tracking/chat, 0-size or display:none) are removed by the visibility prune.
|
// wasn't possible the iframe is kept as a sized placeholder box (document-loading attrs
|
||||||
|
// dropped at generation — see propsList — so the clone stays self-contained). Truly
|
||||||
|
// invisible iframes (tracking/chat, 0-size or display:none) are removed by the prune.
|
||||||
const NOISE_TAGS = new Set(["next-route-announcer"]);
|
const NOISE_TAGS = new Set(["next-route-announcer"]);
|
||||||
|
|
||||||
// Third-party overlay widgets (chat launchers, cookie/consent bars, captcha badges) are
|
// Third-party overlay widgets (chat launchers, cookie/consent bars, captcha badges) are
|
||||||
@@ -306,6 +311,7 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
|
|||||||
const sizingByVp: Record<number, RawSizing> = {};
|
const sizingByVp: Record<number, RawSizing> = {};
|
||||||
const beforeByVp: Record<number, StyleMap> = {};
|
const beforeByVp: Record<number, StyleMap> = {};
|
||||||
const afterByVp: Record<number, StyleMap> = {};
|
const afterByVp: Record<number, StyleMap> = {};
|
||||||
|
const placeholderByVp: Record<number, StyleMap> = {};
|
||||||
|
|
||||||
for (const vw of viewports) {
|
for (const vw of viewports) {
|
||||||
const match = matched[vw];
|
const match = matched[vw];
|
||||||
@@ -316,6 +322,7 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
|
|||||||
if (match.sizing) sizingByVp[vw] = match.sizing;
|
if (match.sizing) sizingByVp[vw] = match.sizing;
|
||||||
if (match.before) beforeByVp[vw] = match.before;
|
if (match.before) beforeByVp[vw] = match.before;
|
||||||
if (match.after) afterByVp[vw] = match.after;
|
if (match.after) afterByVp[vw] = match.after;
|
||||||
|
if (match.placeholder) placeholderByVp[vw] = match.placeholder;
|
||||||
}
|
}
|
||||||
|
|
||||||
const node: IRNode = {
|
const node: IRNode = {
|
||||||
@@ -333,6 +340,7 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
|
|||||||
if (Object.keys(sizingByVp).length) node.sizingByVp = sizingByVp;
|
if (Object.keys(sizingByVp).length) node.sizingByVp = sizingByVp;
|
||||||
if (Object.keys(beforeByVp).length) node.beforeByVp = beforeByVp;
|
if (Object.keys(beforeByVp).length) node.beforeByVp = beforeByVp;
|
||||||
if (Object.keys(afterByVp).length) node.afterByVp = afterByVp;
|
if (Object.keys(afterByVp).length) node.afterByVp = afterByVp;
|
||||||
|
if (Object.keys(placeholderByVp).length) node.placeholderByVp = placeholderByVp;
|
||||||
|
|
||||||
if (!raw.rawHTML) {
|
if (!raw.rawHTML) {
|
||||||
const canonKids = elementChildren(raw);
|
const canonKids = elementChildren(raw);
|
||||||
@@ -381,11 +389,16 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
|
|||||||
keptChildren.push(c);
|
keptChildren.push(c);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// `<source>` carries a <picture>/<video>'s media/art-direction candidates but never
|
||||||
|
// paints (0×0 at every viewport), so the visibility prune would always drop it —
|
||||||
|
// losing responsive variants (the lazy-loaded mobile img.src then serves every
|
||||||
|
// width). Keep it structurally; generation emits it only when its file materialized.
|
||||||
|
const keepSource = c.tag === "source" && (node.tag === "picture" || node.tag === "video");
|
||||||
if (keepAll) {
|
if (keepAll) {
|
||||||
prune(c, true);
|
prune(c, true);
|
||||||
keptChildren.push(c);
|
keptChildren.push(c);
|
||||||
if (Object.values(c.visibleByVp).some(Boolean) || childHasVisible(c)) hasVisibleDescendant = true;
|
if (Object.values(c.visibleByVp).some(Boolean) || childHasVisible(c)) hasVisibleDescendant = true;
|
||||||
} else if (prune(c, false)) {
|
} else if (prune(c, false) || keepSource) {
|
||||||
keptChildren.push(c);
|
keptChildren.push(c);
|
||||||
if (Object.values(c.visibleByVp).some(Boolean) || childHasVisible(c)) hasVisibleDescendant = true;
|
if (Object.values(c.visibleByVp).some(Boolean) || childHasVisible(c)) hasVisibleDescendant = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -438,6 +438,7 @@ export function generateSiteApp(opts: {
|
|||||||
const globals = tailwindGlobalsCss({
|
const globals = tailwindGlobalsCss({
|
||||||
reset: RESET_CSS, fontCss: unionFontCss(routes), tokensCss, htmlBg, bodyFont: SYSTEM_FALLBACK, clip,
|
reset: RESET_CSS, fontCss: unionFontCss(routes), tokensCss, htmlBg, bodyFont: SYSTEM_FALLBACK, clip,
|
||||||
colorTokens: [...interner.tokens], viewports: entry.ir.doc.viewports,
|
colorTokens: [...interner.tokens], viewports: entry.ir.doc.viewports,
|
||||||
|
canonical: entry.ir.doc.canonicalViewport,
|
||||||
});
|
});
|
||||||
writeText(join(appDir, "src", isVite ? "globals.css" : join("app", "globals.css")), isVite ? viteGlobalsCss(globals) : globals);
|
writeText(join(appDir, "src", isVite ? "globals.css" : join("app", "globals.css")), isVite ? viteGlobalsCss(globals) : globals);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import type { GateResult } from "./gates.js";
|
|||||||
* on the node, register the referenced @keyframes, and (decisively for infinite
|
* on the node, register the referenced @keyframes, and (decisively for infinite
|
||||||
* loops) actually be running it (`document.getAnimations()`); a keyframes set we
|
* loops) actually be running it (`document.getAnimations()`); a keyframes set we
|
||||||
* could not capture (cross-origin sheet) is counted "frozen" — honestly left static.
|
* could not capture (cross-origin sheet) is counted "frozen" — honestly left static.
|
||||||
|
* A captured CSS entrance animation that the clone instead REPLAYS through a
|
||||||
|
* DittoMotion reveal (visibility+entrance-class: start hidden, restart the entrance
|
||||||
|
* @keyframes on scroll-into-view) is NOT emitted as a static decl — it is deliberately
|
||||||
|
* driven by the reveal replay and graded by the scroll-reveal check below, so it is
|
||||||
|
* excluded from the static-CSS expectation instead of failing `emitted=false`.
|
||||||
* - WAAPI animations + rotating text (from motion.json): the clone's DittoMotion
|
* - WAAPI animations + rotating text (from motion.json): the clone's DittoMotion
|
||||||
* controller must instantiate the same running animation / cycle the same texts.
|
* controller must instantiate the same running animation / cycle the same texts.
|
||||||
* No wall-clock: the check reads the declared spec (names/timing/keyframe registration)
|
* No wall-clock: the check reads the declared spec (names/timing/keyframe registration)
|
||||||
@@ -46,14 +51,39 @@ function capToCid(ir: IR): Map<string, string> {
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** CSS-animated nodes expected from the IR (computed animation-name ≠ none). */
|
/** A captured CSS entrance is reveal-REPLAYED (not statically emitted) when the node has a
|
||||||
function collectExpectedCss(ir: IR): ExpectedCss[] {
|
* DittoMotion reveal spec that replays a matching entrance animationName. Such cids are graded
|
||||||
|
* by the scroll-reveal check, so they must be excluded from the static-CSS expectation rather
|
||||||
|
* than failing `emitted=false`. `revealAnimByCid` maps cid → the animationName its reveal replays. */
|
||||||
|
export function cssReplayedByReveal(
|
||||||
|
cid: string,
|
||||||
|
names: string[],
|
||||||
|
revealAnimByCid: Map<string, string>,
|
||||||
|
): boolean {
|
||||||
|
const revealAnim = revealAnimByCid.get(cid);
|
||||||
|
return !!revealAnim && names.includes(revealAnim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A node's animation is scroll/view-timeline-driven when its computed animation-duration is
|
||||||
|
* `auto` (the duration is resolved from the timeline range, not a time). The generator does NOT
|
||||||
|
* emit such animations — replaying a scroll-linked animation is out of scope; the clone reproduces
|
||||||
|
* the correct AT-REST state instead — so these are excluded from the static-CSS expectation rather
|
||||||
|
* than failing `emitted=false`. Distinct from time-based reveals (finite duration, default timeline). */
|
||||||
|
export function isScrollTimelineAnim(cs: IRNode["computedByVp"][number] | undefined): boolean {
|
||||||
|
const dur = cs?.animationDuration;
|
||||||
|
return !!dur && dur.split(",").some((d) => d.trim() === "auto");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSS-animated nodes expected from the IR (computed animation-name ≠ none). Scroll/view-timeline
|
||||||
|
* animations (animation-duration:auto) are excluded — the clone renders their at-rest state, not
|
||||||
|
* a replay, so there is no static decl to expect. */
|
||||||
|
export function collectExpectedCss(ir: IR): ExpectedCss[] {
|
||||||
const vp = ir.doc.canonicalViewport;
|
const vp = ir.doc.canonicalViewport;
|
||||||
const out: ExpectedCss[] = [];
|
const out: ExpectedCss[] = [];
|
||||||
const walk = (n: IRNode): void => {
|
const walk = (n: IRNode): void => {
|
||||||
const cs = n.computedByVp[vp];
|
const cs = n.computedByVp[vp];
|
||||||
const an = cs?.animationName;
|
const an = cs?.animationName;
|
||||||
if (an && an !== "none") {
|
if (an && an !== "none" && !isScrollTimelineAnim(cs)) {
|
||||||
const names = an.split(",").map((x) => x.trim()).filter((x) => x && x !== "none");
|
const names = an.split(",").map((x) => x.trim()).filter((x) => x && x !== "none");
|
||||||
if (names.length) out.push({ cid: n.id, names, infinite: /infinite/.test(cs!.animationIterationCount ?? "") });
|
if (names.length) out.push({ cid: n.id, names, infinite: /infinite/.test(cs!.animationIterationCount ?? "") });
|
||||||
}
|
}
|
||||||
@@ -100,7 +130,7 @@ export async function driveMotionGate(opts: {
|
|||||||
const vh = VIEWPORT_HEIGHTS[vp] ?? Math.round(vp * 0.66);
|
const vh = VIEWPORT_HEIGHTS[vp] ?? Math.round(vp * 0.66);
|
||||||
|
|
||||||
const issues: string[] = [];
|
const issues: string[] = [];
|
||||||
let cssReproduced = 0, cssFrozen = 0, cssRunning = 0;
|
let cssReproduced = 0, cssFrozen = 0, cssReplayed = 0, cssRunning = 0;
|
||||||
let waapiReproduced = 0, rotatorsReproduced = 0, revealsReproduced = 0, marqueesReproduced = 0;
|
let waapiReproduced = 0, rotatorsReproduced = 0, revealsReproduced = 0, marqueesReproduced = 0;
|
||||||
const browser = await chromium.launch({ headless: true, args: ["--disable-dev-shm-usage"] });
|
const browser = await chromium.launch({ headless: true, args: ["--disable-dev-shm-usage"] });
|
||||||
try {
|
try {
|
||||||
@@ -146,8 +176,21 @@ export async function driveMotionGate(opts: {
|
|||||||
const presentKf = new Set(probe.kf);
|
const presentKf = new Set(probe.kf);
|
||||||
const runningSet = new Set(probe.runningCids);
|
const runningSet = new Set(probe.runningCids);
|
||||||
|
|
||||||
|
// Entrance animations the clone drives through a DittoMotion reveal (visibility+entrance-class):
|
||||||
|
// it re-hides the node on mount and restarts the entrance @keyframes on scroll-into-view, so the
|
||||||
|
// static `animation-name` is deliberately NOT emitted. Such cids are graded by the scroll-reveal
|
||||||
|
// check (opacity → visible), not the static-CSS decl check — record their names so a matching
|
||||||
|
// captured CSS animation is excluded from the static expectation rather than failing emitted=false.
|
||||||
|
const revealAnimByCid = new Map<string, string>();
|
||||||
|
for (const rv of reveals) {
|
||||||
|
if (rv.animationName) revealAnimByCid.set(rv.cid, rv.animationName);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- declarative CSS @keyframes ----
|
// ---- declarative CSS @keyframes ----
|
||||||
for (const e of expectedCss) {
|
for (const e of expectedCss) {
|
||||||
|
// Reveal-replayed entrance: covered by the scroll-reveal check, not static CSS. Excluded
|
||||||
|
// when the captured animation name matches the name the reveal replays for this cid.
|
||||||
|
if (cssReplayedByReveal(e.cid, e.names, revealAnimByCid)) { cssReplayed++; continue; }
|
||||||
const reproducible = e.names.filter((n) => capturedKf.has(n));
|
const reproducible = e.names.filter((n) => capturedKf.has(n));
|
||||||
if (reproducible.length === 0) { cssFrozen++; continue; } // keyframes not captured → frozen (honest)
|
if (reproducible.length === 0) { cssFrozen++; continue; } // keyframes not captured → frozen (honest)
|
||||||
const cloneAn = probe.animName[e.cid] ?? "";
|
const cloneAn = probe.animName[e.cid] ?? "";
|
||||||
@@ -191,9 +234,13 @@ export async function driveMotionGate(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- scroll reveals — scrolling each into view must reveal it (opacity → visible) ----
|
// ---- scroll reveals — scrolling each into view must reveal it (opacity → visible) ----
|
||||||
|
// Grade EVERY captured reveal: the pass check compares revealsReproduced against
|
||||||
|
// reveals.length, so capping the drive at a subset would make any page with more reveals
|
||||||
|
// than the cap unable to pass (its later reveals silently ungraded). (This was the cause
|
||||||
|
// of the spurious "reveals 16/24" — 24 reveals, only 16 driven.)
|
||||||
const opacityOf = (cid: string): Promise<number> =>
|
const opacityOf = (cid: string): Promise<number> =>
|
||||||
page.evaluate((c) => { const el = document.querySelector(`[data-cid="${c}"]`); return el ? parseFloat(getComputedStyle(el).opacity || "1") : -1; }, cid);
|
page.evaluate((c) => { const el = document.querySelector(`[data-cid="${c}"]`); return el ? parseFloat(getComputedStyle(el).opacity || "1") : -1; }, cid);
|
||||||
for (const rv of reveals.slice(0, 16)) {
|
for (const rv of reveals) {
|
||||||
try {
|
try {
|
||||||
await page.evaluate((c) => document.querySelector(`[data-cid="${c}"]`)?.scrollIntoView({ block: "center" }), rv.cid);
|
await page.evaluate((c) => document.querySelector(`[data-cid="${c}"]`)?.scrollIntoView({ block: "center" }), rv.cid);
|
||||||
await page.waitForTimeout(650); // allow the reveal transition to complete
|
await page.waitForTimeout(650); // allow the reveal transition to complete
|
||||||
@@ -212,10 +259,11 @@ export async function driveMotionGate(opts: {
|
|||||||
|
|
||||||
const cssExpected = expectedCss.length;
|
const cssExpected = expectedCss.length;
|
||||||
const droveOk = !issues.some((i) => i.startsWith("motion drive error"));
|
const droveOk = !issues.some((i) => i.startsWith("motion drive error"));
|
||||||
// Pass: every captured animation is either faithfully reproduced or honestly frozen
|
// Pass: every captured animation is either faithfully reproduced as static CSS, honestly
|
||||||
// (keyframes uncapturable), every WAAPI/rotator reproduced, and the driver didn't crash.
|
// frozen (keyframes uncapturable), or replayed through a DittoMotion reveal (graded by the
|
||||||
|
// scroll-reveal check); every WAAPI/rotator/reveal reproduced; and the driver didn't crash.
|
||||||
const pass = droveOk
|
const pass = droveOk
|
||||||
&& cssReproduced + cssFrozen === cssExpected
|
&& cssReproduced + cssFrozen + cssReplayed === cssExpected
|
||||||
&& waapiReproduced === waapi.length
|
&& waapiReproduced === waapi.length
|
||||||
&& rotatorsReproduced === rotators.length
|
&& rotatorsReproduced === rotators.length
|
||||||
&& revealsReproduced === reveals.length
|
&& revealsReproduced === reveals.length
|
||||||
@@ -225,7 +273,7 @@ export async function driveMotionGate(opts: {
|
|||||||
pass,
|
pass,
|
||||||
metrics: {
|
metrics: {
|
||||||
animations: cssExpected + waapi.length + rotators.length + reveals.length + marquees.length,
|
animations: cssExpected + waapi.length + rotators.length + reveals.length + marquees.length,
|
||||||
css: `${cssReproduced}/${cssExpected}`, cssReproduced, cssFrozen, cssRunning,
|
css: `${cssReproduced}/${cssExpected}`, cssReproduced, cssFrozen, cssReplayed, cssRunning,
|
||||||
waapi: `${waapiReproduced}/${waapi.length}`,
|
waapi: `${waapiReproduced}/${waapi.length}`,
|
||||||
rotators: `${rotatorsReproduced}/${rotators.length}`,
|
rotators: `${rotatorsReproduced}/${rotators.length}`,
|
||||||
reveals: `${revealsReproduced}/${reveals.length}`,
|
reveals: `${revealsReproduced}/${reveals.length}`,
|
||||||
|
|||||||
@@ -21,9 +21,54 @@ export type BuildResult = {
|
|||||||
durationMs: number;
|
durationMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Dependencies the app's package.json declares that are absent from the harness's
|
||||||
|
* preinstalled node_modules. The harness copies app SOURCE over its own tree but keeps its
|
||||||
|
* preinstalled node_modules/package.json (for speed + determinism), so a dep the generator
|
||||||
|
* injects for this clone but that the harness was never provisioned with (e.g. `lottie-web`,
|
||||||
|
* added by injectLottieDep only for clones with Lottie content) would be missing at build time
|
||||||
|
* → "Module not found" webpack error. Returns each such dep pinned to the app's exact version. */
|
||||||
|
export function missingHarnessDeps(appDir: string, harnessDir: string): Array<{ name: string; version: string }> {
|
||||||
|
const pkgPath = join(appDir, "package.json");
|
||||||
|
if (!existsSync(pkgPath)) return [];
|
||||||
|
let deps: Record<string, string> = {};
|
||||||
|
try { deps = (JSON.parse(readFileSync(pkgPath, "utf8")).dependencies ?? {}) as Record<string, string>; }
|
||||||
|
catch { return []; }
|
||||||
|
const out: Array<{ name: string; version: string }> = [];
|
||||||
|
for (const [name, version] of Object.entries(deps)) {
|
||||||
|
// A dep is present iff its package.json resolves under the harness node_modules.
|
||||||
|
if (!existsSync(join(harnessDir, "node_modules", name, "package.json"))) {
|
||||||
|
out.push({ name, version: String(version).replace(/^[\^~]/, "") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Install any app-declared dependency missing from the harness node_modules, pinned to the
|
||||||
|
* app's exact version. --no-save keeps the harness package.json/lockfile untouched (the install
|
||||||
|
* is per-clone and idempotent); returns an error string if the install failed (surfaced as a
|
||||||
|
* build-gate issue) or null on success/no-op. */
|
||||||
|
function ensureHarnessDeps(appDir: string, harnessDir: string): string | null {
|
||||||
|
const missing = missingHarnessDeps(appDir, harnessDir);
|
||||||
|
if (!missing.length) return null;
|
||||||
|
const specs = missing.map((d) => `${d.name}@${d.version}`);
|
||||||
|
const res = spawnSync("npm", ["install", "--no-save", "--no-audit", "--no-fund", ...specs], {
|
||||||
|
cwd: harnessDir,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env },
|
||||||
|
maxBuffer: 64 * 1024 * 1024,
|
||||||
|
timeout: 180000,
|
||||||
|
});
|
||||||
|
if (res.status !== 0) {
|
||||||
|
return `harness dep install failed for ${specs.join(", ")}: ${(res.stderr || res.stdout || "").split("\n").filter(Boolean).slice(-3).join(" | ")}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Build the generated app inside the shared harness (deps preinstalled). */
|
/** Build the generated app inside the shared harness (deps preinstalled). */
|
||||||
export function buildApp(appDir: string, harnessDir: string): BuildResult {
|
export function buildApp(appDir: string, harnessDir: string): BuildResult {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
|
const depErr = ensureHarnessDeps(appDir, harnessDir);
|
||||||
|
if (depErr) return { ok: false, outDir: null, stderr: depErr, durationMs: Date.now() - t0 };
|
||||||
const isVite = existsSync(join(appDir, "vite.config.ts")) || existsSync(join(appDir, "index.html"));
|
const isVite = existsSync(join(appDir, "vite.config.ts")) || existsSync(join(appDir, "index.html"));
|
||||||
if (isVite) {
|
if (isVite) {
|
||||||
for (const entry of readdirSync(harnessDir, { withFileTypes: true })) {
|
for (const entry of readdirSync(harnessDir, { withFileTypes: true })) {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { isRetryableAssetFailure, ASSET_RETRY_DELAY_MS } from "../src/capture/capture.js";
|
||||||
|
|
||||||
|
describe("asset download retry decision", () => {
|
||||||
|
it("retries visual/font assets on network error (no response)", () => {
|
||||||
|
for (const type of ["image", "svg", "video", "font"]) {
|
||||||
|
assert.equal(isRetryableAssetFailure(type, null), true, `${type} + network error retries`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries visual/font assets on transient server states (5xx, 429)", () => {
|
||||||
|
assert.equal(isRetryableAssetFailure("image", 500), true);
|
||||||
|
assert.equal(isRetryableAssetFailure("image", 503), true);
|
||||||
|
assert.equal(isRetryableAssetFailure("video", 502), true);
|
||||||
|
assert.equal(isRetryableAssetFailure("font", 429), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT retry authoritative 4xx failures (404/403/410)", () => {
|
||||||
|
assert.equal(isRetryableAssetFailure("image", 404), false);
|
||||||
|
assert.equal(isRetryableAssetFailure("image", 403), false);
|
||||||
|
assert.equal(isRetryableAssetFailure("video", 410), false);
|
||||||
|
assert.equal(isRetryableAssetFailure("font", 401), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT retry non-visual asset types at all", () => {
|
||||||
|
assert.equal(isRetryableAssetFailure("css", null), false);
|
||||||
|
assert.equal(isRetryableAssetFailure("manifest", 500), false);
|
||||||
|
assert.equal(isRetryableAssetFailure("lottie", 503), false);
|
||||||
|
assert.equal(isRetryableAssetFailure("other", null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a fixed, bounded retry delay (deterministic — no jitter)", () => {
|
||||||
|
assert.equal(typeof ASSET_RETRY_DELAY_MS, "number");
|
||||||
|
assert.ok(ASSET_RETRY_DELAY_MS > 0 && ASSET_RETRY_DELAY_MS <= 2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import type { IR, IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
|
||||||
|
import type { RawSizing } from "../src/capture/walker.js";
|
||||||
|
import { generateCss } from "../src/generate/css.js";
|
||||||
|
|
||||||
|
// Two-plus viewports so the per-vp fluid detectors (grid-template solve, width probe) can run.
|
||||||
|
const VPS = [375, 768, 1280];
|
||||||
|
const CANONICAL = 1280;
|
||||||
|
|
||||||
|
function computed(over: StyleMap = {}): StyleMap {
|
||||||
|
return { display: "block", position: "static", visibility: "visible", listStyleType: "disc", listStylePosition: "outside", ...over };
|
||||||
|
}
|
||||||
|
|
||||||
|
type PerVp = { cs?: StyleMap; bbox: BBox; sizing?: RawSizing; visible?: boolean };
|
||||||
|
|
||||||
|
/** Build a node with independent per-viewport computed style / bbox / sizing probe. */
|
||||||
|
function vpNode(id: string, tag: string, byVp: Record<number, PerVp>, children: IRChild[] = []): IRNode {
|
||||||
|
const computedByVp: Record<number, StyleMap> = {};
|
||||||
|
const bboxByVp: Record<number, BBox> = {};
|
||||||
|
const visibleByVp: Record<number, boolean> = {};
|
||||||
|
const sizingByVp: Record<number, RawSizing> = {};
|
||||||
|
for (const vp of VPS) {
|
||||||
|
const s = byVp[vp]!;
|
||||||
|
computedByVp[vp] = computed(s.cs);
|
||||||
|
bboxByVp[vp] = s.bbox;
|
||||||
|
visibleByVp[vp] = s.visible ?? true;
|
||||||
|
if (s.sizing) sizingByVp[vp] = s.sizing;
|
||||||
|
}
|
||||||
|
const n: IRNode = { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
|
||||||
|
if (Object.keys(sizingByVp).length) n.sizingByVp = sizingByVp;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function irWith(root: IRNode): IR {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
sourceUrl: "https://example.test/fidelity",
|
||||||
|
title: "Fidelity Fixture",
|
||||||
|
lang: "en",
|
||||||
|
charset: "UTF-8",
|
||||||
|
metaViewport: "width=device-width, initial-scale=1",
|
||||||
|
viewports: VPS,
|
||||||
|
sampleViewports: VPS,
|
||||||
|
canonicalViewport: CANONICAL,
|
||||||
|
perViewport: Object.fromEntries(VPS.map((vp) => [vp, { scrollHeight: 4000, scrollWidth: vp, htmlBg: "rgb(255,255,255)", bodyBg: "rgb(255,255,255)", bodyColor: "rgb(0,0,0)", bodyFont: "Arial" }])),
|
||||||
|
nodeCount: 8,
|
||||||
|
keyframes: [],
|
||||||
|
},
|
||||||
|
root,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The base-rule body for a selector (first non-banded `.c<id>{…}` block). */
|
||||||
|
function baseRule(css: string, id: string): string {
|
||||||
|
const m = css.match(new RegExp(`\\.c${id}\\{([^}]*)\\}`));
|
||||||
|
return m?.[1] ?? "";
|
||||||
|
}
|
||||||
|
/** Every `.c<id>{…}` body (base + banded) concatenated, for asserting a value appears at some vp. */
|
||||||
|
function allRules(css: string, id: string): string {
|
||||||
|
const re = new RegExp(`\\.c${id}\\{([^}]*)\\}`, "g");
|
||||||
|
let out = "", m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(css))) out += m[1] + ";";
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fills = (): RawSizing => ({ wAuto: false, wFill: true, hAuto: false, hFill: true });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FIX 1 — circular shrink-0 carousel slide keeps a definite width
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("FIX 1: circular carousel slide width pinning", () => {
|
||||||
|
// Track (flex) → slide <a> (shrink-0, probe says fill/auto) → inner (w-full h-full).
|
||||||
|
// The probe would drop the slide width; with an all-fill child it must instead be pinned.
|
||||||
|
function build(slideChildSizing: RawSizing, slideShrink = "0"): IR {
|
||||||
|
const slideW = { 375: 300, 768: 400, 1280: 566 } as Record<number, number>;
|
||||||
|
const inner = vpNode("n486", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||||
|
cs: { display: "block", position: "relative", width: `${slideW[vp]}px`, height: "640px", aspectRatio: "1 / 1" },
|
||||||
|
bbox: { x: 0, y: 0, width: slideW[vp]!, height: 640 },
|
||||||
|
sizing: slideChildSizing,
|
||||||
|
}])) as Record<number, PerVp>);
|
||||||
|
const slide = vpNode("n485", "a", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||||
|
cs: { display: "block", position: "relative", width: `${slideW[vp]}px`, height: "640px", flexShrink: slideShrink },
|
||||||
|
bbox: { x: 0, y: 0, width: slideW[vp]!, height: 640 },
|
||||||
|
// probe reads the slide as fill+content (Splide inline px looks derivable)
|
||||||
|
sizing: { wAuto: true, wFill: true, hAuto: true, hFill: false },
|
||||||
|
}])) as Record<number, PerVp>, [inner]);
|
||||||
|
const track = vpNode("n484", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||||
|
cs: { display: "flex", position: "static", width: `${vp}px`, height: "640px" },
|
||||||
|
bbox: { x: 0, y: 0, width: vp, height: 640 },
|
||||||
|
}])) as Record<number, PerVp>, [slide]);
|
||||||
|
return irWith(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("pins the captured px width on a shrink-0 slide whose only child fills it", () => {
|
||||||
|
const css = generateCss(build(fills()), new Map());
|
||||||
|
const rules = allRules(css, "n485");
|
||||||
|
// The captured canonical width (566px) must be emitted somewhere — not dropped to 100%/auto.
|
||||||
|
assert.match(rules, /width:\s*566px/, `slide should keep its captured width; got: ${rules}`);
|
||||||
|
assert.doesNotMatch(baseRule(css, "n485"), /width:\s*100%/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT pin when the child is a real in-flow content box (genuine width source)", () => {
|
||||||
|
// Child sizes to its own content (wAuto true, wFill false) → not the circular case.
|
||||||
|
const contentChild: RawSizing = { wAuto: true, wFill: false, hAuto: true, hFill: false };
|
||||||
|
const css = generateCss(build(contentChild), new Map());
|
||||||
|
const rules = allRules(css, "n485");
|
||||||
|
// With a genuine width source, the probe verdict is honored — no forced 566px pin.
|
||||||
|
assert.doesNotMatch(baseRule(css, "n485"), /width:\s*566px/, `content-child slide must not be force-pinned; got: ${rules}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT pin a shrinkable (shrink:1) item even with a fill child", () => {
|
||||||
|
const css = generateCss(build(fills(), "1"), new Map());
|
||||||
|
assert.doesNotMatch(baseRule(css, "n485"), /width:\s*566px/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FIX 1b: full-width shrink-0 carousel slide gets flex-basis:100%", () => {
|
||||||
|
// Flex list → shrink-0 slide whose border box FILLS the list (a full-viewport hero slide).
|
||||||
|
function build(): IR {
|
||||||
|
const slide = vpNode("n200", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||||
|
cs: { display: "block", position: "relative", width: `${vp}px`, flexShrink: "0" },
|
||||||
|
bbox: { x: 0, y: 0, width: vp, height: 462 },
|
||||||
|
sizing: { wAuto: false, wFill: true, hAuto: true, hFill: false },
|
||||||
|
}])) as Record<number, PerVp>);
|
||||||
|
const list = vpNode("n199", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||||
|
cs: { display: "flex", position: "static", flexDirection: "row", width: `${vp}px` },
|
||||||
|
bbox: { x: 0, y: 0, width: vp, height: 462 },
|
||||||
|
}])) as Record<number, PerVp>, [slide]);
|
||||||
|
return irWith(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("emits flex-basis:100% + flex-shrink:0 (not width:100%) for a container-filling shrink-0 slide", () => {
|
||||||
|
const rules = allRules(generateCss(build(), new Map()), "n200");
|
||||||
|
assert.match(rules, /flex-basis:\s*100%/, `full-width slide should get flex-basis:100%; got: ${rules}`);
|
||||||
|
assert.doesNotMatch(rules, /(?<!max-)width:\s*100%/, "should not use width:100% (flex max-content over-widening)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FIX 2 — fixed-track scroll grid keeps its px template; fluid grid still fr
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("FIX 2: fixed-track scroll grid vs fluid equal-track grid", () => {
|
||||||
|
// N equal tracks. `content` is the container content width; `sum` is total track+gap extent.
|
||||||
|
function gridNode(id: string, perVp: Record<number, { count: number; track: number; gap: number; content: number }>): IRNode {
|
||||||
|
return vpNode(id, "div", Object.fromEntries(VPS.map((vp) => {
|
||||||
|
const g = perVp[vp]!;
|
||||||
|
const cols = new Array(g.count).fill(`${g.track}px`).join(" ");
|
||||||
|
return [vp, {
|
||||||
|
cs: { display: "grid", position: "static", gridTemplateColumns: cols, columnGap: `${g.gap}px`, width: `${g.content}px`, overflowX: "auto" },
|
||||||
|
bbox: { x: 0, y: 0, width: g.content, height: 300 },
|
||||||
|
}];
|
||||||
|
})) as Record<number, PerVp>);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps the baked px template for a scrolling grid whose tracks overflow the container", () => {
|
||||||
|
// 50 tracks of 173.5px in a ~1066px container = 8675px of scrolling content.
|
||||||
|
const css = generateCss(irWith(gridNode("n550", {
|
||||||
|
375: { count: 50, track: 132.9, gap: 0, content: 326 },
|
||||||
|
768: { count: 50, track: 106.2, gap: 0, content: 662 },
|
||||||
|
1280: { count: 50, track: 173.5, gap: 0, content: 1066 },
|
||||||
|
})), new Map());
|
||||||
|
const rules = allRules(css, "n550");
|
||||||
|
assert.doesNotMatch(rules, /repeat\(50,/, `scrolling grid must not be rewritten as repeat(50,1fr); got: ${rules.slice(0, 200)}`);
|
||||||
|
assert.match(rules, /173\.5px/, "scrolling grid should keep its baked fixed-px tracks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites a single fixed-px full-bleed track as minmax(0,1fr) even on a custom-element grid", () => {
|
||||||
|
// uwp-carousel: one track == viewport width, baked per breakpoint. Must become a fill track.
|
||||||
|
const carousel = vpNode("n196", "uwp-carousel", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||||
|
cs: { display: "grid", position: "relative", gridTemplateColumns: `${vp}px`, columnGap: "normal", width: `${vp}px`, paddingLeft: "0px", paddingRight: "0px" },
|
||||||
|
bbox: { x: 0, y: 0, width: vp, height: 462 },
|
||||||
|
}])) as Record<number, PerVp>);
|
||||||
|
const rules = allRules(generateCss(irWith(carousel), new Map()), "n196");
|
||||||
|
assert.match(rules, /grid-template-columns:\s*minmax\(0,\s*1fr\)/, `single full-bleed track should fill: ${rules.slice(0, 200)}`);
|
||||||
|
assert.doesNotMatch(rules, /grid-template-columns:\s*\d/, "should not keep a baked px single track");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still rewrites a genuinely fluid equal-track grid as repeat(N, 1fr)", () => {
|
||||||
|
// 3 equal tracks that FILL the container (sum+gaps == content) at every width.
|
||||||
|
const css = generateCss(irWith(gridNode("n700", {
|
||||||
|
375: { count: 3, track: (375 - 2 * 16) / 3, gap: 16, content: 375 },
|
||||||
|
768: { count: 3, track: (768 - 2 * 16) / 3, gap: 16, content: 768 },
|
||||||
|
1280: { count: 3, track: (1280 - 2 * 16) / 3, gap: 16, content: 1280 },
|
||||||
|
})), new Map());
|
||||||
|
const rules = allRules(css, "n700");
|
||||||
|
assert.match(rules, /repeat\(3,\s*minmax\(0,\s*1fr\)\)/, `filling equal-track grid should become repeat(3,1fr); got: ${rules.slice(0, 200)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FIX 3b — inset-spanned absolute box with an aspect-ratio fills (width:100%)
|
||||||
|
// instead of width:auto (which back-computes width from height × aspect and
|
||||||
|
// over-widens at unsampled probe widths — the full-bleed responsive violations).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("FIX 3b: inset-spanned + aspect-ratio full-bleed box", () => {
|
||||||
|
// Positioned parent → absolute child (inset-x-0) that stretches to the parent width at every vp.
|
||||||
|
function bleed(childAspect?: string): IR {
|
||||||
|
const child = vpNode("n202", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||||
|
cs: {
|
||||||
|
display: "block", position: "absolute", top: "0px", left: "0px", right: "0px",
|
||||||
|
minHeight: "462px", ...(childAspect ? { aspectRatio: childAspect } : {}),
|
||||||
|
},
|
||||||
|
bbox: { x: 0, y: 0, width: vp, height: 462 },
|
||||||
|
}])) as Record<number, PerVp>);
|
||||||
|
const parent = vpNode("n201", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||||
|
cs: { display: "block", position: "relative", width: `${vp}px` },
|
||||||
|
bbox: { x: 0, y: 0, width: vp, height: 462 },
|
||||||
|
}])) as Record<number, PerVp>, [child]);
|
||||||
|
return irWith(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("emits width:100% for an inset-spanned absolute box that carries an aspect-ratio", () => {
|
||||||
|
const css = generateCss(bleed("16 / 9"), new Map());
|
||||||
|
assert.match(baseRule(css, "n202"), /width:\s*100%/, `aspect full-bleed box should fill: ${baseRule(css, "n202")}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps width:auto for an inset-spanned box with NO aspect-ratio (unchanged behaviour)", () => {
|
||||||
|
const css = generateCss(bleed(undefined), new Map());
|
||||||
|
assert.doesNotMatch(baseRule(css, "n202"), /width:\s*100%/, `plain inset-spanned box must not be forced to fill: ${baseRule(css, "n202")}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FIX 3 — authored height kept when the only in-flow child fills it
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("FIX 3: authored height with a fill-only child", () => {
|
||||||
|
// Hero (authored, height VARIES per vp — a responsive header) → child (h-full aspect-video). The
|
||||||
|
// child extent equals the parent's height at every vp ONLY because it fills. The varying height also
|
||||||
|
// exercises heightFlows' "varies" gate, which is the path that was wrongly dropping the height.
|
||||||
|
const HERO_H = { 375: 324.8, 768: 409.6, 1280: 240 } as Record<number, number>;
|
||||||
|
function hero(childSizing: RawSizing, parentSizing: RawSizing): IR {
|
||||||
|
const child = vpNode("n290", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||||
|
cs: { display: "block", position: "relative", height: `${HERO_H[vp]}px`, overflow: "hidden", aspectRatio: "16 / 9" },
|
||||||
|
bbox: { x: 0, y: 176, width: vp, height: HERO_H[vp]! },
|
||||||
|
sizing: childSizing,
|
||||||
|
}])) as Record<number, PerVp>);
|
||||||
|
const h = vpNode("n289", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||||
|
cs: { display: "block", position: "relative", height: `${HERO_H[vp]}px`, overflow: "visible" },
|
||||||
|
bbox: { x: 0, y: 176, width: vp, height: HERO_H[vp]! },
|
||||||
|
sizing: parentSizing,
|
||||||
|
}])) as Record<number, PerVp>, [child]);
|
||||||
|
return irWith(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps the authored height when the sole in-flow child is a fill child (height derives from parent)", () => {
|
||||||
|
const css = generateCss(hero(
|
||||||
|
{ wAuto: false, wFill: true, hAuto: false, hFill: true }, // child fills (h-full)
|
||||||
|
{ wAuto: true, wFill: true, hAuto: false, hFill: false }, // parent authored height (auto does NOT reproduce)
|
||||||
|
), new Map());
|
||||||
|
assert.match(baseRule(css, "n289"), /height:\s*240px/, `authored height must survive when child only fills it: ${baseRule(css, "n289")}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still drops the height when the child is real content flow (its extent IS the evidence)", () => {
|
||||||
|
// Child sizes to its own content (hAuto true, not a fill) AND the parent auto-height reproduces.
|
||||||
|
// Height varies per-vp so the heightFlows path is exercised — it must STILL flow (drop) here.
|
||||||
|
const child = vpNode("n290b", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||||
|
cs: { display: "block", position: "relative", height: `${HERO_H[vp]}px` },
|
||||||
|
bbox: { x: 0, y: 176, width: vp, height: HERO_H[vp]! },
|
||||||
|
sizing: { wAuto: true, wFill: true, hAuto: true, hFill: false }, // content-sized child
|
||||||
|
}])) as Record<number, PerVp>);
|
||||||
|
const h = vpNode("n289b", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||||
|
cs: { display: "block", position: "relative", height: `${HERO_H[vp]}px`, overflow: "visible" },
|
||||||
|
bbox: { x: 0, y: 176, width: vp, height: HERO_H[vp]! },
|
||||||
|
sizing: { wAuto: true, wFill: true, hAuto: true, hFill: false }, // parent auto-height reproduces → drop
|
||||||
|
}])) as Record<number, PerVp>, [child]);
|
||||||
|
const css = generateCss(irWith(h), new Map());
|
||||||
|
assert.doesNotMatch(baseRule(css, "n289b"), /height:\s*(?:240|324\.8|409\.6)px/, `content-flow parent should let height drop: ${baseRule(css, "n289b")}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import type { IR, IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
|
||||||
|
import { generateCss } from "../src/generate/css.js";
|
||||||
|
|
||||||
|
const VPS = [375, 1280];
|
||||||
|
const CANONICAL = 1280;
|
||||||
|
|
||||||
|
/** Computed style with the minimum the emitter reads, per viewport. */
|
||||||
|
function computed(over: StyleMap = {}): StyleMap {
|
||||||
|
return { display: "block", position: "static", visibility: "visible", listStyleType: "disc", listStylePosition: "outside", ...over };
|
||||||
|
}
|
||||||
|
|
||||||
|
function node(id: string, tag: string, cs: StyleMap, children: IRChild[] = [], visible = true): IRNode {
|
||||||
|
const computedByVp: Record<number, StyleMap> = {};
|
||||||
|
const bboxByVp: Record<number, BBox> = {};
|
||||||
|
const visibleByVp: Record<number, boolean> = {};
|
||||||
|
for (const vp of VPS) {
|
||||||
|
computedByVp[vp] = { ...cs };
|
||||||
|
bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 100 };
|
||||||
|
visibleByVp[vp] = visible;
|
||||||
|
}
|
||||||
|
return { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
|
||||||
|
}
|
||||||
|
|
||||||
|
function irWith(root: IRNode): IR {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
sourceUrl: "https://example.test/css",
|
||||||
|
title: "CSS Fixture",
|
||||||
|
lang: "en",
|
||||||
|
charset: "UTF-8",
|
||||||
|
metaViewport: "width=device-width, initial-scale=1",
|
||||||
|
viewports: VPS,
|
||||||
|
sampleViewports: VPS,
|
||||||
|
canonicalViewport: CANONICAL,
|
||||||
|
perViewport: Object.fromEntries(VPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)", bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial" }])),
|
||||||
|
nodeCount: 4,
|
||||||
|
keyframes: [],
|
||||||
|
},
|
||||||
|
root,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The base-rule body for a selector (first non-banded `.c<id>{…}` block). */
|
||||||
|
function baseRule(css: string, id: string): string {
|
||||||
|
const m = css.match(new RegExp(`\\.c${id}\\{([^}]*)\\}`));
|
||||||
|
return m?.[1] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("generateCss list markers", () => {
|
||||||
|
it("re-establishes list-style on a <ul> whose disc equals the parent's initial value", () => {
|
||||||
|
// list-style-type's initial value is `disc` on EVERY element, so a real <ul disc>
|
||||||
|
// equals its parent <div>'s computed value — but the reset (`ul, ol, menu
|
||||||
|
// { list-style: none; }`) breaks the inheritance chain, so it must still emit.
|
||||||
|
const li = node("n2", "li", computed({ display: "list-item" }));
|
||||||
|
const ul = node("n1", "ul", computed(), [li]);
|
||||||
|
const root = node("n0", "body", computed(), [ul]);
|
||||||
|
const css = generateCss(irWith(root), new Map());
|
||||||
|
assert.ok(baseRule(css, "n1").includes("list-style-type:disc"));
|
||||||
|
// The <li> inherits from the ul (not reset), so parent-equality elision still applies.
|
||||||
|
assert.ok(!baseRule(css, "n2").includes("list-style-type"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not emit list-style-type on non-list tags at the initial disc", () => {
|
||||||
|
const div = node("n1", "div", computed());
|
||||||
|
const root = node("n0", "body", computed(), [div]);
|
||||||
|
const css = generateCss(irWith(root), new Map());
|
||||||
|
assert.ok(!baseRule(css, "n1").includes("list-style-type"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateCss visibility", () => {
|
||||||
|
it("emits visibility:hidden for a node hidden at the canonical viewport", () => {
|
||||||
|
const hidden = node("n1", "div", computed({ visibility: "hidden" }), [], false);
|
||||||
|
const shown = node("n2", "div", computed());
|
||||||
|
const root = node("n0", "body", computed(), [hidden, shown]);
|
||||||
|
const css = generateCss(irWith(root), new Map());
|
||||||
|
assert.ok(baseRule(css, "n1").includes("visibility:hidden"));
|
||||||
|
assert.ok(!baseRule(css, "n2").includes("visibility"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores inherited visibility at a band where the node is shown", () => {
|
||||||
|
const n = node("n1", "div", computed({ visibility: "hidden" }), [], false);
|
||||||
|
n.computedByVp[375]!.visibility = "visible";
|
||||||
|
n.visibleByVp[375] = true;
|
||||||
|
const root = node("n0", "body", computed(), [n]);
|
||||||
|
const css = generateCss(irWith(root), new Map());
|
||||||
|
assert.ok(baseRule(css, "n1").includes("visibility:hidden"));
|
||||||
|
const band = css.match(/@media \(max-width: \d+px\) \{\n([\s\S]*?)\n\}/);
|
||||||
|
assert.ok(band?.[1]?.includes("visibility:inherit"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays silent on the descendants of a hidden subtree (inheritance covers them)", () => {
|
||||||
|
const child = node("n2", "div", computed({ visibility: "hidden" }), [], false);
|
||||||
|
const parent = node("n1", "div", computed({ visibility: "hidden" }), [child], false);
|
||||||
|
const root = node("n0", "body", computed(), [parent]);
|
||||||
|
const css = generateCss(irWith(root), new Map());
|
||||||
|
assert.ok(baseRule(css, "n1").includes("visibility:hidden"));
|
||||||
|
assert.ok(!baseRule(css, "n2").includes("visibility"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hidden-node banded geometry. A `visibility:hidden` box still PARTICIPATES in layout (unlike
|
||||||
|
// `display:none`), so the emitter must not let the base rule's baked CANONICAL geometry stand at
|
||||||
|
// widths where the capture measured something else — that is how a desktop `left:548px` slider
|
||||||
|
// arrow ends up parked, invisibly, 210px past the right edge of a 375px viewport.
|
||||||
|
describe("generateCss hidden-node banded geometry", () => {
|
||||||
|
const HVPS = [375, 1280, 1920];
|
||||||
|
type VpState = { cs?: StyleMap; bbox?: BBox; visible?: boolean };
|
||||||
|
function nodeAt(id: string, tag: string, byVp: Record<number, VpState>, children: IRChild[] = []): IRNode {
|
||||||
|
const computedByVp: Record<number, StyleMap> = {};
|
||||||
|
const bboxByVp: Record<number, BBox> = {};
|
||||||
|
const visibleByVp: Record<number, boolean> = {};
|
||||||
|
for (const vp of HVPS) {
|
||||||
|
const s = byVp[vp] ?? {};
|
||||||
|
computedByVp[vp] = computed(s.cs);
|
||||||
|
bboxByVp[vp] = s.bbox ?? { x: 0, y: 0, width: vp, height: 100 };
|
||||||
|
visibleByVp[vp] = s.visible ?? true;
|
||||||
|
}
|
||||||
|
return { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
|
||||||
|
}
|
||||||
|
function ir3(root: IRNode): IR {
|
||||||
|
const ir = irWith(root);
|
||||||
|
ir.doc.viewports = HVPS;
|
||||||
|
ir.doc.sampleViewports = HVPS;
|
||||||
|
ir.doc.perViewport = Object.fromEntries(HVPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)", bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial" }]));
|
||||||
|
return ir;
|
||||||
|
}
|
||||||
|
/** The `.c<id>{…}` body inside the first @media block whose query matches `mediaRe`. */
|
||||||
|
function bandRule(css: string, mediaRe: RegExp, id: string): string {
|
||||||
|
for (const m of css.matchAll(/@media ([^{]+) \{\n([\s\S]*?)\n\}/g)) {
|
||||||
|
if (!mediaRe.test(m[1]!)) continue;
|
||||||
|
const r = m[2]!.match(new RegExp(`\\.c${id}\\{([^}]*)\\}`));
|
||||||
|
if (r) return r[1]!;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const arrowCs = (left: string, hidden: boolean): StyleMap =>
|
||||||
|
({ display: "flex", position: "absolute", left, ...(hidden ? { visibility: "hidden" } : {}) });
|
||||||
|
|
||||||
|
it("emits the captured per-band geometry for a box its own visibility:hidden leaves occupying space", () => {
|
||||||
|
// Hidden at base AND at the mobile band with DIFFERENT lefts (the swiper-arrow shape): the
|
||||||
|
// band must carry the mobile left, not inherit the baked canonical one.
|
||||||
|
const arrow = nodeAt("n1", "div", {
|
||||||
|
375: { cs: arrowCs("37px", true), bbox: { x: 37, y: 0, width: 46, height: 46 }, visible: false },
|
||||||
|
1280: { cs: arrowCs("548px", true), bbox: { x: 548, y: 0, width: 46, height: 46 }, visible: false },
|
||||||
|
1920: { cs: arrowCs("588px", false), bbox: { x: 588, y: 0, width: 46, height: 46 } },
|
||||||
|
});
|
||||||
|
const root = nodeAt("n0", "body", { 1280: { cs: { position: "relative" } } }, [arrow]);
|
||||||
|
const css = generateCss(ir3(root), new Map());
|
||||||
|
assert.ok(baseRule(css, "n1").includes("visibility:hidden"));
|
||||||
|
assert.ok(baseRule(css, "n1").includes("left:548px"));
|
||||||
|
const mobile = bandRule(css, /max-width/, "n1");
|
||||||
|
assert.ok(mobile.includes("left:37px"), `mobile band should carry the captured left, got: ${mobile}`);
|
||||||
|
assert.ok(!mobile.includes("display:none"), "an occupying hidden box must stay in layout");
|
||||||
|
// The wide band where the node becomes visible keeps working: visibility restored + its left.
|
||||||
|
const wide = bandRule(css, /min-width/, "n1");
|
||||||
|
assert.ok(wide.includes("visibility:inherit"), `wide band should restore visibility, got: ${wide}`);
|
||||||
|
assert.ok(wide.includes("left:588px"), `wide band should carry the 1920 left, got: ${wide}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides a visibility:hidden box whose captured bbox is 0x0 with display:none at that band", () => {
|
||||||
|
// At 375 the hidden arrow occupied NOTHING (uninitialised swiper) — display:none reproduces
|
||||||
|
// "renders nothing, takes no space" and cannot extend the scrollable area.
|
||||||
|
const arrow = nodeAt("n1", "div", {
|
||||||
|
375: { cs: arrowCs("calc(50% - 52px)", true), bbox: { x: 0, y: 0, width: 0, height: 0 }, visible: false },
|
||||||
|
1280: { cs: arrowCs("548px", true), bbox: { x: 548, y: 0, width: 46, height: 46 }, visible: false },
|
||||||
|
1920: { cs: arrowCs("588px", false), bbox: { x: 588, y: 0, width: 46, height: 46 } },
|
||||||
|
});
|
||||||
|
const root = nodeAt("n0", "body", { 1280: { cs: { position: "relative" } } }, [arrow]);
|
||||||
|
const css = generateCss(ir3(root), new Map());
|
||||||
|
const mobile = bandRule(css, /max-width/, "n1");
|
||||||
|
assert.ok(mobile.includes("display:none"), `0x0 hidden band should be display:none, got: ${mobile}`);
|
||||||
|
const wide = bandRule(css, /min-width/, "n1");
|
||||||
|
assert.ok(wide.includes("visibility:inherit") && wide.includes("left:588px"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits display:none at a band where the node turns display:none even when hidden at base", () => {
|
||||||
|
// The base rule bakes an OCCUPYING visibility:hidden box (canonical geometry); without the
|
||||||
|
// band that box would render at mobile widths where the source had display:none.
|
||||||
|
const wrap = nodeAt("n1", "div", {
|
||||||
|
375: { cs: { display: "none", visibility: "hidden" }, bbox: { x: 0, y: 0, width: 0, height: 0 }, visible: false },
|
||||||
|
1280: { cs: { visibility: "hidden" }, bbox: { x: 40, y: 0, width: 1200, height: 574 }, visible: false },
|
||||||
|
1920: { cs: {}, bbox: { x: 320, y: 0, width: 1280, height: 533 } },
|
||||||
|
});
|
||||||
|
const root = nodeAt("n0", "body", {}, [wrap]);
|
||||||
|
const css = generateCss(ir3(root), new Map());
|
||||||
|
assert.ok(baseRule(css, "n1").includes("visibility:hidden"));
|
||||||
|
const mobile = bandRule(css, /max-width/, "n1");
|
||||||
|
assert.ok(mobile.includes("display:none"), `own display:none band must emit even when hidden at base, got: ${mobile}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits per-band geometry for an occupying box an ancestor hides at base AND at the band", () => {
|
||||||
|
// The cropin.com/cotton slider arrow: the elementor-widget ANCESTOR is visibility:hidden at
|
||||||
|
// 375/1280 (so the arrow is never ownHidden and never shownAtBase) yet the arrow's absolute
|
||||||
|
// box still occupies layout. Without a band the base's canonical left:548px parks it 210px
|
||||||
|
// past the right edge of a 375px viewport — the band must carry the captured mobile left.
|
||||||
|
const arrow = nodeAt("n2", "div", {
|
||||||
|
375: { cs: arrowCs("120px", true), bbox: { x: 112, y: 0, width: 46, height: 46 }, visible: false },
|
||||||
|
1280: { cs: arrowCs("548px", true), bbox: { x: 548, y: 0, width: 46, height: 46 }, visible: false },
|
||||||
|
1920: { cs: arrowCs("588px", false), bbox: { x: 588, y: 0, width: 46, height: 46 } },
|
||||||
|
});
|
||||||
|
const parent = nodeAt("n1", "div", {
|
||||||
|
375: { cs: { position: "relative", visibility: "hidden" }, visible: false },
|
||||||
|
1280: { cs: { position: "relative", visibility: "hidden" }, visible: false },
|
||||||
|
1920: { cs: { position: "relative" } },
|
||||||
|
}, [arrow]);
|
||||||
|
const root = nodeAt("n0", "body", {}, [parent]);
|
||||||
|
const css = generateCss(ir3(root), new Map());
|
||||||
|
const mobile = bandRule(css, /max-width/, "n2");
|
||||||
|
assert.ok(mobile.includes("left:120px"), `mobile band should carry the captured left, got: ${mobile}`);
|
||||||
|
assert.ok(!mobile.includes("display:none"), "an occupying hidden box must stay in layout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still emits only the hide for a box an ANCESTOR's visibility:hidden covers", () => {
|
||||||
|
// Inherited hides stay breakpoint noise: the ancestor's own rule (and its geometry
|
||||||
|
// correction) covers the subtree — the child emits its hide, not geometry overrides.
|
||||||
|
const child = nodeAt("n2", "div", {
|
||||||
|
375: { cs: { position: "absolute", left: "10px", visibility: "hidden" }, bbox: { x: 10, y: 0, width: 40, height: 40 }, visible: false },
|
||||||
|
1280: { cs: { position: "absolute", left: "500px" }, bbox: { x: 500, y: 0, width: 40, height: 40 } },
|
||||||
|
1920: { cs: { position: "absolute", left: "500px" }, bbox: { x: 500, y: 0, width: 40, height: 40 } },
|
||||||
|
});
|
||||||
|
const parent = nodeAt("n1", "div", {
|
||||||
|
375: { cs: { position: "relative", visibility: "hidden" }, visible: false },
|
||||||
|
1280: { cs: { position: "relative" } },
|
||||||
|
1920: { cs: { position: "relative" } },
|
||||||
|
}, [child]);
|
||||||
|
const root = nodeAt("n0", "body", {}, [parent]);
|
||||||
|
const css = generateCss(ir3(root), new Map());
|
||||||
|
const mobile = bandRule(css, /max-width/, "n2");
|
||||||
|
assert.ok(mobile.includes("visibility:hidden"), `child should carry the hide, got: ${mobile}`);
|
||||||
|
assert.ok(!mobile.includes("left:10px"), `ancestor-hidden child must not emit geometry, got: ${mobile}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fix 1 — mobile nav chip-strip overlap. A horizontally-scrollable flex strip (overflow-x:auto
|
||||||
|
// flex <ul>) holds nowrap chips; the base viewport can report `min-width:0px` on those flex-item
|
||||||
|
// chips (e.g. a mobile-only strip collapsed to 0 at desktop). Emitting `min-w-0` lets the chips
|
||||||
|
// compress below their content width instead of overflowing, collapsing the scroll strip so the
|
||||||
|
// nowrap chip text collides ("Pizza OvenSpiral Mixe…"). The emitter must suppress `min-w-0` for a
|
||||||
|
// nowrap flex item inside an overflow-x:auto/scroll flex parent.
|
||||||
|
describe("generateCss scroll-strip chip min-width", () => {
|
||||||
|
it("suppresses min-w-0 on a nowrap chip inside an overflow-x:auto flex strip", () => {
|
||||||
|
const chip = node("n2", "li", computed({ display: "list-item", minWidth: "0px", whiteSpace: "nowrap" }));
|
||||||
|
const ul = node("n1", "ul", computed({ display: "flex", overflowX: "auto", columnGap: "8px" }), [chip]);
|
||||||
|
const root = node("n0", "body", computed(), [ul]);
|
||||||
|
const css = generateCss(irWith(root), new Map());
|
||||||
|
assert.ok(!baseRule(css, "n2").includes("min-width"), `chip must not carry min-width:0 in a scroll strip, got: ${baseRule(css, "n2")}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still emits min-w-0 for a nowrap flex item whose parent does NOT scroll horizontally", () => {
|
||||||
|
// A truncation/ellipsis flex child (parent overflow-x:visible) legitimately needs min-w-0 to
|
||||||
|
// shrink below content — the fix is scoped to overflow-x:auto/scroll parents, so this is kept.
|
||||||
|
const item = node("n2", "div", computed({ minWidth: "0px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }));
|
||||||
|
const row = node("n1", "div", computed({ display: "flex" }), [item]);
|
||||||
|
const root = node("n0", "body", computed(), [row]);
|
||||||
|
const css = generateCss(irWith(root), new Map());
|
||||||
|
assert.ok(baseRule(css, "n2").includes("min-width:0"), `non-scrolling flex row keeps min-w-0, got: ${baseRule(css, "n2")}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fix 3 — scroll-linked text-fill frozen at end state. A scroll/view-timeline animation reports
|
||||||
|
// its resolved `animation-duration` as `auto`. The clone has no scroll timeline, so emitting the
|
||||||
|
// animation-* props makes it jump straight to its END keyframe (fill-mode:both + a 0s time-based
|
||||||
|
// duration), freezing e.g. a text-fill 100% filled at rest. The emitter must suppress the
|
||||||
|
// animation-* longhands when animation-duration is `auto`, leaving the captured at-rest statics.
|
||||||
|
describe("generateCss scroll-timeline animation suppression", () => {
|
||||||
|
it("drops animation-* props for an animation-duration:auto (scroll-timeline) node", () => {
|
||||||
|
const em = node("n1", "em", computed({
|
||||||
|
animationName: "fillAnimation", animationDuration: "auto",
|
||||||
|
animationTimingFunction: "linear", animationFillMode: "both",
|
||||||
|
backgroundClip: "text",
|
||||||
|
}));
|
||||||
|
const root = node("n0", "body", computed(), [em]);
|
||||||
|
const css = generateCss(irWith(root), new Map());
|
||||||
|
const rule = baseRule(css, "n1");
|
||||||
|
assert.ok(!rule.includes("animation-name"), `scroll-timeline node must not emit animation-name, got: ${rule}`);
|
||||||
|
assert.ok(!rule.includes("animation-duration"), `scroll-timeline node must not emit animation-duration, got: ${rule}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still emits animation-* for a normal time-based (finite duration) animation", () => {
|
||||||
|
const el = node("n1", "div", computed({
|
||||||
|
animationName: "fadeInUp", animationDuration: "1.25s",
|
||||||
|
animationTimingFunction: "ease", animationFillMode: "both",
|
||||||
|
}));
|
||||||
|
const root = node("n0", "body", computed(), [el]);
|
||||||
|
const css = generateCss(irWith(root), new Map());
|
||||||
|
const rule = baseRule(css, "n1");
|
||||||
|
assert.ok(rule.includes("animation-name:fadeInUp"), `time-based reveal keeps its animation, got: ${rule}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
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 {
|
||||||
|
planForFrameUrl, graftFrameIntoSnapshot, frameHasRenderableContent, findFrameNode,
|
||||||
|
} from "../src/capture/graft.js";
|
||||||
|
import { captureSite } from "../src/capture/capture.js";
|
||||||
|
import { buildIR, isTextChild, type IRNode } from "../src/normalize/ir.js";
|
||||||
|
import { resolveTag, propsList } from "../src/generate/app.js";
|
||||||
|
import { readJSON } from "../src/util/fsx.js";
|
||||||
|
|
||||||
|
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "..", "fixtures");
|
||||||
|
|
||||||
|
// ---- Synthetic snapshot helpers (unit tests for the merge/offset logic) ----
|
||||||
|
|
||||||
|
function isText(c: RawChild): c is { text: string } {
|
||||||
|
return (c as { text?: string }).text !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function raw(tag: string, attrs: Record<string, string> = {}, children: RawChild[] = [], over: Partial<RawNode> = {}): RawNode {
|
||||||
|
return {
|
||||||
|
tag, attrs,
|
||||||
|
computed: { display: "block", position: "static" },
|
||||||
|
bbox: { x: 0, y: 0, width: 100, height: 50 },
|
||||||
|
visible: true,
|
||||||
|
children,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageSnap(root: RawNode, url = "https://host.test/page"): PageSnapshot {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
url, title: "t",
|
||||||
|
head: { description: "", canonical: "", ogTitle: "", ogDescription: "", ogImage: "", ogType: "", ogSiteName: "", twitterCard: "", themeColor: "" },
|
||||||
|
lang: "en", charset: "UTF-8", viewportWidth: 800, viewportHeight: 600,
|
||||||
|
scrollWidth: 800, scrollHeight: 600, htmlBg: "", bodyBg: "", bodyColor: "", bodyFont: "",
|
||||||
|
metaViewport: "", nodeCount: 3, truncated: false,
|
||||||
|
},
|
||||||
|
root, cssVars: {}, fontFaces: [], cssUrls: [], domAssets: [], keyframes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("planForFrameUrl", () => {
|
||||||
|
it("skips javascript: frames and ad/analytics/captcha domains", () => {
|
||||||
|
assert.equal(planForFrameUrl("javascript:void(0)"), "skip");
|
||||||
|
assert.equal(planForFrameUrl("https://googleads.g.doubleclick.net/pagead/ads"), "skip");
|
||||||
|
assert.equal(planForFrameUrl("https://www.googletagmanager.com/ns.html?id=GTM-X"), "skip");
|
||||||
|
assert.equal(planForFrameUrl("https://www.google.com/recaptcha/api2/anchor"), "skip");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grafts blank/empty-src frames (JS-populated same-origin embeds), visibility-gated upstream", () => {
|
||||||
|
// Klaviyo lightbox signup, loyalty popups etc. render into a blank same-origin iframe via
|
||||||
|
// script — no navigable URL, but real content. Invisible tracking pixels sharing a blank
|
||||||
|
// src are dropped by the cand.visible gate in capture.ts, not here.
|
||||||
|
assert.equal(planForFrameUrl(""), "graft");
|
||||||
|
assert.equal(planForFrameUrl("about:blank"), "graft");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("screenshots media-player embeds instead of grafting their JS-built DOM", () => {
|
||||||
|
assert.equal(planForFrameUrl("https://www.youtube.com/embed/abc123"), "still");
|
||||||
|
assert.equal(planForFrameUrl("https://player.vimeo.com/video/1"), "still");
|
||||||
|
assert.equal(planForFrameUrl("https://www.google.com/maps/embed?pb=x"), "still");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grafts everything else (form/newsletter embeds)", () => {
|
||||||
|
assert.equal(planForFrameUrl("https://static-forms.klaviyo.com/forms/abc"), "graft");
|
||||||
|
assert.equal(planForFrameUrl("http://127.0.0.1:4001/iframe-embed.html"), "graft");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("graftFrameIntoSnapshot", () => {
|
||||||
|
const mkHost = (): PageSnapshot =>
|
||||||
|
pageSnap(raw("body", {}, [
|
||||||
|
raw("iframe", { "data-ditto-frame": "0", src: "https://frame.test/embed" }, [], {
|
||||||
|
bbox: { x: 40, y: 30, width: 320, height: 160 },
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
|
||||||
|
const mkFrame = (): PageSnapshot => {
|
||||||
|
const input = raw("input", { id: "email", placeholder: "Enter your email" }, [], {
|
||||||
|
bbox: { x: 12, y: 34, width: 200, height: 30 },
|
||||||
|
});
|
||||||
|
const label = raw("label", { for: "email" }, [{ text: "Email" }]);
|
||||||
|
const anchor = raw("a", { href: "#terms" }, [{ text: "Terms" }]);
|
||||||
|
const img = raw("img", { src: "/pixel.png", srcset: "/pixel.png 1x, /pixel@2x.png 2x" });
|
||||||
|
const form = raw("form", { id: "signup" }, [label, input, anchor, img], {
|
||||||
|
bbox: { x: 0, y: 0, width: 320, height: 160 },
|
||||||
|
});
|
||||||
|
return pageSnap(raw("body", {}, [form]), "https://frame.test/embed");
|
||||||
|
};
|
||||||
|
|
||||||
|
it("grafts the frame body as a <div> child of the iframe with offset bboxes", () => {
|
||||||
|
const host = mkHost();
|
||||||
|
const ok = graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, mkFrame());
|
||||||
|
assert.equal(ok, true);
|
||||||
|
const iframe = findFrameNode(host.root, 0)!;
|
||||||
|
assert.equal(iframe.children.length, 1);
|
||||||
|
const wrapper = iframe.children[0] as RawNode;
|
||||||
|
assert.equal(wrapper.tag, "div", "the grafted <body> is retagged to <div>");
|
||||||
|
const form = wrapper.children[0] as RawNode;
|
||||||
|
assert.deepEqual([form.bbox.x, form.bbox.y], [40, 30], "frame-doc coords shift by the content-box origin");
|
||||||
|
const input = form.children.find((c) => !isText(c) && (c as RawNode).tag === "input") as RawNode;
|
||||||
|
assert.deepEqual([input.bbox.x, input.bbox.y], [52, 64]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("namespaces ids/for/#href so two frames cannot collide, and absolutizes frame-relative URLs", () => {
|
||||||
|
const host = mkHost();
|
||||||
|
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, mkFrame());
|
||||||
|
const iframe = findFrameNode(host.root, 0)!;
|
||||||
|
const form = (iframe.children[0] as RawNode).children[0] as RawNode;
|
||||||
|
assert.equal(form.attrs.id, "f0-signup");
|
||||||
|
const byTag = (t: string): RawNode => form.children.find((c) => !isText(c) && (c as RawNode).tag === t) as RawNode;
|
||||||
|
assert.equal(byTag("input").attrs.id, "f0-email");
|
||||||
|
assert.equal(byTag("label").attrs.for, "f0-email");
|
||||||
|
assert.equal(byTag("a").attrs.href, "#f0-terms");
|
||||||
|
assert.equal(byTag("img").attrs.src, "https://frame.test/pixel.png");
|
||||||
|
assert.equal(byTag("img").attrs.srcset, "https://frame.test/pixel.png 1x, https://frame.test/pixel@2x.png 2x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes the iframe clip like a real frame viewport (overflow:hidden)", () => {
|
||||||
|
const host = mkHost();
|
||||||
|
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, mkFrame());
|
||||||
|
const iframe = findFrameNode(host.root, 0)!;
|
||||||
|
assert.equal(iframe.computed.overflow, "hidden");
|
||||||
|
assert.equal(iframe.computed.overflowY, "hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to graft an empty/invisible frame (screenshot fallback territory)", () => {
|
||||||
|
const host = mkHost();
|
||||||
|
const empty = pageSnap(raw("body", {}, [raw("div", {}, [], { visible: false, bbox: { x: 0, y: 0, width: 0, height: 0 } })]), "https://frame.test/embed");
|
||||||
|
assert.equal(frameHasRenderableContent(empty.root), false);
|
||||||
|
assert.equal(graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, empty), false);
|
||||||
|
assert.equal(findFrameNode(host.root, 0)!.children.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges the frame's @keyframes into the page snapshot", () => {
|
||||||
|
const host = mkHost();
|
||||||
|
const frame = mkFrame();
|
||||||
|
frame.keyframes = ["@keyframes spin { to { transform: rotate(360deg); } }"];
|
||||||
|
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame);
|
||||||
|
assert.ok(host.keyframes.includes("@keyframes spin { to { transform: rotate(360deg); } }"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Cross-origin integration: capture → snapshot graft → IR → generated tag ----
|
||||||
|
|
||||||
|
describe("cross-origin iframe capture (integration)", () => {
|
||||||
|
let hostServer: Server;
|
||||||
|
let frameServer: Server;
|
||||||
|
let hostUrl = "";
|
||||||
|
let frameOrigin = "";
|
||||||
|
let outDir = "";
|
||||||
|
|
||||||
|
// A 1x1 transparent PNG so the frame's <img src="/pixel.png"> resolves and downloads.
|
||||||
|
const PIXEL = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const frameHtml = readFileSync(join(FIXTURES, "iframe-embed.html"), "utf8");
|
||||||
|
frameServer = createServer((req, res) => {
|
||||||
|
if (req.url?.startsWith("/pixel.png")) {
|
||||||
|
res.writeHead(200, { "content-type": "image/png" });
|
||||||
|
res.end(PIXEL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { "content-type": "text/html" });
|
||||||
|
res.end(frameHtml);
|
||||||
|
});
|
||||||
|
await new Promise<void>((r) => frameServer.listen(0, "127.0.0.1", r));
|
||||||
|
frameOrigin = `http://127.0.0.1:${(frameServer.address() as { port: number }).port}`;
|
||||||
|
|
||||||
|
const hostHtml = readFileSync(join(FIXTURES, "iframe-host.html"), "utf8").replaceAll("{{FRAME_ORIGIN}}", frameOrigin);
|
||||||
|
hostServer = createServer((_req, res) => {
|
||||||
|
res.writeHead(200, { "content-type": "text/html" });
|
||||||
|
res.end(hostHtml);
|
||||||
|
});
|
||||||
|
await new Promise<void>((r) => hostServer.listen(0, "127.0.0.1", r));
|
||||||
|
// localhost:portA vs localhost:portB are DIFFERENT origins — the in-page walker
|
||||||
|
// cannot see into the frame; only the Node-side graft can.
|
||||||
|
hostUrl = `http://127.0.0.1:${(hostServer.address() as { port: number }).port}/`;
|
||||||
|
outDir = mkdtempSync(join(tmpdir(), "ditto-iframe-graft-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
hostServer?.close();
|
||||||
|
frameServer?.close();
|
||||||
|
rmSync(outDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grafts the cross-origin form into the snapshot, merges its assets, and generates a <div>", async () => {
|
||||||
|
const capture = await captureSite({
|
||||||
|
url: hostUrl,
|
||||||
|
outDir,
|
||||||
|
viewports: [800],
|
||||||
|
breakpoints: false,
|
||||||
|
screenshots: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1) The snapshot carries the grafted subtree under the iframe node.
|
||||||
|
const snap = readJSON<PageSnapshot>(join(outDir, "capture", "dom-800.json"));
|
||||||
|
const iframe = findFrameNode(snap.root, 0);
|
||||||
|
assert.ok(iframe, "the iframe was stamped and captured");
|
||||||
|
assert.equal(iframe!.children.length, 1, "the frame document was grafted");
|
||||||
|
const wrapper = iframe!.children[0] as RawNode;
|
||||||
|
assert.equal(wrapper.tag, "div");
|
||||||
|
|
||||||
|
const findIn = (n: RawNode, pred: (x: RawNode) => boolean): RawNode | null => {
|
||||||
|
if (pred(n)) return n;
|
||||||
|
for (const c of n.children) {
|
||||||
|
if (isText(c)) continue;
|
||||||
|
const hit = findIn(c, pred);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const input = findIn(wrapper, (n) => n.tag === "input")!;
|
||||||
|
assert.ok(input, "the email input is part of the graft");
|
||||||
|
assert.equal(input.attrs.id, "f0-email", "frame-internal ids are namespaced");
|
||||||
|
assert.equal(input.attrs.placeholder, "Enter your email");
|
||||||
|
assert.equal(input.placeholder?.color, "rgb(200, 150, 100)", "::placeholder captured inside the frame");
|
||||||
|
// The iframe sits at margin-left:40px — grafted geometry is in page coordinates.
|
||||||
|
assert.ok(input.bbox.x >= 40, `input.bbox.x (${input.bbox.x}) offset by the iframe origin`);
|
||||||
|
const label = findIn(wrapper, (n) => n.tag === "label")!;
|
||||||
|
assert.equal(label.attrs.for, "f0-email");
|
||||||
|
const button = findIn(wrapper, (n) => n.tag === "button")!;
|
||||||
|
assert.ok(button.visible, "the Sign up button is visible in the graft");
|
||||||
|
|
||||||
|
// 2) The frame's assets flow through the normal pipeline (absolute frame URL, downloaded).
|
||||||
|
const pixel = capture.assets.find((a) => a.url === `${frameOrigin}/pixel.png`);
|
||||||
|
assert.ok(pixel, "the frame-relative <img> was discovered under the frame's origin");
|
||||||
|
assert.ok(pixel!.storedAs, "and its bytes were stored");
|
||||||
|
|
||||||
|
// 3) IR keeps the grafted children as ordinary nodes; generation emits a <div>.
|
||||||
|
const ir = buildIR(outDir, [800]);
|
||||||
|
const findIr = (n: IRNode, pred: (x: IRNode) => boolean): IRNode | null => {
|
||||||
|
if (pred(n)) return n;
|
||||||
|
for (const c of n.children) {
|
||||||
|
if (isTextChild(c)) continue;
|
||||||
|
const hit = findIr(c, pred);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const irIframe = findIr(ir.root, (n) => n.tag === "iframe")!;
|
||||||
|
assert.ok(irIframe, "iframe survives into the IR");
|
||||||
|
assert.ok(irIframe.children.some((c) => !isTextChild(c)), "grafted children survive the IR prune");
|
||||||
|
assert.equal(resolveTag(irIframe, false), "div", "a grafted iframe renders as a positioned <div>");
|
||||||
|
const props = new Map(propsList(irIframe, new Map(), hostUrl));
|
||||||
|
assert.equal(props.get("src"), undefined, "document-loading attrs stay dropped");
|
||||||
|
const irInput = findIr(ir.root, (n) => n.tag === "input")!;
|
||||||
|
assert.ok(irInput.placeholderByVp?.[800], "placeholder style flows into the IR");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { RawNode, RawChild } from "../src/capture/walker.js";
|
||||||
|
import { buildIR, isTextChild, type IRNode } from "../src/normalize/ir.js";
|
||||||
|
|
||||||
|
const VPS = [375, 1280];
|
||||||
|
|
||||||
|
function raw(tag: string, attrs: Record<string, string> = {}, children: RawChild[] = [], visible = true): RawNode {
|
||||||
|
return {
|
||||||
|
tag, attrs,
|
||||||
|
computed: { display: visible ? "block" : "none", position: "static", visibility: "visible" },
|
||||||
|
bbox: { x: 0, y: 0, width: visible ? 640 : 0, height: visible ? 360 : 0 },
|
||||||
|
visible,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal PageSnapshot JSON around a body tree (same tree at every viewport). */
|
||||||
|
function snapshot(vp: number, root: RawNode): object {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
url: "https://example.test/page", title: "Fixture",
|
||||||
|
head: { description: "", canonical: "", ogTitle: "", ogDescription: "", ogImage: "", ogType: "", ogSiteName: "", twitterCard: "", themeColor: "" },
|
||||||
|
lang: "en", charset: "UTF-8", viewportWidth: vp, viewportHeight: 800,
|
||||||
|
scrollWidth: vp, scrollHeight: 800, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)",
|
||||||
|
bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial", metaViewport: "width=device-width, initial-scale=1",
|
||||||
|
nodeCount: 10, truncated: false,
|
||||||
|
},
|
||||||
|
root, cssVars: {}, fontFaces: [], cssUrls: [], domAssets: [], keyframes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFixtureIR(root: RawNode): IRNode {
|
||||||
|
const sourceDir = mkdtempSync(join(tmpdir(), "ditto-ir-prune-"));
|
||||||
|
mkdirSync(join(sourceDir, "capture"), { recursive: true });
|
||||||
|
for (const vp of VPS) {
|
||||||
|
writeFileSync(join(sourceDir, "capture", `dom-${vp}.json`), JSON.stringify(snapshot(vp, structuredClone(root))));
|
||||||
|
}
|
||||||
|
return buildIR(sourceDir, VPS).root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findByTag(node: IRNode, tag: string): IRNode | null {
|
||||||
|
if (node.tag === tag) return node;
|
||||||
|
for (const c of node.children) {
|
||||||
|
if (isTextChild(c)) continue;
|
||||||
|
const hit = findByTag(c, tag);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("IR prune keeps <source> media candidates", () => {
|
||||||
|
it("keeps invisible source children of <picture> and <video>, still pruning other invisibles", () => {
|
||||||
|
// <source> is 0×0 at EVERY viewport (never painted), which the visibility prune
|
||||||
|
// reads as unobserved — real case (ooni.com): 47 <source> in dom-1280.json, 0 in
|
||||||
|
// ir.json, so the mobile img.src got baked into desktop layouts.
|
||||||
|
const picture = raw("picture", {}, [
|
||||||
|
raw("source", { srcset: "/img/hero-1280.jpg 1x", media: "(min-width: 768px)" }, [], false),
|
||||||
|
raw("img", { src: "/img/hero-375.jpg", alt: "hero" }),
|
||||||
|
]);
|
||||||
|
const video = raw("video", { autoplay: "" }, [
|
||||||
|
raw("source", { src: "/media/hero.mp4", type: "video/mp4" }, [], false),
|
||||||
|
]);
|
||||||
|
const noise = raw("div", {}, [raw("span", {}, [], false)], false); // invisible everywhere → pruned
|
||||||
|
const body = raw("body", {}, [picture, video, noise]);
|
||||||
|
|
||||||
|
const root = buildFixtureIR(body);
|
||||||
|
|
||||||
|
const pic = findByTag(root, "picture");
|
||||||
|
assert.ok(pic, "picture survives");
|
||||||
|
const picTags = pic!.children.filter((c) => !isTextChild(c)).map((c) => (c as IRNode).tag);
|
||||||
|
assert.deepEqual(picTags, ["source", "img"]);
|
||||||
|
const src = findByTag(pic!, "source")!;
|
||||||
|
assert.equal(src.attrs.srcset, "/img/hero-1280.jpg 1x");
|
||||||
|
assert.equal(src.attrs.media, "(min-width: 768px)");
|
||||||
|
|
||||||
|
const vid = findByTag(root, "video");
|
||||||
|
assert.ok(vid, "video survives");
|
||||||
|
const vidTags = vid!.children.filter((c) => !isTextChild(c)).map((c) => (c as IRNode).tag);
|
||||||
|
assert.deepEqual(vidTags, ["source"]);
|
||||||
|
|
||||||
|
// The carve-out is scoped: unrelated invisible-everywhere subtrees still prune.
|
||||||
|
assert.equal(findByTag(root, "span"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not keep a <source> outside picture/video, nor resurrect a pruned picture", () => {
|
||||||
|
const orphan = raw("div", {}, [raw("source", { srcset: "/x.jpg" }, [], false)]);
|
||||||
|
// A picture invisible everywhere with no visible descendants is still pruned whole.
|
||||||
|
const ghost = raw("picture", {}, [raw("source", { srcset: "/y.jpg" }, [], false)], false);
|
||||||
|
const body = raw("body", {}, [orphan, ghost]);
|
||||||
|
|
||||||
|
const root = buildFixtureIR(body);
|
||||||
|
|
||||||
|
assert.equal(findByTag(root, "source"), null);
|
||||||
|
assert.equal(findByTag(root, "picture"), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import type { IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
|
||||||
|
import { propsList, renderChildrenJsx } from "../src/generate/app.js";
|
||||||
|
|
||||||
|
const VPS = [375, 1280];
|
||||||
|
const SOURCE = "https://example.test/page";
|
||||||
|
const GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||||
|
|
||||||
|
function el(id: string, tag: string, attrs: Record<string, string> = {}, children: IRChild[] = [], visible = true): IRNode {
|
||||||
|
const computedByVp: Record<number, StyleMap> = {};
|
||||||
|
const bboxByVp: Record<number, BBox> = {};
|
||||||
|
const visibleByVp: Record<number, boolean> = {};
|
||||||
|
for (const vp of VPS) {
|
||||||
|
computedByVp[vp] = { display: "block", position: "static", visibility: "visible" };
|
||||||
|
bboxByVp[vp] = { x: 0, y: 0, width: visible ? 640 : 0, height: visible ? 360 : 0 };
|
||||||
|
visibleByVp[vp] = visible;
|
||||||
|
}
|
||||||
|
return { id, tag, attrs, visibleByVp, bboxByVp, computedByVp, children };
|
||||||
|
}
|
||||||
|
|
||||||
|
function props(node: IRNode, assetMap: Map<string, string>): Map<string, string> {
|
||||||
|
return new Map(propsList(node, assetMap, SOURCE));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("video src emission", () => {
|
||||||
|
it("ships the local file and mirrors captured playback attrs when the src materialized", () => {
|
||||||
|
const assetMap = new Map([
|
||||||
|
["https://example.test/media/hero.mp4", "/assets/cloned/videos/ab.mp4"],
|
||||||
|
["https://example.test/img/poster.jpg", "/assets/cloned/images/cd.jpg"],
|
||||||
|
]);
|
||||||
|
const video = el("n1", "video", {
|
||||||
|
src: "/media/hero.mp4", poster: "/img/poster.jpg",
|
||||||
|
autoplay: "", loop: "", muted: "", playsinline: "",
|
||||||
|
});
|
||||||
|
const p = props(video, assetMap);
|
||||||
|
assert.equal(p.get("src"), JSON.stringify("/assets/cloned/videos/ab.mp4"));
|
||||||
|
assert.equal(p.get("poster"), JSON.stringify("/assets/cloned/images/cd.jpg"));
|
||||||
|
assert.equal(p.get("autoPlay"), "true");
|
||||||
|
assert.equal(p.get("loop"), "true");
|
||||||
|
assert.equal(p.get("muted"), "true");
|
||||||
|
assert.equal(p.get("playsInline"), "true");
|
||||||
|
// preload is mirrored, not forced: none captured → none emitted.
|
||||||
|
assert.equal(p.get("preload"), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to poster-only when no video file materialized", () => {
|
||||||
|
const assetMap = new Map([
|
||||||
|
["https://example.test/img/poster.jpg", "/assets/cloned/images/cd.jpg"],
|
||||||
|
]);
|
||||||
|
const video = el("n1", "video", { src: "/media/hero.mp4", poster: "/img/poster.jpg", autoplay: "", loop: "" });
|
||||||
|
const p = props(video, assetMap);
|
||||||
|
assert.equal(p.get("src"), undefined);
|
||||||
|
assert.equal(p.get("autoPlay"), undefined);
|
||||||
|
assert.equal(p.get("loop"), undefined);
|
||||||
|
assert.equal(p.get("poster"), JSON.stringify("/assets/cloned/images/cd.jpg"));
|
||||||
|
assert.equal(p.get("preload"), JSON.stringify("none"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a materialized child <source> as a shippable video (src carried by the child)", () => {
|
||||||
|
const assetMap = new Map([["https://example.test/media/hero.webm", "/assets/cloned/videos/ef.webm"]]);
|
||||||
|
const source = el("n2", "source", { src: "/media/hero.webm", type: "video/webm" }, [], false);
|
||||||
|
const video = el("n1", "video", { autoplay: "", muted: "" }, [source]);
|
||||||
|
const p = props(video, assetMap);
|
||||||
|
assert.equal(p.get("autoPlay"), "true");
|
||||||
|
assert.equal(p.get("preload"), undefined);
|
||||||
|
const jsx = renderChildrenJsx([video], assetMap, SOURCE, 0);
|
||||||
|
assert.match(jsx, /<source[^>]*src="\/assets\/cloned\/videos\/ef\.webm"[^>]*\/>/);
|
||||||
|
assert.match(jsx, /type="video\/webm"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops non-materialized <source> children (poster-only video keeps none)", () => {
|
||||||
|
const source = el("n2", "source", { src: "/media/hero.webm", type: "video/webm" }, [], false);
|
||||||
|
const video = el("n1", "video", { autoplay: "" }, [source]);
|
||||||
|
const jsx = renderChildrenJsx([video], new Map(), SOURCE, 0);
|
||||||
|
assert.ok(!jsx.includes("<source"));
|
||||||
|
assert.ok(!jsx.includes("autoPlay"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("poster fallback policy", () => {
|
||||||
|
it("DROPS a poster that missed the asset map instead of substituting the transparent GIF", () => {
|
||||||
|
const video = el("n1", "video", { poster: "https://clone-still.local/0-abc.jpg" });
|
||||||
|
const p = props(video, new Map());
|
||||||
|
assert.equal(p.get("poster"), undefined);
|
||||||
|
assert.ok(![...p.values()].includes(JSON.stringify(GIF)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the transparent-GIF fallback for a missed <img> src", () => {
|
||||||
|
const img = el("n1", "img", { src: "/img/gone.png", alt: "" });
|
||||||
|
const p = props(img, new Map());
|
||||||
|
assert.equal(p.get("src"), JSON.stringify(GIF));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("picture>source rewriting", () => {
|
||||||
|
const mkPicture = () => {
|
||||||
|
const desktop = el("n2", "source", {
|
||||||
|
srcset: "/img/hero-1280.jpg 1x, /img/hero-2560.jpg 2x",
|
||||||
|
media: "(min-width: 768px)", type: "image/jpeg", sizes: "100vw",
|
||||||
|
}, [], false);
|
||||||
|
const gone = el("n3", "source", { srcset: "/img/never-downloaded.avif", media: "(min-width: 1024px)" }, [], false);
|
||||||
|
const img = el("n4", "img", { src: "/img/hero-375.jpg", alt: "hero" });
|
||||||
|
return el("n1", "picture", {}, [desktop, gone, img]);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("emits surviving srcset candidates with media/type/sizes preserved, omits sources with none", () => {
|
||||||
|
const assetMap = new Map([
|
||||||
|
["https://example.test/img/hero-1280.jpg", "/assets/cloned/images/a.jpg"],
|
||||||
|
["https://example.test/img/hero-375.jpg", "/assets/cloned/images/m.jpg"],
|
||||||
|
]);
|
||||||
|
const jsx = renderChildrenJsx([mkPicture()], assetMap, SOURCE, 0);
|
||||||
|
// Desktop source survives with ONLY the materialized candidate; void element.
|
||||||
|
assert.match(jsx, /<source[^>]*srcSet="\/assets\/cloned\/images\/a\.jpg 1x"[^>]*\/>/);
|
||||||
|
assert.ok(!jsx.includes("hero-2560"));
|
||||||
|
assert.match(jsx, /media="\(min-width: 768px\)"/);
|
||||||
|
assert.match(jsx, /type="image\/jpeg"/);
|
||||||
|
assert.match(jsx, /sizes="100vw"/);
|
||||||
|
// The fully-missed source is omitted entirely (no placeholder-pointing variant).
|
||||||
|
assert.ok(!jsx.includes("never-downloaded"));
|
||||||
|
assert.ok(!jsx.includes("(min-width: 1024px)"));
|
||||||
|
// The <img> fallback keeps its captured (rewritten) src.
|
||||||
|
assert.match(jsx, /<img[^>]*src="\/assets\/cloned\/images\/m\.jpg"/);
|
||||||
|
// <source> stays a void element: no children, no closing tag.
|
||||||
|
assert.ok(!jsx.includes("</source>"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits every source when nothing materialized, keeping the img fallback", () => {
|
||||||
|
const jsx = renderChildrenJsx([mkPicture()], new Map(), SOURCE, 0);
|
||||||
|
assert.ok(!jsx.includes("<source"));
|
||||||
|
assert.match(jsx, new RegExp(`<img[^>]*src="${GIF.replace(/[+/]/g, (c) => "\\" + c)}"`));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
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(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { describe, it, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { chromium, type Browser, type Page } from "playwright";
|
||||||
|
import { collectPage, type RawNode, type RawChild } from "../src/capture/walker.js";
|
||||||
|
import type { IR, IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
|
||||||
|
import { generateCss } from "../src/generate/css.js";
|
||||||
|
import { buildTailwind } from "../src/generate/tailwind.js";
|
||||||
|
|
||||||
|
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "..", "fixtures");
|
||||||
|
|
||||||
|
function isText(c: RawChild): c is { text: string } {
|
||||||
|
return (c as { text?: string }).text !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBy(root: RawNode, pred: (n: RawNode) => boolean): RawNode | null {
|
||||||
|
if (pred(root)) return root;
|
||||||
|
for (const c of root.children) {
|
||||||
|
if (isText(c)) continue;
|
||||||
|
const hit = findBy(c, pred);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("walker ::placeholder capture", () => {
|
||||||
|
let browser: Browser;
|
||||||
|
let page: Page;
|
||||||
|
before(async () => {
|
||||||
|
browser = await chromium.launch();
|
||||||
|
page = await browser.newPage();
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures the styled ::placeholder color/font of input and textarea", async () => {
|
||||||
|
await page.setContent(readFileSync(join(FIXTURES, "placeholder.html"), "utf8"));
|
||||||
|
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||||
|
const snap = await page.evaluate(collectPage);
|
||||||
|
|
||||||
|
const input = findBy(snap.root, (n) => n.tag === "input" && n.attrs.class === "styled")!;
|
||||||
|
assert.ok(input.placeholder, "styled input carries a placeholder style");
|
||||||
|
assert.equal(input.placeholder!.color, "rgb(120, 30, 200)");
|
||||||
|
assert.equal(input.placeholder!.fontSize, "14px");
|
||||||
|
|
||||||
|
const textarea = findBy(snap.root, (n) => n.tag === "textarea")!;
|
||||||
|
assert.ok(textarea.placeholder, "styled textarea carries a placeholder style");
|
||||||
|
assert.equal(textarea.placeholder!.color, "rgb(5, 100, 5)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not attach placeholder style to a control without placeholder text", async () => {
|
||||||
|
await page.setContent(readFileSync(join(FIXTURES, "placeholder.html"), "utf8"));
|
||||||
|
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||||
|
const snap = await page.evaluate(collectPage);
|
||||||
|
const plain = findBy(snap.root, (n) => n.tag === "input" && n.attrs.class === "plain")!;
|
||||||
|
assert.equal(plain.placeholder, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Generated CSS ----
|
||||||
|
|
||||||
|
const VPS = [375, 1280];
|
||||||
|
const CANONICAL = 1280;
|
||||||
|
|
||||||
|
function computed(over: StyleMap = {}): StyleMap {
|
||||||
|
return { display: "block", position: "static", visibility: "visible", ...over };
|
||||||
|
}
|
||||||
|
|
||||||
|
function node(id: string, tag: string, cs: StyleMap, children: IRChild[] = []): IRNode {
|
||||||
|
const computedByVp: Record<number, StyleMap> = {};
|
||||||
|
const bboxByVp: Record<number, BBox> = {};
|
||||||
|
const visibleByVp: Record<number, boolean> = {};
|
||||||
|
for (const vp of VPS) {
|
||||||
|
computedByVp[vp] = { ...cs };
|
||||||
|
bboxByVp[vp] = { x: 0, y: 0, width: 200, height: 40 };
|
||||||
|
visibleByVp[vp] = true;
|
||||||
|
}
|
||||||
|
return { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
|
||||||
|
}
|
||||||
|
|
||||||
|
function irWith(root: IRNode): IR {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
sourceUrl: "https://example.test/placeholder",
|
||||||
|
title: "Placeholder Fixture",
|
||||||
|
lang: "en",
|
||||||
|
charset: "UTF-8",
|
||||||
|
metaViewport: "width=device-width, initial-scale=1",
|
||||||
|
viewports: VPS,
|
||||||
|
sampleViewports: VPS,
|
||||||
|
canonicalViewport: CANONICAL,
|
||||||
|
perViewport: Object.fromEntries(VPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)", bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial" }])),
|
||||||
|
nodeCount: 2,
|
||||||
|
keyframes: [],
|
||||||
|
},
|
||||||
|
root,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("generateCss ::placeholder emission", () => {
|
||||||
|
it("emits a ::placeholder rule with the captured color", () => {
|
||||||
|
const input = node("n1", "input", computed({ color: "rgb(10, 20, 30)", fontSize: "16px" }));
|
||||||
|
input.placeholderByVp = {
|
||||||
|
375: { color: "rgb(120, 30, 200)", fontSize: "14px" },
|
||||||
|
1280: { color: "rgb(120, 30, 200)", fontSize: "14px" },
|
||||||
|
};
|
||||||
|
const root = node("n0", "body", computed(), [input]);
|
||||||
|
const css = generateCss(irWith(root), new Map());
|
||||||
|
const m = css.match(/\.cn1::placeholder\{([^}]*)\}/);
|
||||||
|
assert.ok(m, "a .cn1::placeholder rule is emitted");
|
||||||
|
assert.ok(m![1]!.includes("color:rgb(120, 30, 200)"));
|
||||||
|
// fontSize differs from the host's own 16px, so it must be emitted too.
|
||||||
|
assert.ok(m![1]!.includes("font-size:14px"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits font props the placeholder inherits from the input itself", () => {
|
||||||
|
const input = node("n1", "input", computed({ color: "rgb(10, 20, 30)", fontSize: "16px", fontFamily: "Arial" }));
|
||||||
|
input.placeholderByVp = {
|
||||||
|
375: { color: "rgb(200, 150, 100)", fontSize: "16px", fontFamily: "Arial" },
|
||||||
|
1280: { color: "rgb(200, 150, 100)", fontSize: "16px", fontFamily: "Arial" },
|
||||||
|
};
|
||||||
|
const root = node("n0", "body", computed(), [input]);
|
||||||
|
const css = generateCss(irWith(root), new Map());
|
||||||
|
const m = css.match(/\.cn1::placeholder\{([^}]*)\}/);
|
||||||
|
assert.ok(m);
|
||||||
|
assert.ok(m![1]!.includes("color:rgb(200, 150, 100)"));
|
||||||
|
assert.ok(!m![1]!.includes("font-size"), "inherited font-size is not re-declared");
|
||||||
|
assert.ok(!m![1]!.includes("font-family"), "inherited font-family is not re-declared");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bands a placeholder color that changes across viewports", () => {
|
||||||
|
const input = node("n1", "input", computed({ color: "rgb(10, 20, 30)" }));
|
||||||
|
input.placeholderByVp = {
|
||||||
|
375: { color: "rgb(1, 2, 3)" },
|
||||||
|
1280: { color: "rgb(120, 30, 200)" },
|
||||||
|
};
|
||||||
|
const root = node("n0", "body", computed(), [input]);
|
||||||
|
const css = generateCss(irWith(root), new Map());
|
||||||
|
assert.ok(/\.cn1::placeholder\{[^}]*rgb\(120, 30, 200\)/.test(css), "base carries the canonical color");
|
||||||
|
assert.ok(/@media \(max-width: \d+px\)[\s\S]*\.cn1::placeholder\{[^}]*rgb\(1, 2, 3\)/.test(css), "mobile band overrides the color");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits no ::placeholder rule for nodes without placeholder styles", () => {
|
||||||
|
const input = node("n1", "input", computed({ color: "rgb(10, 20, 30)" }));
|
||||||
|
const root = node("n0", "body", computed(), [input]);
|
||||||
|
const css = generateCss(irWith(root), new Map());
|
||||||
|
assert.ok(!css.includes("::placeholder"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tailwind mode (the default pipeline) carries the rule in ditto.css keyed by data-cid", () => {
|
||||||
|
const input = node("n1", "input", computed({ color: "rgb(10, 20, 30)" }));
|
||||||
|
input.placeholderByVp = {
|
||||||
|
375: { color: "rgb(120, 30, 200)" },
|
||||||
|
1280: { color: "rgb(120, 30, 200)" },
|
||||||
|
};
|
||||||
|
const root = node("n0", "body", computed(), [input]);
|
||||||
|
const tw = buildTailwind(irWith(root), new Map());
|
||||||
|
assert.ok(/\[data-cid="n1"\]::placeholder\s*\{[^}]*color:\s*rgb\(120, 30, 200\)/.test(tw.pseudoCss),
|
||||||
|
`pseudoCss carries the placeholder rule:\n${tw.pseudoCss}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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 } from "node:path";
|
||||||
|
import { captureSite, looksLikeVideoFile, type CaptureResult } from "../src/capture/capture.js";
|
||||||
|
|
||||||
|
// A minimal-but-valid mp4 shape: [size]"ftyp" brand box followed by payload bytes.
|
||||||
|
const FULL_MP4 = Buffer.concat([
|
||||||
|
Buffer.from([0x00, 0x00, 0x00, 0x18]),
|
||||||
|
Buffer.from("ftypisom"),
|
||||||
|
Buffer.from([0x00, 0x00, 0x02, 0x00]),
|
||||||
|
Buffer.from("isomiso2"),
|
||||||
|
Buffer.alloc(64_000, 0x07),
|
||||||
|
]);
|
||||||
|
// The pathological range fragment observed in the wild: a tail slice (moov atom region)
|
||||||
|
// of the file — starts mid-container, no ftyp/EBML magic.
|
||||||
|
const TAIL_FRAGMENT = FULL_MP4.subarray(FULL_MP4.length - 1000);
|
||||||
|
|
||||||
|
describe("looksLikeVideoFile (container magic)", () => {
|
||||||
|
it("accepts mp4-family (ftyp), webm/mkv (EBML), and ogg (OggS) heads", () => {
|
||||||
|
assert.equal(looksLikeVideoFile(FULL_MP4), true);
|
||||||
|
assert.equal(looksLikeVideoFile(Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.alloc(100)])), true);
|
||||||
|
assert.equal(looksLikeVideoFile(Buffer.concat([Buffer.from("OggS"), Buffer.alloc(100)])), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects range fragments, HTML error bodies, and tiny buffers", () => {
|
||||||
|
assert.equal(looksLikeVideoFile(TAIL_FRAGMENT), false, "moov-tail fragment is not a video file");
|
||||||
|
assert.equal(looksLikeVideoFile(Buffer.from("<!doctype html><html><body>404</body></html>")), false);
|
||||||
|
assert.equal(looksLikeVideoFile(Buffer.from([0x00, 0x00])), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A <video> element makes the browser fetch with Range headers; the 206 fragment must
|
||||||
|
// NOT be stored as the asset (first-stored-wins would then also block the full-download
|
||||||
|
// fallback). The fallback pass fetches without Range and stores the complete 200 body.
|
||||||
|
describe("206 range responses are not stored as full assets (integration)", () => {
|
||||||
|
let server: Server;
|
||||||
|
let url = "";
|
||||||
|
let videoUrl = "";
|
||||||
|
let outDir = "";
|
||||||
|
let capture: CaptureResult;
|
||||||
|
let rangeHits = 0;
|
||||||
|
let fullHits = 0;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = createServer((req, res) => {
|
||||||
|
if (req.url?.startsWith("/video.mp4")) {
|
||||||
|
if (req.headers.range) {
|
||||||
|
rangeHits++;
|
||||||
|
// Serve the tail fragment regardless of the requested range — the corrupt-body
|
||||||
|
// case (byte-verified in the wild: an 18,925-byte moov tail of a 6MB mp4).
|
||||||
|
res.writeHead(206, {
|
||||||
|
"content-type": "video/mp4",
|
||||||
|
"content-range": `bytes ${FULL_MP4.length - TAIL_FRAGMENT.length}-${FULL_MP4.length - 1}/${FULL_MP4.length}`,
|
||||||
|
"content-length": String(TAIL_FRAGMENT.length),
|
||||||
|
});
|
||||||
|
res.end(TAIL_FRAGMENT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fullHits++;
|
||||||
|
res.writeHead(200, { "content-type": "video/mp4", "content-length": String(FULL_MP4.length) });
|
||||||
|
res.end(FULL_MP4);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { "content-type": "text/html" });
|
||||||
|
res.end('<!doctype html><html><body><h1>Video page</h1><video src="/video.mp4" muted preload="auto" width="320" height="180"></video></body></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}/`;
|
||||||
|
videoUrl = url + "video.mp4";
|
||||||
|
outDir = mkdtempSync(join(tmpdir(), "ditto-206-"));
|
||||||
|
capture = await captureSite({
|
||||||
|
url,
|
||||||
|
outDir,
|
||||||
|
viewports: [800],
|
||||||
|
breakpoints: false,
|
||||||
|
screenshots: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
server?.close();
|
||||||
|
rmSync(outDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exercised both paths: browser range fetch (206) and the full fallback fetch (200)", () => {
|
||||||
|
assert.ok(rangeHits >= 1, `browser issued a Range request (got ${rangeHits})`);
|
||||||
|
assert.ok(fullHits >= 1, `fallback pass fetched the full body (got ${fullHits})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the COMPLETE 200 body, byte-identical, never the 206 fragment", () => {
|
||||||
|
const asset = capture.assets.find((a) => a.url === videoUrl);
|
||||||
|
assert.ok(asset, "video asset discovered");
|
||||||
|
assert.ok(asset!.storedAs, "video asset stored");
|
||||||
|
assert.equal(asset!.bytes, FULL_MP4.length, "stored size is the full file, not the fragment");
|
||||||
|
const stored = readFileSync(join(outDir, "assets-store", asset!.storedAs!));
|
||||||
|
assert.ok(stored.equals(FULL_MP4), "stored bytes are the complete video");
|
||||||
|
assert.ok(looksLikeVideoFile(stored), "stored file passes the container-magic check");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { computeBands } from "../src/generate/css.js";
|
||||||
|
import { bandScreens, prefixFor, tailwindGlobalsCss } from "../src/generate/tailwind.js";
|
||||||
|
|
||||||
|
/** Interval covered by a ditto.css band media query (integer px, inclusive). */
|
||||||
|
function bandInterval(media: string | null): [number, number] {
|
||||||
|
if (!media) return [0, Infinity];
|
||||||
|
const min = /min-width:\s*(\d+)px/.exec(media);
|
||||||
|
const max = /max-width:\s*(\d+)px/.exec(media);
|
||||||
|
return [min ? +min[1]! : 0, max ? +max[1]! : Infinity];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Integer interval covered by a Tailwind variant prefix under the given screens.
|
||||||
|
* v4 semantics for named AND arbitrary variants: `X:` = width >= X; `max-X:` = width < X. */
|
||||||
|
function prefixInterval(prefix: string, screens: Map<string, number>): [number, number] {
|
||||||
|
let lo = 0, hi = Infinity;
|
||||||
|
for (const m of prefix.matchAll(/(max-)?(?:(sm|md|lg|xl|2xl)|(?:min-)?\[(\d+)px\]):/g)) {
|
||||||
|
const px = m[3] ? +m[3] : screens.get(m[2]!);
|
||||||
|
assert.ok(px !== undefined, `screen defined for ${m[0]}`);
|
||||||
|
if (m[1]) hi = Math.min(hi, px! - 1);
|
||||||
|
else lo = Math.max(lo, px!);
|
||||||
|
}
|
||||||
|
return [lo, hi];
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAgreement(viewports: number[], canonical: number): void {
|
||||||
|
const screens = new Map(bandScreens(viewports, canonical));
|
||||||
|
for (const b of computeBands(viewports, canonical)) {
|
||||||
|
if (!b.media) continue;
|
||||||
|
assert.deepEqual(
|
||||||
|
prefixInterval(prefixFor(b.media), screens),
|
||||||
|
bandInterval(b.media),
|
||||||
|
`band vp${b.vp} (${b.media}) matches its Tailwind prefix`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tailwind screens agree with computeBands boundaries", () => {
|
||||||
|
it("standard 375/768/1280/1920 ladder: md/lg/2xl pinned to the band midpoint boundaries", () => {
|
||||||
|
const screens = new Map(bandScreens([375, 768, 1280, 1920], 1280));
|
||||||
|
// Midpoints 571/1024/1600 → bands ≤571, 572–1024, base, ≥1601.
|
||||||
|
assert.deepEqual([...screens], [["md", 572], ["lg", 1025], ["2xl", 1601]]);
|
||||||
|
// NOT Tailwind's stock 768/1024/1536 — stock 2xl (1536) would flip utility-classed
|
||||||
|
// nodes to the 1920 layout in 1536–1600 while ditto.css still holds the 1280 layout.
|
||||||
|
assert.notEqual(screens.get("2xl"), 1536);
|
||||||
|
assertAgreement([375, 768, 1280, 1920], 1280);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("odd viewport set: bands fall back to exact arbitrary prefixes (no named screens needed)", () => {
|
||||||
|
assert.deepEqual(bandScreens([320, 900, 1440], 900), []);
|
||||||
|
assertAgreement([320, 900, 1440], 900);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the derived screens into the generated @theme", () => {
|
||||||
|
const css = tailwindGlobalsCss({
|
||||||
|
reset: "", fontCss: "", tokensCss: "", htmlBg: "#fff", bodyFont: "sans-serif",
|
||||||
|
clip: "", colorTokens: [], viewports: [375, 768, 1280, 1920], canonical: 1280,
|
||||||
|
});
|
||||||
|
assert.ok(css.includes("--breakpoint-md: 572px;"));
|
||||||
|
assert.ok(css.includes("--breakpoint-lg: 1025px;"));
|
||||||
|
assert.ok(css.includes("--breakpoint-2xl: 1601px;"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { sectionFiles, type ComponentRegistry, type SectionRegistry } from "../src/generate/app.js";
|
||||||
|
import type { SectionPlan } from "../src/generate/sectionSplit.js";
|
||||||
|
|
||||||
|
// Regression: the data-var → camelCase-prop rewrite in a section module must be
|
||||||
|
// word-boundary aware. A plain substring replace of `${v}.map(` also matched inside a
|
||||||
|
// LONGER var that shares a suffix — e.g. `Tile2_data` is a suffix of `MediaTile2_data`, so
|
||||||
|
// rewriting `Tile2_data.map(` corrupted `MediaTile2_data.map(` into `Mediatile2Data.map(`
|
||||||
|
// (lowercase 't'), which no longer matched the `MediaTile2_data` declaration/import → the
|
||||||
|
// generated app failed `next build` with `ReferenceError: Mediatile2Data is not defined`.
|
||||||
|
//
|
||||||
|
// The invariant these tests lock: every `X.map(` identifier a section emits has a matching
|
||||||
|
// declaration/import/param in the SAME module (single canonical derivation, all sites).
|
||||||
|
|
||||||
|
function emptyReg(): ComponentRegistry {
|
||||||
|
return {
|
||||||
|
plan: { clusters: [] } as unknown as ComponentRegistry["plan"],
|
||||||
|
nodeByCid: new Map(),
|
||||||
|
funcDefs: new Map(),
|
||||||
|
skeletonToName: new Map(),
|
||||||
|
nameCounts: new Map(),
|
||||||
|
dataDecls: [],
|
||||||
|
cidDecls: [],
|
||||||
|
styleDecls: [],
|
||||||
|
dataCounts: new Map(),
|
||||||
|
fieldTypes: new Map(),
|
||||||
|
styleFieldTypes: new Map(),
|
||||||
|
byName: new Map(),
|
||||||
|
failed: new Set(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a shared-skeleton extracted component `name` with one data run, and return the
|
||||||
|
* `{Name_data.map(...)}` call the generator emits inline (mirrors registerComponent). */
|
||||||
|
function addComponent(reg: ComponentRegistry, name: string): string {
|
||||||
|
reg.funcDefs.set(name, `function ${name}({ d, cids, styles }: { d: ${name}Data; cids: string[]; styles: ${name}Styles }) {\n return (\n <div />\n );\n}`);
|
||||||
|
const dataVar = `${name}_data`;
|
||||||
|
const cidsVar = `${name}_cids`;
|
||||||
|
const stylesVar = `${name}_styles`;
|
||||||
|
reg.dataDecls.push({ varName: dataVar, compName: name, body: `[\n { alt: "x" }\n]` });
|
||||||
|
reg.cidDecls.push({ varName: cidsVar, body: `[\n ["a"]\n]` });
|
||||||
|
reg.styleDecls.push({ varName: stylesVar, compName: name, body: `[\n {}\n]` });
|
||||||
|
reg.fieldTypes.set(name, [{ name: "alt", type: "string", optional: false }]);
|
||||||
|
reg.byName.set(name, { runs: 1, instances: 1, cids: [`${name}-cid`] });
|
||||||
|
return `{${dataVar}.map((d, i) => <${name} key={i} d={d} cids={${cidsVar}[i]} styles={${stylesVar}[i]} />)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every `<ident>.map(` in a module must resolve to a binding declared in that module —
|
||||||
|
* a `const <ident>`, an `import { <ident> }`, a default import, or a function param. */
|
||||||
|
function assertMapIdentsResolved(module: string): void {
|
||||||
|
const mapIdents = [...module.matchAll(/([A-Za-z_$][\w$]*)\.map\(/g)].map((m) => m[1]!);
|
||||||
|
for (const id of mapIdents) {
|
||||||
|
const declared =
|
||||||
|
new RegExp(`\\bconst\\s+${id}\\b`).test(module) ||
|
||||||
|
new RegExp(`\\bimport\\b[^\\n]*\\b${id}\\b`).test(module) ||
|
||||||
|
// destructured function param default: `{ ... ${id} = ... }`
|
||||||
|
new RegExp(`[{,]\\s*${id}\\s*=`).test(module);
|
||||||
|
assert.ok(declared, `map identifier ${id} has no matching declaration/import/param in module:\n${module}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHeroModule(componentNames: string[]): string {
|
||||||
|
const reg = emptyReg();
|
||||||
|
const calls = componentNames.map((n) => addComponent(reg, n));
|
||||||
|
// A section body that renders each component's `.map(` call, in order.
|
||||||
|
const jsx = ` <div>\n${calls.map((c) => ` ${c}`).join("\n")}\n </div>`;
|
||||||
|
const plan: SectionPlan = { roots: new Map([["hero-cid", "HeroSection"]]) };
|
||||||
|
const sreg: SectionRegistry = { plan, modules: new Map([["HeroSection", jsx]]), order: ["HeroSection"] };
|
||||||
|
const out = sectionFiles(sreg, reg);
|
||||||
|
return out.files.find((f) => f.name === "HeroSection")!.module;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("section data identifier derivation (digit-suffixed multi-word names)", () => {
|
||||||
|
it("does not corrupt MediaTile2 when a shorter Tile2 shares its suffix", () => {
|
||||||
|
// The exact bug shape: Tile2_data is a suffix of MediaTile2_data.
|
||||||
|
const module = buildHeroModule(["Tile", "Tile2", "MediaTile2"]);
|
||||||
|
// The map site and the param must agree on ONE identifier for MediaTile2's data.
|
||||||
|
assert.match(module, /mediaTile2Data\.map\(/);
|
||||||
|
assert.doesNotMatch(module, /Mediatile2Data/); // the corrupted (lowercase-t) form must never appear
|
||||||
|
assertMapIdentsResolved(module);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a plain name and a Logo2-style digit name self-consistent", () => {
|
||||||
|
const module = buildHeroModule(["Logo", "Logo2"]);
|
||||||
|
// Logo2_data has Logo_data-ish neighbors; both map sites must stay resolvable.
|
||||||
|
assert.match(module, /logo2Data\.map\(/);
|
||||||
|
assert.doesNotMatch(module, /Logo2Data\.map\(/); // no PascalCase corruption of the usage
|
||||||
|
assertMapIdentsResolved(module);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every map identifier resolves for a suffix-overlapping cluster", () => {
|
||||||
|
const module = buildHeroModule(["Card", "MediaCard", "Card2", "MediaCard2"]);
|
||||||
|
assertMapIdentsResolved(module);
|
||||||
|
assert.doesNotMatch(module, /Mediacard/); // no lowercase-c corruption
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
seoRouteFiles,
|
seoRouteFiles,
|
||||||
} from "../src/generate/seo.js";
|
} from "../src/generate/seo.js";
|
||||||
import { agentsMd, architectureMd } from "../src/generate/docs.js";
|
import { agentsMd, architectureMd } from "../src/generate/docs.js";
|
||||||
|
import { NEXT_CONFIG } from "../src/generate/app.js";
|
||||||
|
|
||||||
function fixtureIr(): IR {
|
function fixtureIr(): IR {
|
||||||
return {
|
return {
|
||||||
@@ -169,4 +170,28 @@ describe("SEO inventory and emission", () => {
|
|||||||
assert.ok(agentsMd(docsInput).includes("src/app/ditto"));
|
assert.ok(agentsMd(docsInput).includes("src/app/ditto"));
|
||||||
assert.ok(architectureMd(docsInput).includes("data-ditto-id"));
|
assert.ok(architectureMd(docsInput).includes("data-ditto-id"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes a Next-supported og:type through to openGraph", () => {
|
||||||
|
const ir = fixtureIr();
|
||||||
|
ir.doc.head!.meta!.push({ property: "og:type", content: "article" });
|
||||||
|
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
|
||||||
|
const metadata = metadataExport(report);
|
||||||
|
assert.ok(metadata.includes('"type": "article"'));
|
||||||
|
assert.ok(!metadata.includes('"og:type"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes an unsupported og:type into metadata.other (Next throws on unknown enum values)", () => {
|
||||||
|
const ir = fixtureIr();
|
||||||
|
ir.doc.head!.meta!.push({ property: "og:type", content: "product.group" });
|
||||||
|
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
|
||||||
|
const metadata = metadataExport(report);
|
||||||
|
assert.ok(metadata.includes('"og:type": "product.group"'));
|
||||||
|
assert.ok(!metadata.includes('"type": "product.group"'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generated Next config", () => {
|
||||||
|
it("disables the dev-tools badge so it cannot leak into screenshots", () => {
|
||||||
|
assert.ok(NEXT_CONFIG.includes("devIndicators: false"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, it, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { chromium, type Browser, type Page } from "playwright";
|
||||||
|
import {
|
||||||
|
promoteLazyMediaInPage,
|
||||||
|
settleCarouselsInPage,
|
||||||
|
forceRevealForShot,
|
||||||
|
restoreRevealForShot,
|
||||||
|
} from "../src/capture/stabilize.js";
|
||||||
|
|
||||||
|
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "..", "fixtures");
|
||||||
|
// tsx/esbuild wraps functions with a __name() helper for stack traces; the serialized
|
||||||
|
// page functions carry those calls, so shim it (same as capture.ts's init script).
|
||||||
|
const ESBUILD_SHIM = "globalThis.__name = globalThis.__name || ((fn) => fn);";
|
||||||
|
|
||||||
|
describe("stabilize: lazy-media promotion", () => {
|
||||||
|
let browser: Browser;
|
||||||
|
let page: Page;
|
||||||
|
before(async () => {
|
||||||
|
browser = await chromium.launch();
|
||||||
|
page = await browser.newPage();
|
||||||
|
await page.addInitScript(ESBUILD_SHIM);
|
||||||
|
await page.goto(pathToFileURL(join(FIXTURES, "lazy.html")).href);
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("promotes data-lazy-src/data-src/data-srcset/data-bg and measures the loaded size", async () => {
|
||||||
|
const count = await page.evaluate(promoteLazyMediaInPage);
|
||||||
|
// lazy1 (src), lazy2 (src), lazysource (srcset), lazy3 (src), lazybg (background)
|
||||||
|
assert.equal(count, 5);
|
||||||
|
|
||||||
|
const state = await page.evaluate(() => {
|
||||||
|
const attr = (id: string, name: string) => document.getElementById(id)?.getAttribute(name) ?? null;
|
||||||
|
const img = (id: string) => document.getElementById(id) as HTMLImageElement;
|
||||||
|
return {
|
||||||
|
lazy1Src: attr("lazy1", "src"),
|
||||||
|
lazy1Loading: attr("lazy1", "loading"),
|
||||||
|
lazy1Width: img("lazy1").naturalWidth,
|
||||||
|
lazy2Src: attr("lazy2", "src"),
|
||||||
|
lazy2Sizes: attr("lazy2", "sizes"), // data-sizes="auto" is a flag, not a value
|
||||||
|
sourceSrcset: attr("lazysource", "srcset"),
|
||||||
|
lazy3Src: attr("lazy3", "src"),
|
||||||
|
bg: (document.getElementById("lazybg") as HTMLElement).style.backgroundImage,
|
||||||
|
notaurlSrc: attr("notaurl", "src"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(state.lazy1Src, "og-image.png");
|
||||||
|
assert.equal(state.lazy1Loading, "eager");
|
||||||
|
assert.ok(state.lazy1Width > 1, "promoted image decoded to its real size");
|
||||||
|
assert.equal(state.lazy2Src, "seo-icon.png");
|
||||||
|
assert.equal(state.lazy2Sizes, null);
|
||||||
|
assert.equal(state.sourceSrcset, "og-image.png 1x");
|
||||||
|
assert.equal(state.lazy3Src, "og-image.png");
|
||||||
|
assert.ok(state.bg.includes("brand.svg"), `background promoted (got ${state.bg})`);
|
||||||
|
assert.equal(state.notaurlSrc, null, "non-URL data attr must not be promoted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent and never clobbers an src that already equals the target", async () => {
|
||||||
|
const again = await page.evaluate(promoteLazyMediaInPage);
|
||||||
|
assert.equal(again, 0);
|
||||||
|
const alreadySrc = await page.evaluate(() => document.getElementById("already")?.getAttribute("src"));
|
||||||
|
assert.equal(alreadySrc, "seo-icon.png");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stabilize: carousel settling", () => {
|
||||||
|
let browser: Browser;
|
||||||
|
let page: Page;
|
||||||
|
before(async () => {
|
||||||
|
browser = await chromium.launch();
|
||||||
|
page = await browser.newPage();
|
||||||
|
await page.addInitScript(ESBUILD_SHIM);
|
||||||
|
await page.goto(pathToFileURL(join(FIXTURES, "carousel-autoplay.html")).href);
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const txOf = (sel: string) =>
|
||||||
|
page.evaluate((s) => {
|
||||||
|
const el = document.querySelector(s)!;
|
||||||
|
return new DOMMatrixReadOnly(getComputedStyle(el).transform).m41;
|
||||||
|
}, sel);
|
||||||
|
|
||||||
|
it("returns an autoplaying carousel to its home slide and pauses autoplay", async () => {
|
||||||
|
// let autoplay advance at least one slide so there is something to settle
|
||||||
|
await page.waitForFunction("window.__autoplayTicks >= 2", null, { timeout: 5000 });
|
||||||
|
assert.notEqual(await txOf(".splide__list"), 0);
|
||||||
|
|
||||||
|
const res = await page.evaluate(settleCarouselsInPage);
|
||||||
|
assert.equal(res.roots, 2);
|
||||||
|
assert.equal(res.normalized, 2);
|
||||||
|
|
||||||
|
assert.equal(await txOf(".splide__list"), 0, "track back at the home slide");
|
||||||
|
const bulletActive = await page.evaluate(() =>
|
||||||
|
document.querySelector(".splide__pagination__bullet")!.classList.contains("is-active"));
|
||||||
|
assert.ok(bulletActive, "first bullet re-activated");
|
||||||
|
|
||||||
|
// paused: no further autoplay ticks, and the track holds its home transform
|
||||||
|
const ticks = await page.evaluate("window.__autoplayTicks");
|
||||||
|
await page.waitForTimeout(700); // > 2 autoplay intervals
|
||||||
|
assert.equal(await page.evaluate("window.__autoplayTicks"), ticks, "autoplay latched paused");
|
||||||
|
assert.equal(await txOf(".splide__list"), 0, "track still at home after the autoplay window");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pins a control-less non-loop track left mid-offset back to translateX(0)", async () => {
|
||||||
|
assert.equal(await txOf("#stuck .swiper-wrapper"), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a mid-flight track transition instead of freezing it", async () => {
|
||||||
|
// Start a slide transition and settle while it is still running: pausing the
|
||||||
|
// CSSTransition would disassociate it from style and HOLD the frozen mid-flight
|
||||||
|
// transform over the home navigation — the settled track must still land at 0.
|
||||||
|
await page.evaluate(`(async () => {
|
||||||
|
const bullets = document.querySelectorAll(".splide__pagination__bullet");
|
||||||
|
bullets[2].click(); // transition toward slide 3 begins (0.2s)
|
||||||
|
return await (${settleCarouselsInPage.toString()})();
|
||||||
|
})()`);
|
||||||
|
assert.equal(await txOf(".splide__list"), 0, "track settled at home, not a frozen mid-flight offset");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stabilize: force-reveal for element screenshots", () => {
|
||||||
|
let browser: Browser;
|
||||||
|
let page: Page;
|
||||||
|
before(async () => {
|
||||||
|
browser = await chromium.launch();
|
||||||
|
page = await browser.newPage();
|
||||||
|
await page.addInitScript(ESBUILD_SHIM);
|
||||||
|
await page.setContent(
|
||||||
|
'<div id="wrap" style="visibility:hidden">' +
|
||||||
|
'<video id="v" style="width:320px;height:180px;background:#000"></video>' +
|
||||||
|
"</div>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reveals the hidden ancestor chain so the screenshot succeeds, then restores exactly", async () => {
|
||||||
|
const vis = () => page.evaluate(() => getComputedStyle(document.getElementById("v")!).visibility);
|
||||||
|
assert.equal(await vis(), "hidden");
|
||||||
|
|
||||||
|
const forced = await page.evaluate(forceRevealForShot, "#v");
|
||||||
|
assert.equal(forced, 2); // the video (inherited hidden) + the wrapping div
|
||||||
|
assert.equal(await vis(), "visible");
|
||||||
|
|
||||||
|
const buf = await page.locator("#v").screenshot({ type: "jpeg", quality: 82, timeout: 2000, animations: "disabled" });
|
||||||
|
assert.ok(buf.length > 0, "screenshot captured while revealed");
|
||||||
|
|
||||||
|
await page.evaluate(restoreRevealForShot);
|
||||||
|
assert.equal(await vis(), "hidden");
|
||||||
|
const after = await page.evaluate(() => ({
|
||||||
|
wrapInline: (document.getElementById("wrap") as HTMLElement).style.visibility,
|
||||||
|
videoInline: (document.getElementById("v") as HTMLElement).style.visibility,
|
||||||
|
markers: document.querySelectorAll("[data-clone-vis-restore]").length,
|
||||||
|
}));
|
||||||
|
assert.equal(after.wrapInline, "hidden", "original inline value restored");
|
||||||
|
assert.equal(after.videoInline, "", "no inline visibility left on the video");
|
||||||
|
assert.equal(after.markers, 0, "restore markers removed");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { declToUtil } from "../src/generate/tailwind.js";
|
||||||
|
|
||||||
|
// Zero-value gating. A named `-0` step only exists for props on Tailwind's spacing (or numeric)
|
||||||
|
// scales; for the rest the class compiles to NOTHING — a silent no-op that ships the wrong style
|
||||||
|
// (a map label captured at font-size:0 painted at the inherited 20px because `text-0` isn't a
|
||||||
|
// utility). Zeros for scale-less props must stay arbitrary so the declaration really compiles.
|
||||||
|
describe("declToUtil zero values", () => {
|
||||||
|
it("emits arbitrary text-[0px] for font-size:0 (no text-0 utility in v4)", () => {
|
||||||
|
assert.equal(declToUtil("font-size", "0px"), "text-[0px]");
|
||||||
|
assert.equal(declToUtil("font-size", "0"), "text-[0px]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits arbitrary tracking-[0px] for letter-spacing:0 (no tracking-0 utility in v4)", () => {
|
||||||
|
assert.equal(declToUtil("letter-spacing", "0px"), "tracking-[0px]");
|
||||||
|
assert.equal(declToUtil("letter-spacing", "0"), "tracking-[0px]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits arbitrary rounded corners for radius:0 (radius scale has no numeric 0)", () => {
|
||||||
|
assert.equal(declToUtil("border-top-left-radius", "0px"), "rounded-tl-[0px]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the named -0 for spacing-scale props", () => {
|
||||||
|
assert.equal(declToUtil("width", "0px"), "w-0");
|
||||||
|
assert.equal(declToUtil("width", "0"), "w-0");
|
||||||
|
assert.equal(declToUtil("top", "0px"), "top-0");
|
||||||
|
assert.equal(declToUtil("margin-left", "0px"), "ml-0");
|
||||||
|
assert.equal(declToUtil("line-height", "0px"), "leading-0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the named -0 for numeric scales that include 0", () => {
|
||||||
|
assert.equal(declToUtil("flex-grow", "0"), "grow-0");
|
||||||
|
assert.equal(declToUtil("flex-shrink", "0"), "shrink-0");
|
||||||
|
assert.equal(declToUtil("order", "0"), "order-0");
|
||||||
|
assert.equal(declToUtil("z-index", "0"), "z-0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves non-zero values untouched", () => {
|
||||||
|
assert.equal(declToUtil("font-size", "20px"), "text-[20px]");
|
||||||
|
assert.equal(declToUtil("letter-spacing", "-0.5px"), "tracking-[-0.5px]");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, it, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { chromium, type Browser, type Page } from "playwright";
|
||||||
|
import { collectPage, type RawNode, type RawChild } from "../src/capture/walker.js";
|
||||||
|
|
||||||
|
function isText(c: RawChild): c is { text: string } {
|
||||||
|
return (c as { text?: string }).text !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findByTag(root: RawNode, tag: string): RawNode | null {
|
||||||
|
if (root.tag === tag) return root;
|
||||||
|
for (const c of root.children) {
|
||||||
|
if (isText(c)) continue;
|
||||||
|
const hit = findByTag(c, tag);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textRun(node: RawNode): string {
|
||||||
|
let out = "";
|
||||||
|
for (const c of node.children) out += isText(c) ? c.text : textRun(c);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("walker whitespace-only text nodes", () => {
|
||||||
|
let browser: Browser;
|
||||||
|
let page: Page;
|
||||||
|
before(async () => {
|
||||||
|
browser = await chromium.launch();
|
||||||
|
page = await browser.newPage();
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const capture = async (html: string) => {
|
||||||
|
await page.setContent(html);
|
||||||
|
// tsx/esbuild wraps functions with a __name() helper for stack traces; the
|
||||||
|
// serialized collectPage carries those calls, so shim it (same as capture.ts).
|
||||||
|
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||||
|
return page.evaluate(collectPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("keeps the lone space that is the only child of an inline element", async () => {
|
||||||
|
// Real case (ooni.com): the space between "of" and "the" lives alone inside
|
||||||
|
// <strong>; dropping it fuses the adjacent text runs ("ofthe").
|
||||||
|
const snap = await capture("<p>Creator of<strong> </strong><em><strong>the world's</strong></em></p>");
|
||||||
|
const p = findByTag(snap.root, "p")!;
|
||||||
|
const strong = p.children.find((c) => !isText(c) && c.tag === "strong") as RawNode;
|
||||||
|
assert.deepEqual(strong.children, [{ text: " " }]);
|
||||||
|
assert.equal(textRun(p), "Creator of the world's");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still keeps the single space between inline elements", async () => {
|
||||||
|
const snap = await capture("<p><em>a</em> <em>b</em></p>");
|
||||||
|
const p = findByTag(snap.root, "p")!;
|
||||||
|
assert.equal(textRun(p), "a b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not emit a space inside an empty block container", async () => {
|
||||||
|
const snap = await capture("<main><section>\n \n</section></main>");
|
||||||
|
const section = findByTag(snap.root, "section")!;
|
||||||
|
assert.deepEqual(section.children, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function findByClass(root: RawNode, cls: string): RawNode | null {
|
||||||
|
if ((root.attrs?.class ?? "").split(/\s+/).includes(cls)) return root;
|
||||||
|
for (const c of root.children) {
|
||||||
|
if (isText(c)) continue;
|
||||||
|
const hit = findByClass(c, cls);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("walker off-screen visibility", () => {
|
||||||
|
let browser: Browser;
|
||||||
|
let page: Page;
|
||||||
|
before(async () => {
|
||||||
|
browser = await chromium.launch();
|
||||||
|
page = await browser.newPage();
|
||||||
|
await page.setViewportSize({ width: 375, height: 768 });
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const capture = async (html: string) => {
|
||||||
|
await page.setContent(html);
|
||||||
|
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||||
|
return page.evaluate(collectPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("marks a fixed off-screen-left drawer's un-hidden inner content invisible", async () => {
|
||||||
|
// Real case (ooni.com): a slide-in search drawer is a position:fixed box parked at
|
||||||
|
// x:-375 (right edge at 0) with visibility:hidden; its inner content sets
|
||||||
|
// visibility:visible (so getComputedStyle reports it visible) but the whole box is
|
||||||
|
// still off the left edge. The drawer must not leak into the clone's DOM text.
|
||||||
|
const snap = await capture(`
|
||||||
|
<div class="drawer" style="position:fixed;left:0;top:0;width:375px;height:768px;
|
||||||
|
transform:translateX(-100%);visibility:hidden;">
|
||||||
|
<div class="inner" style="visibility:visible;width:343px;">
|
||||||
|
<h2 class="drawer-title">Popular Searches</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="onscreen">Hello</p>`);
|
||||||
|
const title = findByClass(snap.root, "drawer-title")!;
|
||||||
|
assert.equal(title.visible, false, "off-screen-left drawer title should be invisible");
|
||||||
|
// The genuinely on-screen paragraph is still visible (sanity that the gate isn't over-broad).
|
||||||
|
const onscreen = findByClass(snap.root, "onscreen")!;
|
||||||
|
assert.equal(onscreen.visible, true, "on-screen content stays visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a partially-overlapping negative-margin decoration visible", async () => {
|
||||||
|
// A decoration pulled left by a negative margin so it straddles the left edge
|
||||||
|
// (x:-40, width:200 -> right edge +160) still paints and must stay visible.
|
||||||
|
const snap = await capture(`
|
||||||
|
<div style="overflow:hidden;width:375px;">
|
||||||
|
<div class="deco" style="width:200px;height:40px;margin-left:-40px;background:red;">peek</div>
|
||||||
|
</div>`);
|
||||||
|
const deco = findByClass(snap.root, "deco")!;
|
||||||
|
assert.ok(deco.bbox.x < 0, "decoration starts off-screen left");
|
||||||
|
assert.ok(deco.bbox.x + deco.bbox.width > 0, "but its right edge is on-screen");
|
||||||
|
assert.equal(deco.visible, true, "partially-overlapping decoration stays visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps normal in-flow content below the fold visible", async () => {
|
||||||
|
// The page scrolls vertically, so content below the viewport is reachable and must
|
||||||
|
// NOT be marked invisible (only position:fixed boxes are pinned).
|
||||||
|
const snap = await capture(`
|
||||||
|
<div style="height:2000px;"></div>
|
||||||
|
<p class="belowfold" style="height:40px;">Below the fold</p>`);
|
||||||
|
const below = findByClass(snap.root, "belowfold")!;
|
||||||
|
assert.ok(below.bbox.y > 768, "content is below the 768px viewport");
|
||||||
|
assert.equal(below.visible, true, "below-fold in-flow content stays visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a computed-visibility:hidden subtree invisible", async () => {
|
||||||
|
// A subtree whose computed visibility is actually hidden (and does not set
|
||||||
|
// visibility:visible on any descendant) is not painted.
|
||||||
|
const snap = await capture(`
|
||||||
|
<div style="visibility:hidden;width:300px;">
|
||||||
|
<span class="hidden-child">secret</span>
|
||||||
|
</div>`);
|
||||||
|
const child = findByClass(snap.root, "hidden-child")!;
|
||||||
|
assert.equal(child.visible, false, "computed-hidden child is invisible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a fixed box parked entirely below the viewport", async () => {
|
||||||
|
// A position:fixed banner pinned below the viewport bottom never scrolls into view.
|
||||||
|
const snap = await capture(`
|
||||||
|
<div class="fixedlow" style="position:fixed;left:0;top:900px;width:375px;height:60px;">
|
||||||
|
<span>hidden banner</span>
|
||||||
|
</div>`);
|
||||||
|
const fixed = findByClass(snap.root, "fixedlow")!;
|
||||||
|
assert.equal(fixed.visible, false, "fixed box below the viewport is invisible");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user