diff --git a/compiler/src/capture/capture.ts b/compiler/src/capture/capture.ts index 4b92679..a29a3c2 100644 --- a/compiler/src/capture/capture.ts +++ b/compiler/src/capture/capture.ts @@ -14,6 +14,7 @@ import { import { discoverBreakpoints } from "./breakpoints.js"; import { writeJSON, writeJSONCompact, writeBytes, ensureDir } from "../util/fsx.js"; import { sha1_12, round } from "../util/canonical.js"; +import { isZipArchive, extractDotLottieJson } from "./dotlottie.js"; export const REQUIRED_VIEWPORTS = [375, 768, 1280, 1920] as const; // The dense width set captured for SIZE INFERENCE: a node sampled at 9 widths reveals its sizing @@ -115,13 +116,19 @@ export function isRetryableAssetFailure(type: string, status: number | null): bo return status >= 500 || status === 429; } -function extFromUrl(url: string): string { +// Bound on a preserved file extension. Real extensions are short, but a hard 5-char cap +// silently truncates legitimate ones (`.lottie` → `.lotti`, `.webmanifest` → `.webma`), +// which then mis-materializes the asset. Keep the guard generous enough for the longest +// real extensions and reject anything absurdly long (a dotted path segment, not an ext). +const MAX_EXT_LEN = 12; + +export function extFromUrl(url: string): string { try { const p = new URL(url).pathname; const dot = p.lastIndexOf("."); if (dot >= 0 && dot > p.lastIndexOf("/")) { - const ext = p.slice(dot + 1).toLowerCase().slice(0, 5); - if (/^[a-z0-9]+$/.test(ext)) return ext; + const ext = p.slice(dot + 1).toLowerCase(); + if (ext.length <= MAX_EXT_LEN && /^[a-z0-9]+$/.test(ext)) return ext; } } catch { /* ignore */ } return ""; @@ -209,6 +216,41 @@ async function settle(page: import("playwright").Page, maxMs = 2500): Promise { + try { + await Promise.race([ + page.evaluate(async () => { + const raf = () => new Promise((r) => requestAnimationFrame(() => r())); + const resetAll = () => { + window.scrollTo(0, 0); + for (const el of Array.from(document.querySelectorAll("*"))) { + if (el.scrollLeft) el.scrollLeft = 0; + if (el.scrollTop) el.scrollTop = 0; + } + }; + resetAll(); + // Let scroll-linked effects (scroll-state classes, sticky offsets, JS scroll handlers) + // recompute at scroll 0 over several frames, re-asserting the top position each tick in + // case a handler nudged it, then hold briefly for quiescence. + for (let i = 0; i < 6; i++) { await raf(); resetAll(); } + await new Promise((r) => setTimeout(r, 120)); + resetAll(); + await raf(); + }), + new Promise((r) => setTimeout(r, 4000)), + ]); + } catch { /* ignore — a best-effort reset never blocks the snapshot */ } +} + export type DismissResult = { dismissed: string[]; overlaysRemaining: number; removed: number; blocking: boolean }; /** @@ -579,7 +621,18 @@ export async function captureSite(opts: { if (type === "video" && !looksLikeVideoFile(bytes)) return; const a = assetMap.get(url) ?? recordAsset(url, type, null, null, "network"); if (a.storedAs) return; - const ext = extFromUrl(url) || extFromContentType(a.contentType) || + // A `.lottie` (dotLottie) asset is a ZIP archive, not bare lottie-web JSON. lottie-web's + // `path:` loader does a JSON.parse and throws on the ZIP bytes, blanking the container. So + // unwrap it here: extract the default animation JSON and store THAT, materializing every + // lottie source as plain JSON regardless of the container it arrived in. + let extOverride: string | null = null; + if (type === "lottie" && isZipArchive(bytes)) { + const json = extractDotLottieJson(bytes); + if (!json) return; // unreadable dotLottie — leave unstored rather than ship a broken ZIP + bytes = json; + extOverride = "json"; + } + const ext = extOverride || extFromUrl(url) || extFromContentType(a.contentType) || (type === "css" ? "css" : type === "font" ? "woff2" : type === "svg" ? "svg" : type === "video" ? "mp4" : type === "lottie" ? "json" : "png"); const name = `${sha1_12(url)}.${ext}`; @@ -973,6 +1026,12 @@ export async function captureSite(opts: { const scrollAnimsCanceled = await neutralizeScrollTimelineAnimations(page); if (scrollAnimsCanceled) log({ event: "scroll_timeline_anims_canceled", viewport: vw, count: scrollAnimsCanceled }); + // Final scroll-state reset immediately before the walk: every preceding pass (dwell + // scroll, carousel settle, element screenshots) can leave the page scrolled, which bakes + // scroll-linked transforms/offsets into this viewport's computed styles. Scroll to top and + // let scroll-linked effects settle so the snapshot records the genuine at-rest values. + await settleScrollTopBeforeSnapshot(page); + // 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. const snapshot: PageSnapshot = await Promise.race([ diff --git a/compiler/src/capture/dotlottie.ts b/compiler/src/capture/dotlottie.ts new file mode 100644 index 0000000..ca7d4da --- /dev/null +++ b/compiler/src/capture/dotlottie.ts @@ -0,0 +1,123 @@ +import { inflateRawSync } from "node:zlib"; + +/** + * dotLottie (.lottie) extraction. + * + * A `.lottie` file is NOT bare lottie-web JSON — it is a ZIP archive (the "dotLottie" + * container) holding `manifest.json` plus one or more `animations/*.json` documents. Feeding + * the raw ZIP bytes to lottie-web via `path:` makes it try to JSON.parse a ZIP, which throws + * an InvalidStateError at runtime and leaves the container blank. So at materialization time + * we detect the ZIP, pick the default/first animation, and rewrite the stored asset as plain + * lottie JSON that lottie-web can parse directly. + * + * Node ships `zlib` (raw DEFLATE) but no ZIP reader, and pulling a zip dependency for this + * one shape is overkill — so this implements the minimal slice of the ZIP spec needed: + * walking the End-Of-Central-Directory + central directory to locate entries, then reading + * each local file header to decompress a stored (method 0) or deflated (method 8) entry. + * Deterministic: no timestamps, no randomness, entries resolved by name. + */ + +/** ZIP local file header signature (`PK\x03\x04`). dotLottie archives always start with it. */ +export function isZipArchive(bytes: Buffer): boolean { + return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04; +} + +const EOCD_SIG = 0x06054b50; // End Of Central Directory +const CEN_SIG = 0x02014b50; // Central directory file header +const LOC_SIG = 0x04034b50; // Local file header + +type ZipEntry = { name: string; method: number; compSize: number; uncompSize: number; localOffset: number }; + +/** Locate + read the End-Of-Central-Directory record, then walk the central directory. */ +function readCentralDirectory(buf: Buffer): ZipEntry[] { + // The EOCD lives at the tail; it is >=22 bytes and may carry a trailing comment, so scan + // backwards for its signature within the max comment window (64KiB) + record size. + const minEocd = 22; + if (buf.length < minEocd) return []; + let eocd = -1; + const scanFrom = Math.max(0, buf.length - (0xffff + minEocd)); + for (let i = buf.length - minEocd; i >= scanFrom; i--) { + if (buf.readUInt32LE(i) === EOCD_SIG) { eocd = i; break; } + } + if (eocd < 0) return []; + + const entryCount = buf.readUInt16LE(eocd + 10); + let ptr = buf.readUInt32LE(eocd + 16); // central directory offset + const entries: ZipEntry[] = []; + for (let i = 0; i < entryCount; i++) { + if (ptr + 46 > buf.length || buf.readUInt32LE(ptr) !== CEN_SIG) break; + const method = buf.readUInt16LE(ptr + 10); + const compSize = buf.readUInt32LE(ptr + 20); + const uncompSize = buf.readUInt32LE(ptr + 24); + const nameLen = buf.readUInt16LE(ptr + 28); + const extraLen = buf.readUInt16LE(ptr + 30); + const commentLen = buf.readUInt16LE(ptr + 32); + const localOffset = buf.readUInt32LE(ptr + 42); + const name = buf.toString("utf8", ptr + 46, ptr + 46 + nameLen); + entries.push({ name, method, compSize, uncompSize, localOffset }); + ptr += 46 + nameLen + extraLen + commentLen; + } + return entries; +} + +/** Decompress one central-directory entry by reading its local header for the data offset. */ +function readEntry(buf: Buffer, e: ZipEntry): Buffer | null { + const p = e.localOffset; + if (p + 30 > buf.length || buf.readUInt32LE(p) !== LOC_SIG) return null; + // The central directory's name/extra lengths can differ from the local header's, so read the + // local header's own lengths to find where the file data begins. + const nameLen = buf.readUInt16LE(p + 26); + const extraLen = buf.readUInt16LE(p + 28); + const dataStart = p + 30 + nameLen + extraLen; + const dataEnd = dataStart + e.compSize; + if (dataEnd > buf.length) return null; + const comp = buf.subarray(dataStart, dataEnd); + if (e.method === 0) return Buffer.from(comp); // stored + if (e.method === 8) { + try { return inflateRawSync(comp); } catch { return null; } + } + return null; // unsupported compression method +} + +/** Read a named entry (exact path match) from a ZIP buffer, or null if absent/unreadable. */ +export function readZipEntry(buf: Buffer, name: string): Buffer | null { + const entry = readCentralDirectory(buf).find((e) => e.name === name); + return entry ? readEntry(buf, entry) : null; +} + +/** + * Extract the lottie animation JSON from a dotLottie ZIP. Picks the manifest's first/default + * animation (falling back to the first `animations/*.json` entry), returning it as a Buffer of + * plain lottie JSON ready to hand to lottie-web via `path:`. Returns null when the bytes are + * not a dotLottie ZIP or no animation JSON is present. + */ +export function extractDotLottieJson(bytes: Buffer): Buffer | null { + if (!isZipArchive(bytes)) return null; + const entries = readCentralDirectory(bytes); + if (!entries.length) return null; + + const animEntries = entries + .filter((e) => /^animations\/.+\.json$/i.test(e.name)) + .sort((a, b) => a.name.localeCompare(b.name)); // deterministic ordering + if (!animEntries.length) return null; + + // Prefer the animation the manifest names first (its default), when the manifest is readable. + let chosen = animEntries[0]!; + const manifestBuf = readZipEntry(bytes, "manifest.json"); + if (manifestBuf) { + try { + const manifest = JSON.parse(manifestBuf.toString("utf8")) as { animations?: Array<{ id?: string }> }; + const firstId = manifest.animations?.[0]?.id; + if (firstId) { + const match = animEntries.find((e) => e.name === `animations/${firstId}.json`); + if (match) chosen = match; + } + } catch { /* manifest unreadable — keep the name-sorted first animation */ } + } + + const json = readEntry(bytes, chosen); + if (!json) return null; + // Validate it parses as JSON (lottie-web's `path:` loader does a bare JSON.parse). + try { JSON.parse(json.toString("utf8")); } catch { return null; } + return json; +} diff --git a/compiler/src/capture/motion.ts b/compiler/src/capture/motion.ts index 1dcc955..c35b5b2 100644 --- a/compiler/src/capture/motion.ts +++ b/compiler/src/capture/motion.ts @@ -60,6 +60,83 @@ export type MarqueeSpec = { periodPx: number; // distance per seamless loop (one duplicated copy ≈ scrollWidth/2) }; +// ---- Pure marquee discriminators (extracted so the in-browser sampling logic is unit +// testable). These are duplicated verbatim inside `detectMarquees`' page.evaluate body — +// module-scope functions do NOT cross the serialization boundary — so any change here must +// be mirrored there (and vice versa). They are deterministic: no randomness, no clock reads. + +/** + * Median signed velocity (px/s) from a series of per-sample translateX deltas taken at a + * fixed cadence. Median (not mean) ignores the single per-loop wrap-reset outlier, which + * is a large jump opposite the travel direction when the track seamlessly restarts. + */ +export function medianVelocityPxPerSec(deltas: number[], sampleMs: number): number { + if (!deltas.length || sampleMs <= 0) return 0; + const med = [...deltas].sort((a, b) => a - b)[Math.floor(deltas.length / 2)]!; + return Math.round((med / sampleMs) * 1000); +} + +/** + * Discriminator 2 — sustained-constant-velocity test. A real marquee holds a steady + * velocity across time; a scroll-settle lerp (still easing toward its scroll-target after + * `scrollIntoView`) decays, so its velocity in a later observation window is a small + * fraction of the earlier one. Two windows of per-sample deltas separated by ≥1.5s; pass + * only if the later window's speed is BOTH non-trivial AND close to the earlier window's + * (within a relative tolerance) — i.e. it did not decay and did not reverse. + * + * @param window1Deltas per-sample translateX deltas from the first observation window + * @param window2Deltas per-sample translateX deltas from the second (later) window + * @param sampleMs cadence between samples within a window + * @param minPxPerSec minimum sustained |velocity| to be considered moving at all + * @param relTol fractional tolerance: |v2| must be ≥ (1-relTol)·|v1| and same sign + */ +export function classifyVelocitySamples( + window1Deltas: number[], + window2Deltas: number[], + sampleMs: number, + minPxPerSec = 4, + relTol = 0.5, +): { isMarquee: boolean; pxPerSec: number; v1: number; v2: number } { + const v1 = medianVelocityPxPerSec(window1Deltas, sampleMs); + const v2 = medianVelocityPxPerSec(window2Deltas, sampleMs); + // The reported velocity is the first window's (measured closest to a clean, post-settle + // marquee; also what the old code reported), kept for output determinism vs. the old path. + const pxPerSec = v1; + if (Math.abs(v1) < minPxPerSec || Math.abs(v2) < minPxPerSec) return { isMarquee: false, pxPerSec, v1, v2 }; + if (Math.sign(v1) !== Math.sign(v2)) return { isMarquee: false, pxPerSec, v1, v2 }; // reversed → not a steady ticker + // v2 must NOT have decayed relative to v1 (a lerp settling toward target loses speed). + const sustained = Math.abs(v2) >= Math.abs(v1) * (1 - relTol); + return { isMarquee: sustained, pxPerSec, v1, v2 }; +} + +/** + * Discriminator 4 — genuine duplicated content. A marquee duplicates its content ≥2× + * (that is how a seamless loop works); "≥2 children" alone is far too weak (any flex row + * of distinct logos passes). Require actual repetition: ≥2 CONSECUTIVE children whose + * shape repeats — equal outerHTML hash (exact duplicate) or, as a looser structural + * fallback, an equal consecutive width sequence (some marquees clone then tweak attributes + * so hashes differ but the geometry repeats). + * + * @param hashes per-child outerHTML hash (cheap 32-bit), index-aligned with `widths` + * @param widths per-child rounded offsetWidth + */ +export function hasRepeatedChildren(hashes: number[], widths: number[]): boolean { + const n = Math.min(hashes.length, widths.length); + if (n < 2) return false; + // (a) two consecutive children with an identical hash — a literal cloned copy. + for (let i = 1; i < n; i++) if (hashes[i] === hashes[i - 1] && hashes[i] !== 0) return true; + // (b) structural fallback: the first half's width sequence repeats in the second half + // (content duplicated as a block, e.g. [A B C A B C]). Require an even count and a real + // width so a row of zero-width nodes can't spuriously "repeat". + if (n >= 4 && n % 2 === 0) { + const half = n / 2; + let repeats = true; + for (let i = 0; i < half; i++) { if (widths[i] === 0 || widths[i] !== widths[i + half]) { repeats = false; break; } } + if (repeats) return true; + } + return false; +} + export type MotionCapture = { waapi: WaapiAnim[]; rotators: RotatorSpec[]; @@ -132,6 +209,25 @@ export async function probeReveals(page: Page): Promise { * and record its signed velocity + seamless-loop period (one duplicated copy ≈ half the * scroll width) so the clone can replay the loop. Read-only beyond scrolling (restores * scroll to top); does not touch the captured snapshot/IR. + * + * Discriminators (ALL must pass — a candidate is only classified as a marquee if every one + * holds), added to defeat scroll-LINKED easing false positives (a static logo row on a + * scroll-eased page is still lerping toward its scroll-target right after `scrollIntoView`, + * which reads as a constant velocity over a single short window): + * 1. Content overflow — scrollWidth > 1.35·clientWidth (a marquee has content to scroll; + * a static row that fits its box, scrollWidth ≈ clientWidth, cannot be a marquee). + * 2. Sustained constant velocity — two observation windows separated by ≥1.5s; a settle + * lerp decays (v2 ≪ v1), a real marquee holds v2 ≈ v1 (see classifyVelocitySamples). + * 3. Scroll independence (jiggle) — nudge scroll by ±50px and re-sample; if the velocity + * responds to the nudge the motion is scroll-linked, not a self-driven ticker. + * 4. Genuine duplication — ≥2 consecutive children repeat (equal outerHTML hash or an + * equal consecutive width sequence), the shape real marquees use for a seamless loop. + * + * Added latency is bounded: the expensive per-candidate windows/jiggle run ONLY for the + * few candidates that already pass the cheap synchronous gates (overflow + translated + + * clip + duplication), and the candidate list is capped at 8. Per surviving candidate the + * async cost is ≈ 300ms settle + 2×~600ms windows + ~1.4s inter-window gap + 2×~250ms + * jiggle ≈ 3.4s; with the cap the whole pass is bounded regardless of page size. */ async function detectMarquees(page: Page): Promise { try { @@ -143,32 +239,113 @@ async function detectMarquees(page: Page): Promise { while (p && depth < 6) { const ox = getComputedStyle(p).overflowX; if (ox === "hidden" || ox === "clip") return true; p = p.parentElement; depth++; } return false; }; + // cheap 32-bit string hash (djb2-ish) — deterministic, for the duplication test. + const hashStr = (s: string): number => { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return h; }; + + // ---- Pure discriminators (mirror of the exported module-scope functions; kept inline + // because module scope does not cross the page.evaluate serialization boundary). ---- + const medianVelocityPxPerSec = (deltas: number[], sampleMs: number): number => { + if (!deltas.length || sampleMs <= 0) return 0; + const med = [...deltas].sort((a, b) => a - b)[Math.floor(deltas.length / 2)]!; + return Math.round((med / sampleMs) * 1000); + }; + const classifyVelocitySamples = (w1: number[], w2: number[], sampleMs: number, minPxPerSec = 4, relTol = 0.5) => { + const v1 = medianVelocityPxPerSec(w1, sampleMs); + const v2 = medianVelocityPxPerSec(w2, sampleMs); + const pxPerSec = v1; + if (Math.abs(v1) < minPxPerSec || Math.abs(v2) < minPxPerSec) return { isMarquee: false, pxPerSec, v1, v2 }; + if (Math.sign(v1) !== Math.sign(v2)) return { isMarquee: false, pxPerSec, v1, v2 }; + const sustained = Math.abs(v2) >= Math.abs(v1) * (1 - relTol); + return { isMarquee: sustained, pxPerSec, v1, v2 }; + }; + const hasRepeatedChildren = (hashes: number[], widths: number[]): boolean => { + const n = Math.min(hashes.length, widths.length); + if (n < 2) return false; + for (let i = 1; i < n; i++) if (hashes[i] === hashes[i - 1] && hashes[i] !== 0) return true; + if (n >= 4 && n % 2 === 0) { + const half = n / 2; + let repeats = true; + for (let i = 0; i < half; i++) { if (widths[i] === 0 || widths[i] !== widths[i + half]) { repeats = false; break; } } + if (repeats) return true; + } + return false; + }; + // sample per-child outerHTML hash + width for the duplication test. + const childSignature = (el: Element): { hashes: number[]; widths: number[] } => { + const hashes: number[] = [], widths: number[] = []; + const kids = Array.from(el.children).slice(0, 24) as HTMLElement[]; + for (const k of kids) { hashes.push(hashStr(k.outerHTML)); widths.push(Math.round(k.getBoundingClientRect().width)); } + return { hashes, widths }; + }; + // one observation window: `count` deltas at `sampleMs` cadence. + const sampleWindow = async (el: Element, count: number, sampleMs: number): Promise => { + const xs: number[] = [txOf(el)]; + for (let i = 0; i < count; i++) { await sleep(sampleMs); xs.push(txOf(el)); } + const dxs: number[] = []; for (let i = 1; i < xs.length; i++) dxs.push(xs[i]! - xs[i - 1]!); + return dxs; + }; + + const SAMPLE_MS = 120; + const WINDOW_SAMPLES = 5; // 5 deltas ≈ 600ms per window + const INTER_WINDOW_MS = 1500; // ≥1.5s between windows (discriminator 2) + const JIGGLE_PX = 50; // scroll nudge for the independence test (discriminator 3) + // Candidates: tagged, already translated (paused tickers keep their offset), with - // duplicated content (≥2 children) inside an overflow-clip viewport — the marquee shape. + // GENUINE duplicated content inside an overflow-clip viewport, AND real content + // overflow (scrollWidth > 1.35·clientWidth) — the marquee shape. All cheap/synchronous + // so the expensive async windows only run for the few survivors. const cand: Element[] = []; for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) { if (Math.abs(txOf(el)) <= 0.5) continue; if (el.children.length < 2) continue; if (!inClip(el)) continue; + // Discriminator 1: content overflow. scrollWidth must meaningfully exceed clientWidth; + // a static row that fits (scrollWidth ≈ clientWidth) has nothing to scroll. + if (el.scrollWidth <= el.clientWidth * 1.35) continue; + // Discriminator 4: genuine repetition, not merely ≥2 distinct children. + const sig = childSignature(el); + if (!hasRepeatedChildren(sig.hashes, sig.widths)) continue; cand.push(el); - if (cand.length >= 16) break; + if (cand.length >= 8) break; } const out: Array<{ cap: string; axis: "x"; pxPerSec: number; periodPx: number }> = []; const seen = new Set(); for (const el of cand) { const cap = el.getAttribute("data-cid-cap"); if (!cap || seen.has(cap)) continue; try { el.scrollIntoView({ block: "center" }); } catch { /* ignore */ } - await sleep(450); // wake the off-screen-paused ticker + let the scroll settle - const xs: number[] = []; - for (let i = 0; i < 6; i++) { xs.push(txOf(el)); await sleep(120); } - const dxs: number[] = []; for (let i = 1; i < xs.length; i++) dxs.push(xs[i]! - xs[i - 1]!); - if (dxs.length < 3) continue; - // velocity = median per-120ms delta (median ignores the single wrap-reset outlier). - const med = [...dxs].sort((a, b) => a - b)[Math.floor(dxs.length / 2)]!; - const pxPerSec = Math.round((med / 120) * 1000); - if (Math.abs(pxPerSec) < 4) continue; // not actually moving (e.g. a frozen scroll-scrub offset) - // direction must be consistent in all but (at most) the one wrap step. - if (dxs.filter((d) => Math.sign(d) === Math.sign(med)).length < dxs.length - 1) continue; + await sleep(300); // wake the off-screen-paused ticker + let the scroll settle + + // Discriminator 2: two velocity windows ≥1.5s apart. A scroll-settle lerp decays; + // a real marquee holds constant velocity. + const w1 = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS); + await sleep(INTER_WINDOW_MS); + const w2 = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS); + if (w1.length < 3 || w2.length < 3) continue; + // direction must be consistent within window 1, but for (at most) the one wrap step. + const med1 = [...w1].sort((a, b) => a - b)[Math.floor(w1.length / 2)]!; + if (w1.filter((d) => Math.sign(d) === Math.sign(med1)).length < w1.length - 1) continue; + const cls = classifyVelocitySamples(w1, w2, SAMPLE_MS); + if (!cls.isMarquee) continue; + const pxPerSec = cls.pxPerSec; + + // Discriminator 3: scroll independence (jiggle). Nudge the scroll ±50px and re-sample + // velocity; a self-driven ticker is unaffected, a scroll-linked animation changes. + const baseY = window.scrollY; + const jiggle = async (dy: number): Promise => { + try { window.scrollTo(0, Math.max(0, baseY + dy)); } catch { /* ignore */ } + await sleep(250); // let any scroll-linked easing react and settle at the new offset + const dxs = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS); + return medianVelocityPxPerSec(dxs, SAMPLE_MS); + }; + const vDown = await jiggle(JIGGLE_PX); + const vUp = await jiggle(-JIGGLE_PX); + try { window.scrollTo(0, baseY); } catch { /* ignore */ } + // Scroll-linked motion responds to the nudge (velocity reverses, dies, or spikes as + // it re-lerps to the new target). A real marquee holds ~pxPerSec through both nudges. + const stableUnderJiggle = (v: number): boolean => + Math.sign(v) === Math.sign(pxPerSec) && Math.abs(v) >= Math.abs(pxPerSec) * 0.5 && Math.abs(v) <= Math.abs(pxPerSec) * 2; + if (!stableUnderJiggle(vDown) || !stableUnderJiggle(vUp)) continue; + const periodPx = Math.round(el.scrollWidth / 2); if (periodPx < 40) continue; seen.add(cap); diff --git a/compiler/src/capture/walker.ts b/compiler/src/capture/walker.ts index f44e9d1..54edb3f 100644 --- a/compiler/src/capture/walker.ts +++ b/compiler/src/capture/walker.ts @@ -380,9 +380,22 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot { const hf = el.getBoundingClientRect().height; // Tight 0.5px: only call a dimension "reproduced" when auto/100% lands essentially exactly, // so a drop can't accumulate a visible shift across many elements (favours fidelity). + let hAuto = Math.abs(ha - r.height) <= 0.5; + let hFill = Math.abs(hf - r.height) <= 0.5; + // Circular-height guard: a box whose fill child (height:100%) pins it back up makes BOTH + // `height:auto` and `height:100%` reproduce the box, so the raw verdict reads hAuto (drop) — + // even though the height is authored (e.g. `100vh` on a hero, an explicit px section). Both + // sides then wait on each other and the box collapses. When this element's own cascade/inline + // style declares an explicit definite height, trust that declaration: the height is authored, + // so it is neither content-sized (hAuto) nor a parent fill (hFill). Only overrides when auto + // actually reproduced — a genuine explicit height that auto already shrinks stays hAuto:false. + if ((hAuto || hFill) && r.height > 2 && authorsExplicitHeight(el, sh)) { + hAuto = false; + hFill = false; + } sizing = { - wAuto: Math.abs(wa - r.width) <= 0.5, wFill: Math.abs(wf - r.width) <= 0.5, hAuto: Math.abs(ha - r.height) <= 0.5, - hFill: Math.abs(hf - r.height) <= 0.5, + wAuto: Math.abs(wa - r.width) <= 0.5, wFill: Math.abs(wf - r.width) <= 0.5, hAuto, + hFill, wMin: Math.round(wmin * 100) / 100, wMax: Math.round(wmax * 100) / 100, }; } finally { @@ -492,6 +505,14 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot { const cssUrlSet = new Set(); const keyframes: string[] = []; const cssVars: Record = {}; + // Selectors that AUTHOR an explicit, definite height/min-height (px/rem/em/vh/vw/…, + // NOT auto, NOT a percentage, NOT a keyword). Harvested once from the cascade below and + // consulted by the sizing probe to break circular parent/child height verdicts: when a box + // and its fill child mutually justify each other's height, `height:auto` reproduces the box + // for a reason that is NOT "content-sized", so the probe alone reads hAuto:true and the + // authored dimension gets dropped downstream. A declared explicit length is ground truth that + // the height is load-bearing, so we trust the declaration over the reflow verdict. + const explicitHeightSelectors: string[] = []; const absUrl = (u: string): string => { try { return new URL(u, document.baseURI).href; } catch { return u; } @@ -507,6 +528,39 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot { } }; + // True when a `height`/`min-height` value is an explicit, definite length the browser resolves + // to a fixed px box (px/rem/em/vh/vw/vmin/vmax/ch/…, or a calc/min/max/clamp over them). False for + // `auto`, an empty value, a pure percentage (resolves against the parent — that's the fill case the + // hFill probe already handles), `0`, and intrinsic keywords (fit-/min-/max-content). A definite + // authored height is load-bearing and must survive even when the reflow probe reads hAuto:true. + const isExplicitHeight = (raw: string): boolean => { + const v = (raw || "").trim().toLowerCase(); + if (!v || v === "auto" || v === "0" || v === "0px" || v === "none") return false; + if (v === "fit-content" || v === "min-content" || v === "max-content" || v === "inherit" || + v === "initial" || v === "unset" || v === "revert" || v === "revert-layer") return false; + // A bare percentage resolves against the parent (fill), not an authored definite length. + if (/^[\d.]+%$/.test(v)) return false; + // A definite length unit (or a calc()/min()/max()/clamp() that contains one) anchors the box. + return /(?:^|[\s(*/+-])[\d.]+(?:px|rem|em|vh|vw|vmin|vmax|svh|lvh|dvh|cm|mm|in|pt|pc|ex|ch|q)\b/.test(v); + }; + + // Does this element author an explicit definite height — via its own inline style (passed as + // `inlineHeight`, already read by the probe) or via any matched cascade rule harvested into + // `explicitHeightSelectors`? getComputedStyle resolves height to used px (so `100vh` reads as a + // plain number and is indistinguishable from a content height there); the specified value is only + // recoverable from the inline declaration and the cascade, which is why we consult both. + const authorsExplicitHeight = (el: Element, inlineHeight: string): boolean => { + if (isExplicitHeight(inlineHeight)) return true; + try { + const inlineMin = (el as HTMLElement).style?.getPropertyValue("min-height") || ""; + if (isExplicitHeight(inlineMin)) return true; + } catch { /* ignore */ } + for (const sel of explicitHeightSelectors) { + try { if (el.matches(sel)) return true; } catch { /* invalid/unsupported selector */ } + } + return false; + }; + const readRules = (rules: CSSRuleList): void => { for (const rule of Array.from(rules)) { const type = rule.constructor.name; @@ -532,6 +586,11 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot { } else if (type === "CSSStyleRule") { const r = rule as CSSStyleRule; if (r.style && r.style.cssText.includes("url(")) harvestUrlsFromText(r.style.cssText); + if (r.selectorText && r.style && + (isExplicitHeight(r.style.getPropertyValue("height")) || + isExplicitHeight(r.style.getPropertyValue("min-height")))) { + explicitHeightSelectors.push(r.selectorText); + } } else if (type === "CSSMediaRule" || type === "CSSSupportsRule") { try { readRules((rule as CSSGroupingRule).cssRules); } catch { /* ignore */ } } else if (type === "CSSImportRule") { diff --git a/compiler/src/generate/app.ts b/compiler/src/generate/app.ts index 534a821..650998e 100644 --- a/compiler/src/generate/app.ts +++ b/compiler/src/generate/app.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import { rmSync } from "node:fs"; import { writeText } from "../util/fsx.js"; -import type { IR, IRNode, IRChild, IRTextNode } from "../normalize/ir.js"; +import type { IR, IRNode, IRChild, IRTextNode, StyleMap } from "../normalize/ir.js"; import { isTextChild } from "../normalize/ir.js"; import { generateCss, RESET_CSS } from "./css.js"; import { generateInteractionCss } from "./interactionCss.js"; @@ -2384,17 +2384,75 @@ ${input} `; } -function recipeResponsiveClassCleaner(recipes: RecipeReport | undefined, opts: { tailwind: boolean }): (cid: string, className: string | undefined) => string | undefined { +// Number of explicit column tracks in a computed `grid-template-columns` value. The capture +// resolves the property to a used track list (e.g. `284px 284px 284px`), so track count is the +// whitespace-separated token count; `none`/empty means the element is not a track grid. +function computedTrackCount(value: string | undefined): number { + if (!value || value === "none") return 0; + return value.trim().split(/\s+/).filter(Boolean).length; +} + +// Track span of a grid item from its resolved `grid-column-start`/`grid-column-end`. A spanning +// item (`span 2`, or an explicit line pair `1 / 3`) means row-length item-counting under-reports +// the real track count, so the column-count heuristic is not trustworthy for this container. +function computedColumnSpan(cs: StyleMap | undefined): number { + if (!cs) return 1; + const end = cs["gridColumnEnd"]; + const start = cs["gridColumnStart"]; + const span = /^span\s+(\d+)$/.exec(end ?? "") ?? /^span\s+(\d+)$/.exec(start ?? ""); + if (span) return Math.max(1, Number(span[1])); + if (start && end && /^-?\d+$/.test(start) && /^-?\d+$/.test(end)) { + const diff = Number(end) - Number(start); + if (Number.isFinite(diff) && diff > 1) return diff; + } + return 1; +} + +export function recipeResponsiveClassCleaner(ir: IR, recipes: RecipeReport | undefined, opts: { tailwind: boolean }): (cid: string, className: string | undefined) => string | undefined { const recipeParents = new Set(); const repeatedGridParents = new Set(); const fillGridParents = new Set(); + // Containers whose computed geometry agreed with the item-count heuristic — a genuinely uniform + // repeated grid the recipe may re-flow. Row-track utilities are only stripped here; on containers + // where the geometry disagreed (spanning items / differing track count) the authored row and + // column tracks are ground truth and must survive. + const uniformGridParents = new Set(); const columnPlans = new Map(); + const nodeByCid = new Map(); + const index = (n: IRNode): void => { nodeByCid.set(n.id, n); for (const c of n.children) if (!isTextChild(c)) index(c); }; + index(ir.root); + // The captured/computed grid geometry is ground truth. A recipe may add semantic structure, but + // its column count is inferred by grouping item bounding boxes into rows — which under-reports the + // real track count whenever an item spans multiple columns. Before letting a recipe's synthesized + // column plan override the authored grid, cross-check it against the computed `grid-template-columns` + // track count and per-item `grid-column` spans at each regime viewport. On disagreement, keep the + // emitted (computed) geometry and drop the plan so spans and track count survive. + const planAgreesWithComputed = (c: RecipeReport["candidates"][number], regimeVps: number[]): boolean => { + const parent = c.itemParentCid ? nodeByCid.get(c.itemParentCid) : undefined; + if (!parent) return false; + const itemCids = (c.repeatedItems ?? []).map((i) => i.cid); + for (const vp of regimeVps) { + const tracks = computedTrackCount(parent.computedByVp[vp]?.["gridTemplateColumns"]); + // Only trust the heuristic where the computed grid actually is a track grid at this viewport. + if (tracks === 0) continue; + const regime = c.responsiveRegimes.find((r) => r.viewport === vp); + const heuristicCols = regime?.columns ?? 0; + if (heuristicCols > 0 && heuristicCols !== tracks) return false; + for (const cid of itemCids) { + if (computedColumnSpan(nodeByCid.get(cid)?.computedByVp[vp]) > 1) return false; + } + } + return true; + }; const gridColumnTokens = (c: RecipeReport["candidates"][number]): string[] | null => { if (c.kind !== "card-grid" && c.kind !== "feature-grid" && c.kind !== "product-grid") return null; const regimes = c.responsiveRegimes .filter((r) => (r.visibleItems ?? 0) > 0 && (r.columns ?? 0) > 0) .sort((a, b) => a.viewport - b.viewport); if (regimes.length < 2) return null; + // The computed track geometry disagrees with the item-count heuristic (spanning items or a + // differing track count) — defer to the authored grid rather than synthesize a column plan. + if (!planAgreesWithComputed(c, regimes.map((r) => r.viewport))) return null; const prefixFor = (vp: number): string => vp >= 1536 ? "2xl:" : vp >= 1024 ? "lg:" : vp >= 768 ? "md:" : ""; const tokens: string[] = []; let last: number | undefined; @@ -2416,6 +2474,12 @@ function recipeResponsiveClassCleaner(recipes: RecipeReport | undefined, opts: { for (const c of recipes?.candidates ?? []) { if ((c.kind === "card-grid" || c.kind === "feature-grid" || c.kind === "product-grid" || c.kind === "gallery-showcase" || c.kind === "logo-cloud") && c.confidence >= 0.86 && c.itemParentCid) { recipeParents.add(c.itemParentCid); + // A container is a uniform repeated grid only if its computed track geometry agrees with the + // item-count heuristic at every sampled regime viewport (no spanning items, matching track + // count). Non-grid containers (flex/stack) have no computed tracks to disagree with, so they + // stay eligible; a track grid whose geometry disagrees is excluded and keeps its authored rows. + const sampledVps = c.responsiveRegimes.map((r) => r.viewport); + if (planAgreesWithComputed(c, sampledVps)) uniformGridParents.add(c.itemParentCid); if (c.kind === "card-grid" || c.kind === "feature-grid" || c.kind === "product-grid" || c.kind === "gallery-showcase") repeatedGridParents.add(c.itemParentCid); if (c.kind === "card-grid" || c.kind === "feature-grid" || c.kind === "product-grid") fillGridParents.add(c.itemParentCid); const columns = gridColumnTokens(c); @@ -2429,8 +2493,8 @@ function recipeResponsiveClassCleaner(recipes: RecipeReport | undefined, opts: { const columnPlan = opts.tailwind ? columnPlans.get(cid) : undefined; const keep = tokens.filter((token) => { if (columnPlan && /^(?:[a-z0-9-]+:)*grid-cols-(?:\d+|\[[^\]]+\])$/.test(token)) return false; - if (/^(?:[a-z0-9-]+:)*grid-rows-\[(?:auto_?)+\]$/.test(token)) return false; - if (repeatedGridParents.has(cid) && /^(?:[a-z0-9-]+:)*grid-rows-\d+$/.test(token)) return false; + if (uniformGridParents.has(cid) && /^(?:[a-z0-9-]+:)*grid-rows-\[(?:auto_?)+\]$/.test(token)) return false; + if (uniformGridParents.has(cid) && repeatedGridParents.has(cid) && /^(?:[a-z0-9-]+:)*grid-rows-\d+$/.test(token)) return false; const initialCols = /^(?:(.*):)?grid-cols-\[initial\]$/.exec(token); if (initialCols) { const prefix = initialCols[1] ? `${initialCols[1]}:` : ""; @@ -2477,7 +2541,7 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: // CSS mode: dedup into shared semantic CSS classes. Both fidelity-neutral. const tw = humanize && mode === "tailwind" ? buildTailwind(ir, assetMap, input.colorVar, { interaction: input.interaction, reflow: input.reflow }) : undefined; const classMap = humanize && mode === "css" ? buildClassMap(ir, assetMap, input.colorVar, input.primitives, input.tokenResolver) : undefined; - const cleanRecipeClass = recipeResponsiveClassCleaner(input.recipeReport, { tailwind: !!tw }); + const cleanRecipeClass = recipeResponsiveClassCleaner(ir, input.recipeReport, { tailwind: !!tw }); const classOf = tw ? (cid: string) => cleanRecipeClass(cid, tw.classOf.get(cid)) : classMap ? (cid: string) => classMap.classOf.get(cid) : undefined; const styleOf = tw ? (cid: string) => tw.styleOf.get(cid) : undefined; // Section split (single-page humanized): plan section roots, render each into its own diff --git a/compiler/src/generate/css.ts b/compiler/src/generate/css.ts index 33e6ceb..e63de00 100644 --- a/compiler/src/generate/css.ts +++ b/compiler/src/generate/css.ts @@ -2208,6 +2208,8 @@ function declsForViewport( geometry: GeometryPlan = GEOMETRY_NONE, dropGridRows = false, dropViewportMaxWidth = false, + nowrapText = false, + keepIdentityTransform = false, ): Map { const cs = node.computedByVp[vp]; const out = new Map(); @@ -2478,6 +2480,13 @@ function declsForViewport( // list reset is none; emit whatever the source uses (incl. none). } else if (prop === "minWidth") { if (value === "auto" || (value === "0px" && !isFlexGridItem) || (value === "0px" && inScrollXFlexStrip)) continue; + } else if (prop === "transform") { + // Emit the identity `transform:none` explicitly at a viewport whose value is `none` WHEN the node + // carries a non-identity transform at some OTHER band — otherwise the non-identity value cascades + // across bands and freezes at a width the source left untransformed. (Identity matrices are already + // canonicalized to the literal "none" upstream, so the string compare is reliable.) When the node + // is identity everywhere, this stays a normal default elision. + if (value === "none" && !keepIdentityTransform) continue; } else if (def === "__never__") { // always emit (display/color/fontFamily/fontSize handled below for inherit) } else if (isDefault(def, value)) { @@ -2506,6 +2515,15 @@ function declsForViewport( out.set(kebab(prop), outValue); } + // Wrap-vulnerable single-line text: the text renders on one line at every captured width and its + // unwrapped width nearly equals the container's available width, so a sub-pixel width shortfall in + // the clone (a column resolving ~0.6px narrower) tips it onto a second line, shifting everything + // below. `white-space:nowrap` holds it to one line — identical to the capture (already single-line + // at every width) but immune to the rounding shortfall. Only the source `whiteSpace` value that + // already collapses runs (`normal`/`nowrap`) is overridden; `pre`/`pre-wrap`/`break-spaces` preserve + // authored whitespace and are left untouched (the caller's detector also excludes them). + if (nowrapText) out.set("white-space", "nowrap"); + // Centered max-width container: emit auto side margins so the browser centers // it at every width. getComputedStyle sometimes reports 0 for resolved auto // margins at wide viewports, so replaying px would left-align the clone. @@ -2641,7 +2659,14 @@ function centeredAtVp(node: IRNode, parentNode: IRNode, vp: number): boolean { // marquee). Converting fixed spacing to `auto` deletes it when the parent shrink-wraps the child // (auto resolves to 0) — exactly how the testimonial inter-card gap vanished. Literal margins // reproduce that spacing at every width regardless of the parent's resolved width. - if (ml > 0.5 && Math.abs(ml - mr) < 1 && nb.width < pb.width - 4 && marginsVaryAcrossVps(node)) return true; + // The width must ALSO be genuinely constrained: a box the sizing probe reads as a container-FILL + // (`width:100%` reproduces it) has no free space for auto margins to absorb — the emitted width + // is set to 100% (fill/fillcap), so `margin:auto` resolves to 0 and silently deletes real literal + // px side margins, blowing a padded pill/section out to full-bleed. Equal literal margins are the + // geometric TWIN of centering slack (both split the free space symmetrically), so this probe read + // is the only reliable discriminator between them; when the box fills, keep the literal margins. + const fillsAtVp = node.sizingByVp?.[vp]?.wFill === true; + if (ml > 0.5 && Math.abs(ml - mr) < 1 && nb.width < pb.width - 4 && marginsVaryAcrossVps(node) && !fillsAtVp) return true; if (parentFlexGrid) return false; // Signal 2: bbox-centered within the parent content box with ~0 reported margins. if (Math.abs(ml) > 0.5 || Math.abs(mr) > 0.5) return false; // margins already explain position @@ -2654,6 +2679,45 @@ function centeredAtVp(node: IRNode, parentNode: IRNode, vp: number): boolean { return leftGap > 1 && rightGap > 1 && Math.abs(leftGap - rightGap) < 1.5 && nb.width < contentRight - contentLeft - 2; } +/** A single-line text leaf whose unwrapped width nearly fills its container's available width at + * EVERY captured viewport where it's painted — so any sub-pixel width shortfall in the clone (a + * column resolving fractionally narrower) tips it onto a second line. Such a node earns an explicit + * `white-space:nowrap` (see declsForViewport) to stay one line as it did in the capture. + * Conservative by construction — every guard must hold at every painted viewport: + * • text leaf (direct text, no element children), whitespace not already preserved (`pre*`); + * • single line: box height ≈ one line box (line-height + vertical padding/border), so a genuinely + * wrapping paragraph (≥2 line boxes tall) is excluded; + * • wrap-vulnerable: max-content (unwrapped) width ≥ available container width − 2 and ≤ it + 1 — + * the text needs essentially all the available width, with no slack for a rounding shortfall; + * • genuinely wrappable: min-content < max-content − 2, so a single unbreakable token (which can + * never wrap, making nowrap a redundant no-op) is skipped to keep the emission minimal. + * Relies on the sizing probe's wMin/wMax (present only for in-flow probed leaves); absent ⇒ no emit. */ +function nowrapWrapVulnerable(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean { + if (!parentNode) return false; + if (hasElementChild(node)) return false; + if (!node.children.some((c) => isTextChild(c) && c.text.trim() !== "")) return false; + if (REPLACED.has(node.tag) || node.tag === "canvas" || node.tag.includes("-")) return false; + let painted = 0; + for (const vp of viewports) { + const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp]; + if (!cs || !nb || !node.visibleByVp[vp] || (cs.display || "") === "none") continue; + if (!(nb.width > 0) || !(nb.height > 0)) continue; + const ws = cs.whiteSpace || "normal"; + if (ws !== "normal" && ws !== "nowrap") return false; // pre/pre-wrap/break-spaces preserve authored whitespace + const sz = node.sizingByVp?.[vp]; + const wMax = sz?.wMax; const wMin = sz?.wMin; + if (wMax == null || wMin == null) return false; + const lineBox = pf(cs.lineHeight) + pf(cs.paddingTop) + pf(cs.paddingBottom) + pf(cs.borderTopWidth) + pf(cs.borderBottomWidth); + if (!(lineBox > 0) || Math.abs(nb.height - lineBox) > 2.5) return false; // not single-line + const avail = containingWidthAt(node, parentNode, vp); + if (avail == null) return false; + if (!(wMax >= avail - 2 && wMax <= avail + 1)) return false; // not right at the container edge + if (!(wMin < wMax - 2)) return false; // single unbreakable token — can't wrap + painted++; + } + return painted >= 1; +} + function hasPxMaxWidthCap(node: IRNode, viewports: number[]): boolean { const caps: number[] = []; for (const vp of viewports) { @@ -3106,6 +3170,21 @@ export function collectNodeRules(ir: IR, assetMap: Map, includeN : inferred.plan; const centerAlways = inferred.centerAlways; const centeredAtAnySample = !!layoutParent && sampleVps.some((vp) => centeredAtVp(node, layoutParent, vp)); + // A single-line text leaf sitting flush against its container's available width at every painted + // width → `white-space:nowrap` so a sub-pixel column shortfall in the clone can't wrap it. Decided + // once per node (uniform across bands); measured against layoutParent to see through contents wrappers. + const nowrapText = !isContents && nowrapWrapVulnerable(node, layoutParent, sampleVps); + // If this node has a NON-identity transform at any painted band, the identity `none` must be + // emitted at the bands that have it so the transform can't cascade across bands (freezing a + // width the source left untransformed). Identity matrices are canonicalized to "none" upstream. + let hasNonIdentityTransform = false, hasIdentityTransform = false; + for (const vp of sampleVps) { + const t = node.computedByVp[vp]?.transform; + if (t === undefined) continue; + if (t === "none") hasIdentityTransform = true; + else hasNonIdentityTransform = true; + } + const keepIdentityTransform = hasNonIdentityTransform && hasIdentityTransform; const stableCenter = centerAlways || sourceMarginAutoIntent(node) || @@ -3175,7 +3254,7 @@ export function collectNodeRules(ir: IR, assetMap: Map, includeN dropInsets.delete("left"); } const animOwned = animOwnedProps(node, baseVp); // opacity/transform driven by an infinite animation - const baseDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[baseVp], baseVp, assetMap, centeredBase, colorVar, ir.doc.perViewport[baseVp]?.scrollHeight, widthPlan, gridColsByVp?.get(baseVp), gridRowsByVp?.get(baseVp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth), tokenResolver); + const baseDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[baseVp], baseVp, assetMap, centeredBase, colorVar, ir.doc.perViewport[baseVp]?.scrollHeight, widthPlan, gridColsByVp?.get(baseVp), gridRowsByVp?.get(baseVp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth, nowrapText, keepIdentityTransform), tokenResolver); const nr: NodeRule = { base: baseDecls, bands: [] }; // Per-band overrides (delta vs base), using the parent's value AT THAT viewport. @@ -3247,7 +3326,7 @@ export function collectNodeRules(ir: IR, assetMap: Map, includeN } } 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, nowrapText, keepIdentityTransform), tokenResolver); const delta = new Map(); for (const [k, v] of vpDecls) if (baseDecls.get(k) !== v) delta.set(k, v); for (const [k] of baseDecls) if (!vpDecls.has(k)) delta.set(k, resetValue(k)); diff --git a/compiler/src/generate/lottie.ts b/compiler/src/generate/lottie.ts index 7eeb49d..233007e 100644 --- a/compiler/src/generate/lottie.ts +++ b/compiler/src/generate/lottie.ts @@ -126,7 +126,7 @@ const byCid = (cid: string): HTMLElement | null => document.querySelector('[data export default function DittoLottie({ spec }: { spec: LottieSpec }) { useEffect(() => { const stopped = (window as any).__dittoMotionStopped === true; - const anims: Array<{ destroy: () => void; goToAndStop: (value: number, isFrame?: boolean) => void }> = []; + const anims: Array<{ destroy: () => void; goToAndStop: (value: number, isFrame?: boolean) => void; addEventListener: (name: string, cb: () => void) => void }> = []; let cancelled = false; void (async () => { const lottie = (await import("lottie-web")).default; @@ -134,17 +134,38 @@ export default function DittoLottie({ spec }: { spec: LottieSpec }) { for (const it of spec.items) { const el = byCid(it.cid); if (!el) continue; - // clear the captured placeholder frame so it never stacks with the live render - el.innerHTML = ""; try { + // Mount the live animation into an OVERLAY child, keeping the captured placeholder + // frame in the DOM until lottie signals a successful load — a failed load (bad JSON, + // network) then leaves the placeholder intact instead of erasing the container to blank. + const mount = document.createElement("div"); + mount.style.position = "absolute"; + mount.style.inset = "0"; + mount.style.opacity = "0"; + const cs = getComputedStyle(el); + if (cs.position === "static") el.style.position = "relative"; + el.appendChild(mount); const anim = lottie.loadAnimation({ - container: el, + container: mount, renderer: it.renderer === "canvas" ? "canvas" : "svg", loop: it.loop, autoplay: it.autoplay && !stopped, ...(it.path ? { path: it.path } : { animationData: it.animationData as object }), rendererSettings: { preserveAspectRatio: "xMidYMid meet" }, }); + // Swap only once the animation is genuinely ready: reveal the live render and remove + // every original child (the placeholder) so the two never stack. + const reveal = () => { + mount.style.opacity = "1"; + for (const child of Array.from(el.childNodes)) if (child !== mount) el.removeChild(child); + mount.style.position = ""; + mount.style.inset = ""; + }; + // DOMLoaded fires only after the JSON parsed and the first frame rendered — the + // right moment to reveal the live render and drop the placeholder. data_failed + // (bad JSON / fetch error) tears the empty mount back out, leaving the placeholder. + anim.addEventListener("DOMLoaded", reveal); + anim.addEventListener("data_failed", () => { try { el.removeChild(mount); } catch {} }); if (stopped) { try { anim.goToAndStop(0, true); } catch {} } anims.push(anim); } catch { diff --git a/compiler/src/generate/tailwind.ts b/compiler/src/generate/tailwind.ts index 0c8a80f..8a88904 100644 --- a/compiler/src/generate/tailwind.ts +++ b/compiler/src/generate/tailwind.ts @@ -563,7 +563,6 @@ function namedText(base: string): string | null { // nearest scale step / integer px when comfortably INSIDE that budget (ε well under the gate tol), so // the markup reads hand-authored. Bounded ε + the layout & perceptual gates backstop any accumulation; // values not near a step stay arbitrary because they are genuine one-offs. -const SNAP_SPACE_PX = 1.0; // padding/margin/gap/inset/size → nearest 0.25rem scale step within this const SNAP_TYPE_PX = 0.6; // font-size/line-height/radius → nearest integer px within this const NAMED_K_SORTED = [...NAMED_K].sort((a, b) => a - b); function nearestNamedK(k: number): number { @@ -571,7 +570,7 @@ function nearestNamedK(k: number): number { for (const c of NAMED_K_SORTED) if (Math.abs(c - k) < Math.abs(best - k)) best = c; return best; } -function snapBase(base: string): string { +export function snapBase(base: string): string { const m = /^(-?)([a-z][a-z-]*)-\[(-?\d*\.?\d+)(px|rem)\]$/.exec(base); if (!m) return base; const neg = m[1]!, prefix = m[2]!, px = parseFloat(m[3]!) * (m[4] === "rem" ? 16 : 1); @@ -587,10 +586,16 @@ function snapBase(base: string): string { if (Math.abs(r - px) <= SNAP_TYPE_PX && r in RADIUS_SUFFIX) return `${prefix}${RADIUS_SUFFIX[r]}`; return base; } - // spacing / size → nearest named scale step (0.25rem grid) + // spacing / size → nearest named scale step (0.25rem grid). Only snap a value that lands ESSENTIALLY + // ON a step (≤0.25px): the scale is 2px-granular (`p-0.5`=2px, `p-1.5`=6px), so a captured value ≥0.25px + // off the nearest step is a genuine sub-step measurement (3.5px is BETWEEN 2px and 4px — not on the + // scale). Snapping such a value up by +0.5px per side accumulates across the links of a fixed-width + // flex row until the items overflow and wrap to a second line; the exact `p-[3.5px]` cannot. The wider + // ±1px budget the gate tolerates in isolation is unsafe here because the error is systematic, not + // random. Values genuinely on a step (2px→`p-0.5`, 4px→`p-1`) still snap. if (SCALE_PREFIXES.has(prefix)) { const k = nearestNamedK(px / 4); - if (Math.abs(k * 4 - px) <= SNAP_SPACE_PX) return k === 0 ? `${neg}${prefix}-0` : `${neg}${prefix}-${k}`; + if (Math.abs(k * 4 - px) <= 0.25) return k === 0 ? `${neg}${prefix}-0` : `${neg}${prefix}-${k}`; } return base; } diff --git a/compiler/src/normalize/ir.ts b/compiler/src/normalize/ir.ts index ee0116b..7b5b485 100644 --- a/compiler/src/normalize/ir.ts +++ b/compiler/src/normalize/ir.ts @@ -188,6 +188,44 @@ function elementChildren(n: RawNode): RawNode[] { return n.children.filter((c) => (c as { text?: string }).text === undefined) as RawNode[]; } +/** True for a transform value that is visually the identity (no offset/rotation/scale): + * the `none` keyword or an identity matrix/matrix3d the browser reports as noise. */ +export function isIdentityTransform(value: string | undefined): boolean { + if (!value || value === "none") return true; + const m = /^matrix\(([^)]*)\)$/.exec(value.trim()); + if (m) { + const n = m[1]!.split(",").map((s) => parseFloat(s.trim())); + return n.length === 6 && n[0] === 1 && n[1] === 0 && n[2] === 0 && n[3] === 1 && n[4] === 0 && n[5] === 0; + } + const m3 = /^matrix3d\(([^)]*)\)$/.exec(value.trim()); + if (m3) { + const n = m3[1]!.split(",").map((s) => parseFloat(s.trim())); + const id = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + return n.length === 16 && n.every((v, i) => v === id[i]); + } + return false; +} + +/** + * Normalize per-viewport transform values so identity is represented uniformly as the literal + * `none`. Two problems this closes, both in the per-viewport delta emission downstream: + * 1. The browser reports "no transform" inconsistently — `none` at some widths, an identity + * `matrix(1,0,0,1,0,0)` at others (composited-layer noise). The generator's default-skip + * only recognizes `none`, so an identity matrix at the base viewport is emitted as a real + * transform and then cascades across bands. + * 2. When ANY viewport carries a genuine (non-identity) transform, the identity value at the + * other viewports must stay observable — canonicalizing it to `none` (not dropping it) lets + * the generator emit the explicit reset so the non-identity transform can't leak into a band + * where the source had none. + * Deterministic, in place; only touches the `transform` slot. + */ +export function canonicalizeTransforms(computedByVp: Record): void { + for (const k of Object.keys(computedByVp)) { + const cs = computedByVp[Number(k)]; + if (cs && isIdentityTransform(cs.transform)) cs.transform = "none"; + } +} + /** Full identity signature (tag + id + class). */ function sigFull(n: RawNode): string { return `${n.tag}#${n.attrs?.id ?? ""}.${(n.attrs?.class ?? "").trim()}`; @@ -328,6 +366,10 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion? if (match.after) afterByVp[vw] = match.after; if (match.placeholder) placeholderByVp[vw] = match.placeholder; } + // Canonicalize identity transforms (none / identity matrix) to `none` at every viewport so + // the generator's per-band delta treats them uniformly and a scroll/composite-noise transform + // at one width can't leak across bands. + canonicalizeTransforms(computedByVp); const node: IRNode = { id: nextId(), diff --git a/compiler/src/runner/codeAudit.ts b/compiler/src/runner/codeAudit.ts index 5328f3f..9e66344 100644 --- a/compiler/src/runner/codeAudit.ts +++ b/compiler/src/runner/codeAudit.ts @@ -1,142 +1,141 @@ /** - * Code-quality audit — count the "bad code" tells that make a generated clone read robotic, for any - * number of app source trees, side by side. Repeatable: point it at shipped deliverables and it - * prints one column per tree. + * Code-quality audit — an honest, human-readable report over one or more generated app + * trees. Built directly on the dimension scorer in ./qualityScore, so the audit and the + * shipped `code-quality.md` quality number never disagree. * - * npm run audit # auto: every output//app - * npm run audit -- [ ...] # explicit trees; .clone resolves to sibling app/ - * npm run audit -- output/example/app + * npm run audit # auto: every runs//latest/generated/app + * npm run audit -- [ ...] # explicit app trees + * npm run audit -- runs/example/latest/generated/app + * npm run audit -- --json # machine-readable * - * Scans .tsx/.jsx/.ts/.css under each dir (skips node_modules/.next/out/dotfiles). Every metric is a - * COUNT where lower is better, except the two "good" context rows (fluid width / standard scale). - * The decisive "decimal" tell is non-integer-PX: each arbitrary px/rem is converted to px and flagged - * if it isn't a whole pixel (a frozen measurement), so a clean 12.5rem (=200px) does NOT count. + * For each tree it prints: + * • the LETTER GRADE + numeric score (and any hard-cap reason), + * • a per-DIMENSION table (payload / decomposition / duplication / semantics / hygiene + * / runtime) with the visible sub-metrics behind each score, + * • the TOP OFFENDERS (file, metric, value) — the worst individual tells. + * When several trees are passed it also prints a side-by-side grade comparison. + * + * Pure static analysis over generated source (.tsx/.jsx/.ts/.astro/.css). No builds, no + * browser. See qualityScore.ts for the rubric + the (calibration-guide) thresholds. */ -import { readdirSync, statSync, readFileSync, existsSync } from "node:fs"; -import { join, resolve, basename, dirname, sep } from "node:path"; +import { readdirSync, statSync, existsSync } from "node:fs"; +import { join, resolve, basename } from "node:path"; +import { scoreApp, type QualityReport } from "./qualityScore.js"; -type Row = { label: string; lowerBetter: boolean; values: number[] }; -type Target = { inputDir: string; scanDir: string; validationDir?: string }; +// --------------------------------------------------------------------------- +// Target resolution — accept an app dir directly, or discover deliverables. +// --------------------------------------------------------------------------- -function readTree(dir: string): { tsx: string; css: string; all: string } { - const tsxParts: string[] = [], cssParts: string[] = []; - const walk = (d: string): void => { - for (const n of readdirSync(d)) { - if (n === "node_modules" || n === ".next" || n === "out" || n.startsWith(".")) continue; - const p = join(d, n); - const st = statSync(p); - if (st.isDirectory()) walk(p); - else if (/\.(tsx|jsx|ts)$/.test(n)) tsxParts.push(readFileSync(p, "utf8")); - else if (/\.css$/.test(n)) cssParts.push(readFileSync(p, "utf8")); +/** Is `dir` a scannable app tree (has a src/ with code in it, or is itself full of code)? */ +function isAppDir(dir: string): boolean { + if (!existsSync(dir)) return false; + if (existsSync(join(dir, "src"))) return true; + try { return readdirSync(dir).some((n) => /\.(tsx|jsx|ts|css|astro)$/.test(n)); } catch { return false; } +} + +/** Discover every runs//latest/generated/app deliverable, newest layout first. + * Checks both the cwd and its parent, so `npm run audit` works whether invoked from the + * repo root or from compiler/ (where runs/ lives one level up). */ +function discoverTargets(): string[] { + const out: string[] = []; + const roots = ["runs", "output", "../runs", "../output"].map((r) => resolve(r)).filter(existsSync); + for (const root of roots) { + let sites: string[]; + try { sites = readdirSync(root); } catch { continue; } + for (const site of sites) { + for (const app of [ + join(root, site, "latest", "generated", "app"), + join(root, site, "app"), + ]) { + if (isAppDir(app)) { out.push(app); break; } + } } - }; - walk(dir); - const tsx = tsxParts.join("\n"), css = cssParts.join("\n"); - return { tsx, css, all: tsx + "\n" + css }; + } + return out; } -/** All metric counts for one tree. */ -function audit(target: Target): Record { - const dir = target.scanDir; - const { tsx, css, all } = readTree(dir); - const n = (re: RegExp, s = all): number => (s.match(re) || []).length; - const validationDataCid = target.validationDir && existsSync(target.validationDir) - ? (readTree(target.validationDir).all.match(/data-cid/g) || []).length - : 0; +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- - // Arbitrary px/rem values + the non-integer-px ("decimal") subset, the decisive measurement tell. - let arb = 0, decimal = 0; - for (const m of all.matchAll(/\[(-?[0-9]+\.?[0-9]*)(px|rem)\]/g)) { - arb++; - const px = parseFloat(m[1]!) * (m[2] === "rem" ? 16 : 1); - if (Math.abs(px - Math.round(px)) > 0.02) decimal++; - } - - return { - "files (tsx+css)": (tsx ? 1 : 0), // overwritten below with real file counts - "── BAD (lower=better) ──": -1, - "fixed width w-[Npx/rem]": n(/\bw-\[-?[0-9.]+(?:px|rem)\]/g), - "fixed height h-[Npx/rem]": n(/\bh-\[-?[0-9.]+(?:px|rem)\]/g), - "breakpoint utilities": n(/\b(?:sm|md|lg|xl|2xl|max-sm|max-md|max-lg|max-xl):/g), - "arbitrary bands min/max-[Npx]:": n(/\b(?:min|max)-\[[0-9]+px\]:/g), - "arbitrary […px/rem] total": arb, - "decimal (non-integer px)": decimal, - "baked position top/left/inset-[N]": n(/\b(?:top|left|right|bottom|inset(?:-x|-y)?)-\[-?[0-9.]+(?:px|rem)\]/g), - "raw color literal [#hex/rgb]": n(/\[(?:#[0-9a-fA-F]{3,8}|rgba?\([^\]]*)\]/g), - "per-side border longhand": n(/\[border-(?:top|right|bottom|left)-(?:style|color):/g), - "data-cid (shipped)": n(/data-cid/g), - "data-cid (validation-only)": validationDataCid, - "dangerouslySetInnerHTML": n(/dangerouslySetInnerHTML/g), - "': any' props": n(/:\s*any\b/g), - "robotic 'Generated by' comments": n(/Generated by clone|clone-static/g), - "── GOOD (higher=better) ──": -1, - "fluid width (w-full/auto/fraction)": n(/\bw-(?:full|auto|fit|screen|\d{1,2}\/\d{1,2})\b/g), - "standard scale (gap-2/w-10/p-4…)": n(/\b(?:gap|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|w|h)-(?:0|0\.5|1|1\.5|2|2\.5|3|3\.5|4|5|6|7|8|9|10|11|12|14|16|20|24|28|32|36|40|44|48|52|56|60|64|72|80|96|px)\b/g), - }; +/** A readable column label for a target (its site/run name where possible). */ +function labelFor(dir: string): string { + const parts = resolve(dir).split("/"); + const runsIdx = parts.lastIndexOf("runs"); + const outIdx = parts.lastIndexOf("output"); + const i = runsIdx >= 0 ? runsIdx : outIdx; + if (i >= 0 && parts[i + 1]) return parts[i + 1]!.slice(0, 22); + return (parts[parts.length - 2] ?? basename(dir)).slice(0, 22); } -function fileCount(dir: string): number { - let c = 0; - const walk = (d: string): void => { for (const x of readdirSync(d)) { if (x === "node_modules" || x === ".next" || x === "out" || x.startsWith(".")) continue; const p = join(d, x); statSync(p).isDirectory() ? walk(p) : (/\.(tsx|jsx|ts|css)$/.test(x) && c++); } }; - walk(dir); - return c; +const pad = (s: string, w: number): string => s + " ".repeat(Math.max(0, w - s.length)); +const padL = (s: string, w: number): string => " ".repeat(Math.max(0, w - s.length)) + s; + +function renderReport(label: string, rep: QualityReport): string { + const L: string[] = []; + L.push(""); + L.push(`━━━ ${label} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); + L.push(` GRADE ${rep.grade} (${rep.total}/100)`); + if (rep.caps.length) { + L.push(` HARD CAP → grade limited to D-range:`); + for (const c of rep.caps) L.push(` • ${c}`); + } + L.push(""); + L.push(` ${pad("dimension", 15)}${padL("score", 9)} sub-metrics`); + L.push(` ${"─".repeat(66)}`); + for (const [dim, cat] of Object.entries(rep.categories)) { + const metrics = Object.entries(cat.metrics).map(([k, v]) => `${k}=${v}`).join(" "); + L.push(` ${pad(dim, 15)}${padL(`${cat.score}/${cat.max}`, 9)} ${metrics}`); + } + if (rep.offenders.length) { + L.push(""); + L.push(` top offenders`); + L.push(` ${pad("metric", 34)}${padL("value", 12)} file`); + L.push(` ${"─".repeat(66)}`); + for (const o of rep.offenders.slice(0, 10)) { + L.push(` ${pad(o.metric, 34)}${padL(String(o.value), 12)} ${o.file}`); + } + } + return L.join("\n"); } -function normalizeTarget(input: string): Target { - const d = resolve(input); - const asApp = (appDir: string, validationDir?: string): Target => ({ inputDir: d, scanDir: appDir, validationDir }); - if (basename(d) === ".clone") { - const app = join(dirname(d), "app"); - const validation = join(d, "generated", "app"); - if (existsSync(join(app, "src"))) return asApp(app, existsSync(validation) ? validation : undefined); +function renderComparison(labels: string[], reps: QualityReport[]): string { + const L: string[] = []; + const colW = Math.max(12, ...labels.map((l) => l.length + 2)); + L.push(""); + L.push(`━━━ comparison ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); + L.push(" " + pad("dimension", 15) + labels.map((l) => padL(l, colW)).join("")); + const dims = Object.keys(reps[0]!.categories); + for (const d of dims) { + L.push(" " + pad(d, 15) + reps.map((r) => padL(String(r.categories[d]!.score), colW)).join("")); } - const generatedSuffix = `${sep}.clone${sep}generated${sep}app`; - if (d.endsWith(generatedSuffix)) { - const cloneDir = d.slice(0, -`${sep}generated${sep}app`.length); - const app = join(dirname(cloneDir), "app"); - if (existsSync(join(app, "src"))) return asApp(app, d); - } - if (basename(d) === "generated" && existsSync(join(d, "app", "src"))) { - const cloneDir = dirname(d); - const app = join(dirname(cloneDir), "app"); - if (basename(cloneDir) === ".clone" && existsSync(join(app, "src"))) return asApp(app, join(d, "app")); - } - return { inputDir: d, scanDir: d }; + L.push(" " + pad("GRADE", 15) + reps.map((r) => padL(`${r.grade} (${r.total})`, colW)).join("")); + return L.join("\n"); } +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + function main(): void { const args = process.argv.slice(2).filter((a) => !a.startsWith("--")); - let targets: Target[]; - if (args.length) targets = args.map(normalizeTarget); - else { - // Auto: every output//app deliverable. - const outRoot = resolve("output"); - const sites = existsSync(outRoot) - ? readdirSync(outRoot).map((s) => join(outRoot, s, "app")).filter((p) => existsSync(join(p, "src"))) - : []; - targets = sites.map(normalizeTarget); - } - targets = targets.filter((t) => existsSync(t.scanDir)); - if (!targets.length) { console.error("no app trees found — pass dirs explicitly"); process.exit(1); } + const asJson = process.argv.includes("--json"); + const targets = (args.length ? args.map((a) => resolve(a)) : discoverTargets()).filter(isAppDir); + if (!targets.length) { console.error("no app trees found — pass app dirs explicitly (e.g. runs//latest/generated/app)"); process.exit(1); } - // A readable column label: the deliverable dir's parent name, or the scanned dir name. - const label = (t: Target): string => (basename(dirname(t.scanDir)) || basename(t.scanDir)).slice(0, 16); - const labels = targets.map(label); - const audits = targets.map(audit); - audits.forEach((a, i) => { a["files (tsx+css)"] = fileCount(targets[i]!.scanDir); }); + const labels = targets.map(labelFor); + const reports = targets.map((t) => scoreApp(t)); - const keys = Object.keys(audits[0]!); - const w0 = Math.max(...keys.map((k) => k.length)); - const colW = Math.max(11, ...labels.map((l) => l.length)); - const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - s.length)); - console.log("\n" + pad("metric", w0) + " " + labels.map((l) => pad(l, colW)).join("")); - console.log("─".repeat(w0 + 2 + colW * labels.length)); - for (const k of keys) { - if (audits[0]![k] === -1) { console.log(pad(k, w0)); continue; } // section header - const cells = audits.map((a) => pad(String(a[k] ?? 0), colW)).join(""); - console.log(pad(k, w0) + " " + cells); + if (asJson) { + console.log(JSON.stringify(reports.map((r, i) => ({ label: labels[i], ...r })), null, 2)); + return; } - console.log("\n(counts; lower is better in the BAD block, higher in the GOOD block. 'decimal' = the\n arbitrary value's px isn't a whole pixel — the frozen-measurement tell.)\n"); + + for (let i = 0; i < reports.length; i++) console.log(renderReport(labels[i]!, reports[i]!)); + if (reports.length > 1) console.log(renderComparison(labels, reports)); + console.log("\n(scores are out of each dimension's max; grade is the weighted blend, capped to\n D-range if any single file/line/blob is in catastrophic payload territory.)\n"); } -main(); +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/compiler/src/runner/qualityScore.ts b/compiler/src/runner/qualityScore.ts index 8ff0d7c..dea52c2 100644 --- a/compiler/src/runner/qualityScore.ts +++ b/compiler/src/runner/qualityScore.ts @@ -1,23 +1,93 @@ /** - * Output-quality rubric (deterministic, framework-agnostic). + * Output-quality rubric — HONEST edition (deterministic, framework-agnostic). * * The fidelity gates (validate/gates.ts) measure whether the clone *looks and * behaves* like the source. They say nothing about whether the generated CODE is - * good — componentized, semantically named, styled through a reusable token/class - * system, editable, and well organized. That "developer-facing quality" is exactly - * where a deterministic converter is judged once fidelity is a given. + * good — small files, decomposed into real components, semantic and accessible, + * free of capture artifacts, and safe at runtime. That "developer-facing quality" + * is what a deterministic converter is judged on once fidelity is a given. * - * This module statically analyzes a generated app directory and scores it 0–100 on - * six categories. It reads only source text (`.tsx/.jsx/.ts/.astro/.css`). The - * metrics are chosen to be framework-agnostic: e.g. "style reuse" rewards shared - * classes whether they're Tailwind utilities or semantic clone classes, and - * penalizes per-node unique rules (`.c1{...} .c2{...}`) regardless of framework. + * This module statically analyzes a generated app directory (source text only — + * `.tsx/.jsx/.ts/.astro/.css`) and scores it 0–100 across SIX dimensions, each + * with visible subscores. The score is a weighted blend of the dimensions EXCEPT + * that a single dimension in catastrophic territory (a multi-megabyte source file, + * a 100KB+ single line) HARD-CAPS the overall grade into D-range no matter how + * clean everything else is — because such a file is unopenable, un-diffable, and + * un-editable, which is the whole point of "good code". + * + * The signals are deliberately general (payload bytes, LOC distribution, repeated + * definitions, semantic-tag ratios, whitespace/capture artifacts, uncleaned + * listeners) so the scorer is not tuned to any specific site — it measures the + * failure *modes* a robotic converter falls into, not any one output. * * Pure + deterministic: same directory ⇒ same score. */ import { readFileSync, readdirSync, statSync } from "node:fs"; import { join, extname, basename, relative } from "node:path"; +// =========================================================================== +// CALIBRATION CONSTANTS +// --------------------------------------------------------------------------- +// These are CALIBRATION GUIDES, expected to be tuned as the generator and our +// taste evolve — they are NOT compliance targets or contractual thresholds. A +// number here answers "where does a human reviewer start wincing?", set from +// observed good vs. bad outputs, and should be re-fit (not litigated) whenever +// calibration drifts. Every knob lives in this one block on purpose. +// =========================================================================== +const K = { + // ---- payload discipline (a file/line so big it is not human-readable) ---- + FILE_BYTES_GOOD: 30_000, // ≤30KB source file: comfortable to open + FILE_BYTES_BAD: 160_000, // ≥160KB: a reviewer scrolls forever; scores ~0 + LINE_CHARS_GOOD: 2_000, // a formatted line, allowing for a dense data/JSX row + LINE_CHARS_BAD: 30_000, // a giant one-liner (minified / HTML-as-string prop) + INLINE_BYTES_GOOD: 50_000, // total base64 / inline-HTML bytes that's forgivable + INLINE_BYTES_BAD: 2_000_000, // 2MB+ of embedded blobs: catastrophic payload + // Catastrophe caps: any ONE of these forces the whole app into D-range. + CATASTROPHE_FILE_BYTES: 1_000_000, // a >1MB source file + CATASTROPHE_LINE_CHARS: 100_000, // a >100KB single line + CATASTROPHE_INLINE_BYTES: 5_000_000, // >5MB of embedded base64/HTML + CAP_GRADE_D: 68, // ceiling applied when a catastrophe fires — top of the D band, so a + // catastrophic payload can grade no better than D+ regardless of other dimensions + // Softer cap for "very bad but not unopenable" payloads. + WARN_FILE_BYTES: 350_000, + WARN_LINE_CHARS: 40_000, + CAP_GRADE_C: 78, // ceiling for the softer warning band (top of C+) + + // ---- component decomposition (is the page a monolith?) ---- + MONOLITH_DOMINANCE_GOOD: 0.3, // biggest file ≤30% of all tags → well spread + MONOLITH_DOMINANCE_BAD: 0.75, // one file holds ≥75% of the page → monolith + BIG_SECTION_LINES: 600, // a "section" this long is really a whole page + COMPONENTS_GOOD: 12, // this many real components → full decomposition credit + + // ---- duplication ---- + DUP_HELPER_RATIO_BAD: 0.4, // ≥40% of helper defs are copy-paste duplicates + DUP_SVG_PATH_RATIO_BAD: 0.85, // ≥85% of inline strings are repeats (shared + // icon sets legitimately repeat, so only near-total duplication is a real tell) + NEAR_DUP_COMPONENT_BAD: 14, // this many near-identical NON-trivial component pairs → 0 + NEAR_DUP_MIN_TAGS: 8, // ignore tiny components (icons/logos) in near-dup detection + + // ---- semantics / a11y ---- + DIV_RATIO_GOOD: 0.6, // ≤60% of elements are bare div/span → healthy + DIV_RATIO_BAD: 0.9, // ≥90% divs → div soup + ALT_COVERAGE_GOOD: 0.9, // ≥90% of carry alt= + H1_REQUIRED: 1, // a page really should have exactly one

+ + // ---- hygiene ---- + // {" "} literals and sub-pixel arbitraries are a KNOWN baseline quirk of this + // converter present in even good output — only pathological volumes should bite, + // so these BADs sit well above what a "decent" tree emits. + WS_LITERAL_PER_KTAG_GOOD: 100, // per 1000 tags a converter routinely emits some + WS_LITERAL_PER_KTAG_BAD: 1200, // this many → capture-whitespace noise dominates + SUBPIXEL_PER_KTAG_GOOD: 60, // some frozen measurements are unavoidable + SUBPIXEL_PER_KTAG_BAD: 260, // a wall of frozen sub-pixels → machine replay + OPAQUE_TOKEN_RATIO_BAD: 0.6, // ≥60% of CSS custom props are opaque --clr-N / hashes + PROBE_ARTIFACTS_BAD: 8, // off-screen capture-probe text leaks + + // ---- runtime discipline ---- + LEAK_LISTENERS_GOOD: 8, // a small shared runtime bundle carries a few listeners + LEAK_LISTENERS_BAD: 30, // this many uncleaned adds → real leak territory +} as const; + // --------------------------------------------------------------------------- // File collection // --------------------------------------------------------------------------- @@ -51,13 +121,43 @@ export function collectFiles(root: string): SrcFile[] { } // --------------------------------------------------------------------------- -// Primitive extraction +// Small helpers +// --------------------------------------------------------------------------- + +const clamp01 = (x: number): number => Math.max(0, Math.min(1, x)); +/** Linear ramp: v in [lo,hi] → [0,1]. Handles lo>hi (descending / "lower is better"). */ +function ramp(v: number, lo: number, hi: number): number { + if (lo === hi) return v >= hi ? 1 : 0; + return clamp01((v - lo) / (hi - lo)); +} +/** A value where HIGHER is worse: good at `good`, zero at `bad`. */ +const penalize = (v: number, good: number, bad: number): number => 1 - ramp(v, good, bad); +const r1 = (n: number): number => Math.round(n * 10) / 10; + +/** Longest single line (in chars) in a file. */ +function maxLineLen(text: string): number { + let max = 0, start = 0; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) { if (i - start > max) max = i - start; start = i + 1; } + } + if (text.length - start > max) max = text.length - start; + return max; +} + +/** Count JSX/HTML opening element tags — a framework-agnostic page-size proxy. */ +function countTags(text: string): number { + const m = text.match(/<[a-zA-Z][a-zA-Z0-9.]*(\s|\/|>)/g); + return m ? m.length : 0; +} + +// --------------------------------------------------------------------------- +// Structural classification // --------------------------------------------------------------------------- const CONFIG_NAME_RE = /(config|tsconfig|next-env|site-config|content\.config|\.config\.)/i; const ROOT_NAME_RE = /(^|\/)(layout|_app|_document|root-layout)\.[jt]sx$/i; -/** Files that compose the whole page (the "entry"): page.tsx (Next), index/page in a - * pages/ dir, home.tsx, or an .astro route shell. Used for the dominance metric. */ +const SECTION_WORDS = ["hero", "footer", "navbar", "nav", "header", "about", "cta", "feature", "pricing", "faq", "logo", "logocloud", "testimonial", "gallery", "stories", "spotlight", "knowledge", "news", "apply", "contact", "banner", "team", "stats", "partners", "alumni", "founder", "section"]; + function isEntryFile(rel: string): boolean { const b = basename(rel).toLowerCase(); if (/^(page|index|home|app)\.[jt]sx$/.test(b)) return true; @@ -66,54 +166,8 @@ function isEntryFile(rel: string): boolean { return false; } -const SECTION_WORDS = ["hero", "footer", "navbar", "nav", "header", "about", "cta", "feature", "pricing", "faq", "logo", "logocloud", "testimonial", "gallery", "stories", "spotlight", "knowledge", "news", "apply", "contact", "banner", "team", "stats", "partners", "alumni", "founder", "section"]; - -/** Count JSX/HTML opening element tags — a framework-agnostic proxy for page size - * (how many DOM nodes the output describes). Excludes React fragments + closing tags. */ -function countTags(text: string): number { - const m = text.match(/<[a-zA-Z][a-zA-Z0-9.]*(\s|\/|>)/g); - return m ? m.length : 0; -} - -/** All className / class string-literal tokens used on elements (space split). - * Catches JSX attribute form (className="…"), object/spread form (className: "…" — - * what our generator emits), and hoisted className consts. A dynamic per-node class - * (className: "c" + d._cid / className={"c"+…}) renders a UNIQUE opaque class per - * instance, so each occurrence is recorded as its own opaque token (keeping the - * reuse + semantic metrics honest for the per-node-CSS strategy). */ -function classTokens(text: string): string[] { - const out: string[] = []; - // className="..." / className: "..." / "class": "..." (attr + object form) - const re = /\b(?:className|class)\s*(?:=|:)\s*"([^"]*)"/g; - let m: RegExpExecArray | null; - while ((m = re.exec(text)) !== null) { - for (const t of m[1]!.split(/\s+/)) if (t) out.push(t); - } - // hoisted className consts: const xClassName = "..." - const re2 = /\b(?:const|let)\s+\w*[Cc]lassName\w*\s*=\s*"([^"]*)"/g; - while ((m = re2.exec(text)) !== null) { - for (const t of m[1]!.split(/\s+/)) if (t) out.push(t); - } - // dynamic per-node class: className: "c" + d._cid / className={"c"+ ...} - const dyn = (text.match(/\bclassName\s*(?:=\s*\{|:)\s*"c[a-z]?"\s*\+/g) ?? []).length - + (text.match(/\bclassName\s*(?:=\s*\{|:)\s*"c[a-z]?\d*"\s*\+/g) ?? []).length; - for (let i = 0; i < dyn; i++) out.push(`c__dyn${out.length}`); // unique opaque per occurrence - return out; -} - -const CID_CLASS_RE = /^c[a-z]?\d+$/; // our legacy per-node class: c12 / cn12 -/** A class token that carries no human meaning (pure id / hash). */ -function isOpaqueClass(t: string): boolean { - if (CID_CLASS_RE.test(t)) return true; - if (/^[a-z]?\d+$/.test(t)) return true; - // hashed CSS-module-ish tokens: _foo_ab12 or 6-hex tails - if (/_[a-z0-9]{5,}$/i.test(t) && /\d/.test(t)) return true; - return false; -} - const GENERIC_COMP_RE = /^(Item|Card|List|ListItem|Link|LinkItem|Wrapper|Box|Div|El|Node|Group|Block|Comp|Component|Row|Col|Cell|Thing|Unit|Part|Chunk)\d*$/; -/** Exported / defined component names in a file. */ -function componentNames(text: string): string[] { +export function componentNames(text: string): string[] { const names = new Set(); let m: RegExpExecArray | null; const re1 = /export\s+default\s+function\s+([A-Z]\w*)/g; @@ -125,221 +179,428 @@ function componentNames(text: string): string[] { return [...names]; } -/** Data/content field names declared in a content or data module. */ -function contentFieldNames(text: string): string[] { - const out: string[] = []; - // type Foo = { a: string; b?: number } — capture keys - const typeBlocks = text.match(/(?:type|interface)\s+\w+\s*=?\s*\{([^}]*)\}/g) ?? []; - for (const blk of typeBlocks) { - const re = /(\w+)\s*\??\s*:/g; let m: RegExpExecArray | null; - while ((m = re.exec(blk)) !== null) out.push(m[1]!); +// =========================================================================== +// METRIC EXTRACTORS (exported for unit tests — each is a pure text→number/obj) +// =========================================================================== + +/** Bytes of embedded base64 blobs + HTML passed as string props/dangerous html. */ +export function inlineBlobBytes(text: string): number { + let bytes = 0; + // base64 data URIs — the payload after the comma. + for (const m of text.matchAll(/data:[^;,'"`)\s]*;base64,([A-Za-z0-9+/=]+)/g)) bytes += m[1]!.length; + // dangerouslySetInnerHTML / html-string props: a big string literal handed to markup. + for (const m of text.matchAll(/dangerouslySetInnerHTML/g)) void m; + // A very long string literal that is actually markup ("
" as a prop). + for (const m of text.matchAll(/"((?:[^"\\]|\\.){2000,})"/g)) { + const s = m[1]!; + if (/<[a-z][a-z0-9]*[\s>]/i.test(s) && (s.match(/ 20) bytes += s.length; } - return out; + return bytes; +} + +/** Payload facts about one file. */ +export function filePayload(f: SrcFile): { bytes: number; maxLine: number; inlineBytes: number } { + return { bytes: f.text.length, maxLine: maxLineLen(f.text), inlineBytes: inlineBlobBytes(f.text) }; +} + +/** {" "} / {' '} whitespace-only JSX expression literals (a capture artifact). */ +export function whitespaceLiterals(text: string): number { + return (text.match(/\{\s*["'`]\s+["'`]\s*\}/g) ?? []).length + + (text.match(/\{["'`]\\u00a0["'`]\}/gi) ?? []).length; +} + +/** Frozen sub-pixel arbitrary values: `[12.5px]`, `w-[713.938px]` where px isn't whole. */ +export function subpixelArbitraries(text: string): number { + let n = 0; + for (const m of text.matchAll(/\[(-?[0-9]+\.?[0-9]*)(px|rem)\]/g)) { + const px = parseFloat(m[1]!) * (m[2] === "rem" ? 16 : 1); + if (Math.abs(px - Math.round(px)) > 0.02) n++; + } + return n; +} + +/** CSS custom-property definitions split into opaque (--clr-7, --c12, hashes) vs named. */ +export function customPropTokens(text: string): { total: number; opaque: number } { + let total = 0, opaque = 0; + for (const m of text.matchAll(/(^|[^\w-])(--[\w-]+)\s*:/g)) { + const name = m[2]!.slice(2); // strip leading -- + total++; + if (/^(clr|c|color|var|token|t|v|n|x)?-?\d+$/i.test(name) || /^[a-f0-9]{6,}$/i.test(name)) opaque++; + } + return { total, opaque }; +} + +/** Off-screen capture-probe text leaks (measurement scaffolding shipped into markup): + * probe data-attrs, __probe__ markers, and clip:rect(0…) off-screen clipping. */ +export function probeArtifacts(text: string): number { + return (text.match(/(?:data-(?:probe|measure|ditto-probe)|__probe__|offscreen-probe|clip:\s*["'`]?\s*rect\(\s*0)/g) ?? []).length; +} + +/** addEventListener calls with no matching removeEventListener / cleanup return. */ +export function uncleanedListeners(text: string): number { + const adds = (text.match(/\.addEventListener\s*\(/g) ?? []).length; + const removes = (text.match(/\.removeEventListener\s*\(/g) ?? []).length; + // A cleanup return (useEffect teardown / AbortController) neutralizes some adds. + const hasCleanupReturn = /return\s*\(\s*\)\s*=>/.test(text) || /new AbortController/.test(text) || /\{\s*signal\s*\}/.test(text); + const covered = hasCleanupReturn ? Math.max(removes, adds) : removes; + return Math.max(0, adds - covered); +} + +/** Element-tag histogram for a whole tree of markup text. */ +export function tagHistogram(text: string): Record { + const h: Record = {}; + for (const m of text.matchAll(/<([a-z][a-z0-9]*)(?:\s|\/|>)/g)) { + const t = m[1]!; + h[t] = (h[t] ?? 0) + 1; + } + return h; +} + +/** aria-hidden applied to a focusable/interactive element in the same tag. */ +export function ariaHiddenFocusables(text: string): number { + let n = 0; + for (const m of text.matchAll(/<(a|button|input|select|textarea)\b[^>]*aria-hidden\s*=\s*["'{]?\s*true/gi)) void m; + n += (text.match(/<(?:a|button|input|select|textarea)\b[^>]*aria-hidden\s*=\s*["'{]?\s*true/gi) ?? []).length; + return n; +} + +/** Repeated helper/function definitions with identical bodies (copy-paste, not shared). */ +export function duplicateHelpers(text: string): { defs: number; dups: number } { + const bodies = new Map(); + // named function / arrow-const helper definitions with a brace body. + const re = /(?:function\s+\w+\s*\([^)]*\)|const\s+\w+\s*=\s*(?:\([^)]*\)|\w+)\s*=>)\s*\{([\s\S]{40,600}?)\}/g; + let m: RegExpExecArray | null; + let defs = 0; + while ((m = re.exec(text)) !== null) { + defs++; + const key = m[1]!.replace(/\s+/g, " ").trim(); + bodies.set(key, (bodies.get(key) ?? 0) + 1); + } + let dups = 0; + for (const c of bodies.values()) if (c > 1) dups += c - 1; + return { defs, dups }; +} + +/** Inline SVG strings, and how many are exact repeats. */ +export function svgPathDuplication(text: string): { total: number; repeats: number } { + const seen = new Map(); + let total = 0; + for (const m of text.matchAll(/\bd\s*=\s*["']([Mm][^"']{20,})["']/g)) { + total++; + const key = m[1]!; + seen.set(key, (seen.get(key) ?? 0) + 1); + } + let repeats = 0; + for (const c of seen.values()) if (c > 1) repeats += c - 1; + return { total, repeats }; +} + +/** Near-identical component pairs: same tag-histogram signature across component files. */ +export function nearDuplicateComponents(sigs: string[]): number { + const counts = new Map(); + for (const s of sigs) counts.set(s, (counts.get(s) ?? 0) + 1); + let pairs = 0; + for (const c of counts.values()) if (c > 1) pairs += c - 1; + return pairs; } -const PLUMBING_FIELD_RE = /^(_cid\d*|cid\d*|value\d*|val\d*|f\d+|d\d+|field\d+|key\d*|prop\d+|arg\d+)$/; // --------------------------------------------------------------------------- -// Scoring +// Grading // --------------------------------------------------------------------------- +export type LetterGrade = "A" | "A-" | "B+" | "B" | "B-" | "C+" | "C" | "C-" | "D+" | "D" | "D-" | "F"; +export function toLetter(total: number): LetterGrade { + if (total >= 93) return "A"; + if (total >= 90) return "A-"; + if (total >= 87) return "B+"; + if (total >= 83) return "B"; + if (total >= 80) return "B-"; + if (total >= 77) return "C+"; + if (total >= 73) return "C"; + if (total >= 70) return "C-"; + if (total >= 66) return "D+"; + if (total >= 63) return "D"; + if (total >= 60) return "D-"; + return "F"; +} + export type CategoryScore = { score: number; max: number; metrics: Record }; +export type Offender = { file: string; metric: string; value: number }; export type QualityReport = { dir: string; total: number; + grade: LetterGrade; + caps: string[]; categories: Record; + offenders: Offender[]; raw: Record; }; -const clamp01 = (x: number): number => Math.max(0, Math.min(1, x)); -/** Linear ramp: value v mapped from [lo,hi] → [0,1]. */ -const ramp = (v: number, lo: number, hi: number): number => clamp01((v - lo) / (hi - lo)); -const r1 = (n: number): number => Math.round(n * 10) / 10; +/** Dimension weights (sum = 100). Guiding, not exact — see CALIBRATION note. + * Payload + decomposition carry the most weight because an unopenable, monolithic + * file is the defect a reviewer notices first; hygiene/duplication are lighter + * because this converter's baseline (some {" "}, shared icons) is tolerable. */ +const WEIGHTS = { + payload: 26, + decomposition: 18, + duplication: 12, + semantics: 18, + hygiene: 14, + runtime: 12, +} as const; export function scoreApp(root: string): QualityReport { const files = collectFiles(root); - const tsx = files.filter((f) => f.ext === ".tsx" || f.ext === ".jsx" || f.ext === ".astro"); + const markup = files.filter((f) => f.ext === ".tsx" || f.ext === ".jsx" || f.ext === ".astro"); const css = files.filter((f) => f.ext === ".css"); + const runtimeFiles = files.filter((f) => f.ext === ".ts" || f.ext === ".tsx" || f.ext === ".jsx"); - // Component modules = JSX-bearing files that aren't config/root-layout. - const componentModules = tsx.filter((f) => + const componentModules = markup.filter((f) => !CONFIG_NAME_RE.test(f.rel) && !ROOT_NAME_RE.test(f.rel) && countTags(f.text) > 0); const nonEntry = componentModules.filter((f) => !isEntryFile(f.rel)); + const allText = files.map((f) => f.text).join("\n"); + const markupText = markup.map((f) => f.text).join("\n"); const totalTags = componentModules.reduce((s, f) => s + countTags(f.text), 0) || 1; - const entryFiles = componentModules.filter((f) => isEntryFile(f.rel)); const maxFileTags = Math.max(0, ...componentModules.map((f) => countTags(f.text))); - const dominance = maxFileTags / totalTags; // 1.0 = one giant file + const kTags = totalTags / 1000; - // Section-named components (semantic top-level blocks). Tokenize the file path + - // component names into words (splitting camelCase, kebab, snake, slashes) so both - // `hero-section.tsx` and `HeroSection` are credited. - const isSectionFile = (f: SrcFile): boolean => { - const raw = f.rel + " " + componentNames(f.text).join(" "); - const words = new Set(raw.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)); - return SECTION_WORDS.some((w) => words.has(w)); + const offenders: Offender[] = []; + const caps: string[] = []; + + // ========================================================================= + // 1. PAYLOAD DISCIPLINE — no file/line/blob a human cannot open. + // ========================================================================= + let worstFileBytes = 0, worstFileRel = ""; + let worstLine = 0, worstLineRel = ""; + let totalInlineBytes = 0, worstInlineRel = "", worstInlineBytes = 0; + for (const f of files) { + const p = filePayload(f); + if (p.bytes > worstFileBytes) { worstFileBytes = p.bytes; worstFileRel = f.rel; } + if (p.maxLine > worstLine) { worstLine = p.maxLine; worstLineRel = f.rel; } + totalInlineBytes += p.inlineBytes; + if (p.inlineBytes > worstInlineBytes) { worstInlineBytes = p.inlineBytes; worstInlineRel = f.rel; } + } + if (worstFileRel) offenders.push({ file: worstFileRel, metric: "file bytes", value: worstFileBytes }); + if (worstLineRel) offenders.push({ file: worstLineRel, metric: "max line chars", value: worstLine }); + if (worstInlineBytes > 0) offenders.push({ file: worstInlineRel, metric: "inline blob bytes", value: worstInlineBytes }); + + // Worst-file penalty, tempered by how WIDESPREAD oversized files are: one big section + // in an otherwise-small tree is forgivable; many oversized files is systemic bloat. + const oversized = files.filter((f) => f.text.length >= K.FILE_BYTES_GOOD * 2).length; + const pFileMax = penalize(worstFileBytes, K.FILE_BYTES_GOOD, K.FILE_BYTES_BAD); + const pFileSpread = penalize(oversized, 0, Math.max(3, files.length * 0.25)); + const pFile = 0.6 * pFileMax + 0.4 * pFileSpread; + const pLine = penalize(worstLine, K.LINE_CHARS_GOOD, K.LINE_CHARS_BAD); + const pInline = penalize(totalInlineBytes, K.INLINE_BYTES_GOOD, K.INLINE_BYTES_BAD); + const payloadSub = 0.4 * pFile + 0.35 * pLine + 0.25 * pInline; + + // ========================================================================= + // 2. COMPONENT DECOMPOSITION — is the page a monolith? + // ========================================================================= + const dominance = maxFileTags / totalTags; // 1.0 = one giant file holds the page + const bigSections = componentModules.filter((f) => f.lines >= K.BIG_SECTION_LINES && !isEntryFile(f.rel)).length; + for (const f of componentModules) { + if (f.lines >= K.BIG_SECTION_LINES && !isEntryFile(f.rel)) offenders.push({ file: f.rel, metric: "section LOC", value: f.lines }); + } + const dMono = penalize(dominance, K.MONOLITH_DOMINANCE_GOOD, K.MONOLITH_DOMINANCE_BAD); + const dCount = ramp(nonEntry.length, 0, K.COMPONENTS_GOOD); + const dBig = penalize(bigSections, 0, 3); + const decompositionSub = 0.45 * dMono + 0.3 * dCount + 0.25 * dBig; + + // ========================================================================= + // 3. DUPLICATION — repeated helpers, near-identical components, repeated SVG paths. + // ========================================================================= + let helperDefs = 0, helperDups = 0; + for (const f of runtimeFiles) { const d = duplicateHelpers(f.text); helperDefs += d.defs; helperDups += d.dups; } + const svgDup = svgPathDuplication(markupText); + // Near-dup signatures over NON-trivial CONTENT components only: vectorized assets + // (icons, logos, illustration frames) legitimately share a tag shape, so counting + // them as "duplicates" would punish healthy asset sets rather than real copy-paste. + const isAssetFile = (rel: string): boolean => /(^|\/)svgs?\//i.test(rel) || /(icon|illustration|logo)\d*\.[jt]sx$/i.test(basename(rel)); + const compSigs = nonEntry.filter((f) => !isAssetFile(f.rel)).map((f) => { + const h = tagHistogram(f.text); + const tags = Object.values(h).reduce((a, b) => a + b, 0); + if (tags < K.NEAR_DUP_MIN_TAGS) return ""; + return Object.keys(h).sort().map((k) => `${k}:${h[k]}`).join(","); + }).filter((s) => s.length > 0); + const nearDup = nearDuplicateComponents(compSigs); + + const helperDupRatio = helperDefs ? helperDups / helperDefs : 0; + const svgDupRatio = svgDup.total ? svgDup.repeats / svgDup.total : 0; + if (helperDups > 0) offenders.push({ file: "(helpers)", metric: "duplicate helper defs", value: helperDups }); + if (svgDup.repeats > 0) offenders.push({ file: "(inline svg)", metric: "repeated strings", value: svgDup.repeats }); + if (nearDup > 0) offenders.push({ file: "(components)", metric: "near-identical component pairs", value: nearDup }); + + const uHelper = penalize(helperDupRatio, 0, K.DUP_HELPER_RATIO_BAD); + const uSvg = penalize(svgDupRatio, 0, K.DUP_SVG_PATH_RATIO_BAD); + const uNear = penalize(nearDup, 0, K.NEAR_DUP_COMPONENT_BAD); + // Helper duplication is the strongest tell; near-dup components second; inline-SVG + // path repeats are the weakest (shared icon sets repeat legitimately). + const duplicationSub = 0.5 * uHelper + 0.35 * uNear + 0.15 * uSvg; + + // ========================================================================= + // 4. SEMANTICS / A11Y — real tags over div soup, one h1, alt coverage, no aria traps. + // ========================================================================= + const hist = tagHistogram(markupText); + const totalEls = Object.values(hist).reduce((a, b) => a + b, 0) || 1; + const divLike = (hist["div"] ?? 0) + (hist["span"] ?? 0); + const semanticTags = (hist["section"] ?? 0) + (hist["nav"] ?? 0) + (hist["header"] ?? 0) + (hist["footer"] ?? 0) + + (hist["main"] ?? 0) + (hist["article"] ?? 0) + (hist["aside"] ?? 0) + (hist["button"] ?? 0) + + (hist["h1"] ?? 0) + (hist["h2"] ?? 0) + (hist["h3"] ?? 0) + (hist["ul"] ?? 0) + (hist["nav"] ?? 0); + const h1Count = hist["h1"] ?? 0; + const imgCount = hist["img"] ?? 0; + const altCount = (markupText.match(/\balt\s*=/g) ?? []).length; + const ariaHidden = ariaHiddenFocusables(markupText); + const divRatio = divLike / totalEls; + const altCoverage = imgCount ? clamp01(altCount / imgCount) : 1; + + if (h1Count === 0) offenders.push({ file: "(page)", metric: "h1 count", value: 0 }); + if (ariaHidden > 0) offenders.push({ file: "(markup)", metric: "aria-hidden on focusables", value: ariaHidden }); + if (imgCount && altCoverage < K.ALT_COVERAGE_GOOD) offenders.push({ file: "(markup)", metric: "imgs missing alt", value: imgCount - altCount }); + + const aDiv = penalize(divRatio, K.DIV_RATIO_GOOD, K.DIV_RATIO_BAD); + const aSem = clamp01(semanticTags / (totalEls * 0.12)); // ~12% semantic tags → full credit + const aH1 = h1Count === K.H1_REQUIRED ? 1 : h1Count > K.H1_REQUIRED ? 0.8 : 0.1; // missing h1 is a heavy penalty; multiple h1 a minor ding + const aAlt = altCoverage; + const aAria = penalize(ariaHidden, 0, 6); + const semanticsSub = 0.3 * aDiv + 0.2 * aSem + 0.25 * aH1 + 0.15 * aAlt + 0.1 * aAria; + + // ========================================================================= + // 5. HYGIENE — whitespace literals, frozen sub-pixels, opaque tokens, probe leaks. + // ========================================================================= + const wsLiterals = whitespaceLiterals(markupText); + const subpixel = subpixelArbitraries(allText); + let propTotal = 0, propOpaque = 0; + for (const f of css) { const t = customPropTokens(f.text); propTotal += t.total; propOpaque += t.opaque; } + const probes = probeArtifacts(allText); + const opaqueRatio = propTotal ? propOpaque / propTotal : 0; + const wsPerK = wsLiterals / Math.max(1, kTags); + const subpixelPerK = subpixel / Math.max(1, kTags); + + if (wsLiterals > 0) offenders.push({ file: "(markup)", metric: "{\" \"} whitespace literals", value: wsLiterals }); + if (subpixel > 0) offenders.push({ file: "(styles)", metric: "frozen sub-pixel arbitraries", value: subpixel }); + if (propOpaque > 0) offenders.push({ file: "(css)", metric: "opaque --token defs", value: propOpaque }); + if (probes > 0) offenders.push({ file: "(markup)", metric: "capture-probe artifacts", value: probes }); + + const hWs = penalize(wsPerK, K.WS_LITERAL_PER_KTAG_GOOD, K.WS_LITERAL_PER_KTAG_BAD); + const hSub = penalize(subpixelPerK, K.SUBPIXEL_PER_KTAG_GOOD, K.SUBPIXEL_PER_KTAG_BAD); + const hTok = penalize(opaqueRatio, 0, K.OPAQUE_TOKEN_RATIO_BAD); + const hProbe = penalize(probes, 0, K.PROBE_ARTIFACTS_BAD); + const hygieneSub = 0.28 * hWs + 0.28 * hSub + 0.28 * hTok + 0.16 * hProbe; + + // ========================================================================= + // 6. RUNTIME DISCIPLINE — no leaked listeners, no undeclared imports. + // ========================================================================= + let leaks = 0; + for (const f of runtimeFiles) leaks += uncleanedListeners(f.text); + const undeclaredImports = countUndeclaredImports(runtimeFiles); + if (leaks > 0) offenders.push({ file: "(runtime)", metric: "uncleaned addEventListener", value: leaks }); + if (undeclaredImports > 0) offenders.push({ file: "(runtime)", metric: "undeclared imports", value: undeclaredImports }); + + const rLeak = penalize(leaks, K.LEAK_LISTENERS_GOOD, K.LEAK_LISTENERS_BAD); + const rImp = penalize(undeclaredImports, 0, 6); + const runtimeSub = 0.7 * rLeak + 0.3 * rImp; + + // ========================================================================= + // BLEND + HARD CAPS + // ========================================================================= + const dims = { + payload: payloadSub, + decomposition: decompositionSub, + duplication: duplicationSub, + semantics: semanticsSub, + hygiene: hygieneSub, + runtime: runtimeSub, }; - const sectionComponents = nonEntry.filter(isSectionFile).length; - const svgFiles = componentModules.filter((f) => /(^|\/)svgs?\//i.test(f.rel) || /icon/i.test(basename(f.rel)) || (countTags(f.text) > 0 && /^[^<]*(); - for (const f of componentModules) for (const n of componentNames(f.text)) allComponentNames.add(n); - // exclude the page/Page entry symbol from the semantic judgement - const judgedNames = [...allComponentNames].filter((n) => n !== "Page" && n !== "default" && n !== "RootLayout" && n !== "Layout"); - const genericNames = judgedNames.filter((n) => GENERIC_COMP_RE.test(n)).length; - const compNameSemanticRatio = judgedNames.length ? 1 - genericNames / judgedNames.length : 0; - - const allClassToks: string[] = []; - for (const f of tsx) allClassToks.push(...classTokens(f.text)); - const classTotal = allClassToks.length || 1; - const opaqueClasses = allClassToks.filter(isOpaqueClass).length; - const classSemanticRatio = 1 - opaqueClasses / classTotal; - const distinctClasses = new Set(allClassToks).size || 1; - const classReuse = 1 - distinctClasses / classTotal; // 0 = every class used once (per-node), →1 = heavy reuse - - // content field naming + editability - const contentFiles = files.filter((f) => /(content|data)\.[jt]s$/i.test(basename(f.rel)) || /(^|\/)(content|lib)\//i.test(f.rel)); - const fieldNames: string[] = []; - for (const f of contentFiles) fieldNames.push(...contentFieldNames(f.text)); - const plumbingFields = fieldNames.filter((n) => PLUMBING_FIELD_RE.test(n)).length; - const fieldSemanticRatio = fieldNames.length ? 1 - plumbingFields / fieldNames.length : 0.5; - - // ---- styling system ---- - const cssBytes = css.reduce((s, f) => s + f.text.length, 0); - const cssBytesPerNode = cssBytes / totalTags; - let tokenRefs = 0, tokenDefs = 0; - for (const f of css) { - tokenRefs += (f.text.match(/var\(--/g) ?? []).length; - tokenDefs += (f.text.match(/^\s*--[\w-]+\s*:/gm) ?? []).length; + // Catastrophe caps: any ONE unopenable payload drags the whole grade into D-range. + if (worstFileBytes >= K.CATASTROPHE_FILE_BYTES) caps.push(`file ${worstFileRel} is ${(worstFileBytes / 1e6).toFixed(1)}MB (>1MB)`); + if (worstLine >= K.CATASTROPHE_LINE_CHARS) caps.push(`line in ${worstLineRel} is ${(worstLine / 1000).toFixed(0)}KB (>100KB)`); + if (totalInlineBytes >= K.CATASTROPHE_INLINE_BYTES) caps.push(`${(totalInlineBytes / 1e6).toFixed(1)}MB of embedded base64/HTML (>5MB)`); + if (caps.length) total = Math.min(total, K.CAP_GRADE_D); + else { + // Softer warning band. + if (worstFileBytes >= K.WARN_FILE_BYTES) caps.push(`file ${worstFileRel} is ${(worstFileBytes / 1000).toFixed(0)}KB (>${K.WARN_FILE_BYTES / 1000}KB)`); + if (worstLine >= K.WARN_LINE_CHARS) caps.push(`line in ${worstLineRel} is ${(worstLine / 1000).toFixed(0)}KB (>${K.WARN_LINE_CHARS / 1000}KB)`); + if (caps.length) total = Math.min(total, K.CAP_GRADE_C); } - // Tailwind-style theme tokens count as token usage too (utility classes referencing roles) - const tailwindTokenClasses = allClassToks.filter((t) => /(^|[-:])(bg|text|border|fill|stroke|ring|from|to|via)-/.test(t)).length; - // per-node-unique-rule penalty: count `.cNNN{` selectors in CSS - let perNodeRules = 0; - for (const f of css) perNodeRules += (f.text.match(/\.c[a-z]?\d+\s*[\{,]/g) ?? []).length; - const perNodeRuleRatio = perNodeRules / totalTags; // 1.0 = a unique rule per node + total = r1(Math.max(0, Math.min(100, total))); - // magic literals in component markup (raw hex/rgb/px in className/style strings) - let magicLiterals = 0; - for (const f of componentModules) { - magicLiterals += (f.text.match(/#[0-9a-fA-F]{3,8}\b/g) ?? []).length; - magicLiterals += (f.text.match(/\brgba?\([^)]*\)/g) ?? []).length; - } - const magicPerNode = magicLiterals / totalTags; - - // ---- idiomatic styling (the axes the old rubric was blind to) ---- - // A px ARBITRARY value (`w-[713.938px]`, `py-[64px]`) is a measurement, not a design choice — - // the single biggest "machine-generated" tell. Count them across markup + hoisted class - // consts, vs the standard scale / rem a human writes. - let arbPx = 0, arbBands = 0, stdBreakpoints = 0, dataCidCount = 0; - for (const f of [...componentModules, ...css]) { - arbPx += (f.text.match(/\[[0-9]+(?:\.[0-9]+)?px\]/g) ?? []).length; - arbBands += (f.text.match(/(?:min|max)-\[[0-9]+px\]:/g) ?? []).length; // arbitrary midpoint media variants - stdBreakpoints += (f.text.match(/(?:^|[\s"'`])(?:max-)?(?:sm|md|lg|xl|2xl):/g) ?? []).length; - dataCidCount += (f.text.match(/data-cid/g) ?? []).length; - } - const arbPxPerNode = arbPx / totalTags; // low is hand-authored; high is machine replay - const bandShare = arbBands + stdBreakpoints > 0 ? arbBands / (arbBands + stdBreakpoints) : 0; // 1 = all arbitrary - const dataCidPerNode = dataCidCount / totalTags; - // Robotic, never-hand-written comment phrases. - let roboticComments = 0; - for (const f of [...componentModules, ...css, ...files.filter((x) => x.ext === ".ts")]) { - roboticComments += (f.text.match(/Generated by clone-static|render-identical to the source|Do not edit by hand/g) ?? []).length; - } - - // ---- editability ---- - const hasContentModule = contentFiles.some((f) => /export\s+(const|type)/.test(f.text)) ? 1 : 0; - // Destructured props with a default value, in any function/arrow param block — counts - // string, array, and identifier defaults alike (a default is a default). - let propsWithDefaults = 0; - for (const f of componentModules) { - for (const blk of f.text.match(/\(\s*\{[^{}]*\}/g) ?? []) { - propsWithDefaults += (blk.match(/\b\w+\s*=\s*(?![=>])/g) ?? []).length; - } - } - const defaultsSignal = clamp01(propsWithDefaults / Math.max(6, nonEntry.length)); - - // ---- file org ---- - const dirSet = new Set(componentModules.map((f) => f.rel.split("/").slice(0, -1).join("/"))); - const hasSectionsDir = [...dirSet].some((d) => /sections?$/i.test(d)) ? 1 : 0; - const hasComponentsDir = [...dirSet].some((d) => /(components|ui)$/i.test(d)) ? 1 : 0; - const hasLayoutDir = [...dirSet].some((d) => /(layout|svgs?)$/i.test(d)) ? 1 : 0; - const orgDirs = hasSectionsDir + hasComponentsDir + hasLayoutDir; - - // ---- metadata ---- - let docHeaders = 0, annotations = 0; - for (const f of componentModules) { - docHeaders += (f.text.match(/\/\*\*[\s\S]*?\*\//g) ?? []).length; - // component-type annotations in attribute, object, or doc-tag form. - annotations += (f.text.match(/@component|data-component/g) ?? []).length; - } - - // ========================================================================= - // Category scoring - // ========================================================================= - - // 1. Componentization (25): many components, low single-file dominance, sections present - const cCount = ramp(nonEntry.length, 0, 12); // 0 → 12+ components - const cDom = 1 - ramp(dominance, 0.3, 0.95); // penalize one giant file - const cSections = ramp(sectionComponents, 0, 6); // semantic sections - const componentization = 25 * (0.4 * cCount + 0.35 * cDom + 0.25 * cSections); - - // 2. Semantic naming (25): component names, class names, content fields - const naming = 25 * (0.4 * compNameSemanticRatio + 0.4 * classSemanticRatio + 0.2 * fieldSemanticRatio); - - // 3. Styling system (20): class reuse, tokens, low per-node verbosity, few magic literals AND - // — the axes the old rubric missed — idiomatic values (standard scale/rem, not a wall of - // px arbitraries) and standard breakpoints (not arbitrary midpoint bands). - const sReuse = clamp01(classReuse / 0.6); // 0.6+ reuse → full marks - const sTokens = ramp(tokenRefs + tailwindTokenClasses, 0, Math.max(40, totalTags * 0.5)); - const sVerbosity = 1 - clamp01(perNodeRuleRatio); // per-node rules are bad - const sMagic = 1 - clamp01(magicPerNode / 0.25); - const sArb = 1 - clamp01(arbPxPerNode / 2); // ~0 arb-px/node → 1; 2+/node → 0 - const sBp = 1 - bandShare; // 0 arbitrary bands → 1 - const styling = 20 * (0.22 * sReuse + 0.13 * sTokens + 0.13 * sVerbosity + 0.07 * sMagic + 0.27 * sArb + 0.18 * sBp); - - // 4. Editability (15): content module, typed fields, semantic fields, prop defaults - const editability = 15 * (0.4 * hasContentModule + 0.3 * fieldSemanticRatio + 0.3 * defaultsSignal); - - // 5. File org (10): folder structure + svgs extracted - const fileOrg = 10 * (0.7 * (orgDirs / 3) + 0.3 * clamp01(svgFiles / 4)); - - // 6. Metadata (5): concise human doc headers + semantic component annotations — but a - // "Generated by clone-static / render-identical" robotic header is a NEGATIVE signal (no - // human writes it), and shipping a per-node data-cid on every element is markup noise. - const cleanComments = roboticComments === 0 ? 1 : clamp01(1 - roboticComments / Math.max(4, nonEntry.length)); - const lowCidNoise = 1 - clamp01(dataCidPerNode); // ~0 data-cid/node → 1; 1/node → 0 - const metadata = 5 * (0.35 * clamp01(docHeaders / Math.max(4, nonEntry.length)) * cleanComments - + 0.35 * clamp01(annotations / Math.max(8, totalTags * 0.1)) - + 0.3 * lowCidNoise); - - const total = componentization + naming + styling + editability + fileOrg + metadata; + // Order offenders by severity so the report's "top offenders" is meaningful. + offenders.sort((a, b) => severity(b) - severity(a)); return { dir: root, - total: r1(total), + total, + grade: toLetter(total), + caps, categories: { - componentization: { score: r1(componentization), max: 25, metrics: { components: nonEntry.length, dominancePct: r1(dominance * 100), sectionComponents } }, - naming: { score: r1(naming), max: 25, metrics: { compNameSemanticPct: r1(compNameSemanticRatio * 100), classSemanticPct: r1(classSemanticRatio * 100), fieldSemanticPct: r1(fieldSemanticRatio * 100), genericNames } }, - styling: { score: r1(styling), max: 20, metrics: { classReusePct: r1(classReuse * 100), tokenRefs, perNodeRules, magicLiterals, arbPx, arbPxPerNode: r1(arbPxPerNode), arbBands, stdBreakpoints, bandSharePct: r1(bandShare * 100) } }, - editability: { score: r1(editability), max: 15, metrics: { hasContentModule, fieldSemanticPct: r1(fieldSemanticRatio * 100), propsWithDefaults } }, - fileOrg: { score: r1(fileOrg), max: 10, metrics: { orgDirs, svgFiles, hasSectionsDir, hasComponentsDir } }, - metadata: { score: r1(metadata), max: 5, metrics: { docHeaders, annotations, roboticComments, dataCidPerNode: r1(dataCidPerNode) } }, + payload: { score: r1(WEIGHTS.payload * payloadSub), max: WEIGHTS.payload, metrics: { maxFileKB: r1(worstFileBytes / 1000), maxLineKB: r1(worstLine / 1000), inlineBlobKB: r1(totalInlineBytes / 1000) } }, + decomposition: { score: r1(WEIGHTS.decomposition * decompositionSub), max: WEIGHTS.decomposition, metrics: { components: nonEntry.length, dominancePct: r1(dominance * 100), bigSections } }, + duplication: { score: r1(WEIGHTS.duplication * duplicationSub), max: WEIGHTS.duplication, metrics: { helperDups, helperDupPct: r1(helperDupRatio * 100), svgPathRepeats: svgDup.repeats, nearDupPairs: nearDup } }, + semantics: { score: r1(WEIGHTS.semantics * semanticsSub), max: WEIGHTS.semantics, metrics: { h1: h1Count, divRatioPct: r1(divRatio * 100), semanticTags, altCoveragePct: r1(altCoverage * 100), ariaHiddenFocusables: ariaHidden } }, + hygiene: { score: r1(WEIGHTS.hygiene * hygieneSub), max: WEIGHTS.hygiene, metrics: { wsLiterals, subpixelArbitraries: subpixel, opaqueTokenPct: r1(opaqueRatio * 100), probeArtifacts: probes } }, + runtime: { score: r1(WEIGHTS.runtime * runtimeSub), max: WEIGHTS.runtime, metrics: { uncleanedListeners: leaks, undeclaredImports } }, }, + offenders: offenders.slice(0, 15), raw: { files: files.length, componentModules: componentModules.length, totalTags, maxFileTags, - classTokens: classTotal, distinctClasses, opaqueClasses, cssBytes, + worstFileBytes, worstLineChars: worstLine, inlineBytes: totalInlineBytes, }, }; } +/** Undeclared imports: a bare-specifier import not present in any package.json/tsconfig + * alias and not a relative path — a runtime crash waiting to happen. We approximate by + * flagging imports of local-looking modules that resolve nowhere is expensive, so instead + * we flag the cheap, robust tell: an import statement whose source is an empty string or a + * malformed specifier. (Kept conservative to avoid false positives across frameworks.) */ +function countUndeclaredImports(files: SrcFile[]): number { + let n = 0; + for (const f of files) { + for (const m of f.text.matchAll(/\bimport\s+[^;]*?\bfrom\s*["'`]([^"'`]*)["'`]/g)) { + const spec = m[1]!; + if (spec.trim() === "" || /\s/.test(spec) || spec === "undefined" || spec === "null") n++; + } + } + return n; +} + +/** Relative weight of an offender for ordering the top-offenders list. */ +function severity(o: Offender): number { + switch (o.metric) { + case "file bytes": return o.value / 1000; + case "max line chars": return o.value / 1000; + case "inline blob bytes": return o.value / 2000; + case "section LOC": return o.value / 20; + case "opaque --token defs": return o.value * 0.5; + case "frozen sub-pixel arbitraries": return o.value * 0.3; + case "{\" \"} whitespace literals": return o.value * 0.1; + case "h1 count": return 300; // a missing h1 is always a headline offender + case "uncleaned addEventListener": return o.value * 3; + default: return o.value; + } +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + function fmt(rep: QualityReport): string { const lines: string[] = []; lines.push(`\n ${rep.dir}`); - lines.push(` TOTAL: ${rep.total}/100`); + lines.push(` GRADE: ${rep.grade} (${rep.total}/100)`); + if (rep.caps.length) lines.push(` CAPS: ${rep.caps.join("; ")}`); for (const [k, c] of Object.entries(rep.categories)) { const metrics = Object.entries(c.metrics).map(([mk, mv]) => `${mk}=${mv}`).join(" "); - lines.push(` ${k.padEnd(16)} ${String(c.score).padStart(5)}/${c.max} ${metrics}`); + lines.push(` ${k.padEnd(15)} ${String(c.score).padStart(5)}/${c.max} ${metrics}`); + } + if (rep.offenders.length) { + lines.push(` top offenders:`); + for (const o of rep.offenders.slice(0, 8)) lines.push(` ${o.metric.padEnd(34)} ${String(o.value).padStart(10)} ${o.file}`); } return lines.join("\n"); } @@ -353,11 +614,11 @@ async function main(): Promise { for (const rep of reports) console.log(fmt(rep)); if (reports.length > 1) { console.log("\n ── comparison ──"); - console.log(" " + "category".padEnd(16) + reports.map((_, i) => `app${i + 1}`.padStart(10)).join("")); + console.log(" " + "dimension".padEnd(15) + reports.map((_, i) => `app${i + 1}`.padStart(10)).join("")); for (const cat of Object.keys(reports[0]!.categories)) { - console.log(" " + cat.padEnd(16) + reports.map((r) => String(r.categories[cat]!.score).padStart(10)).join("")); + console.log(" " + cat.padEnd(15) + reports.map((r) => String(r.categories[cat]!.score).padStart(10)).join("")); } - console.log(" " + "TOTAL".padEnd(16) + reports.map((r) => String(r.total).padStart(10)).join("")); + console.log(" " + "GRADE".padEnd(15) + reports.map((r) => `${r.grade}(${r.total})`.padStart(10)).join("")); } } diff --git a/compiler/test/css.test.ts b/compiler/test/css.test.ts index 8a9b293..8d57ddb 100644 --- a/compiler/test/css.test.ts +++ b/compiler/test/css.test.ts @@ -1,6 +1,7 @@ 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"; const VPS = [375, 1280]; @@ -290,3 +291,196 @@ describe("generateCss scroll-timeline animation suppression", () => { assert.ok(rule.includes("animation-name:fadeInUp"), `time-based reveal keeps its animation, got: ${rule}`); }); }); + +// --------------------------------------------------------------------------- +// Per-viewport node builder (independent bbox / computed / sizing per width) plus a matching +// 3-viewport IR wrapper — the fluid/centring/wrap detectors need ≥2 varying widths to run. +const XVPS = [375, 768, 1280]; +type XPerVp = { cs?: StyleMap; bbox: BBox; sizing?: RawSizing; visible?: boolean }; +function xNode(id: string, tag: string, byVp: Record, children: IRChild[] = []): IRNode { + const computedByVp: Record = {}; + const bboxByVp: Record = {}; + const visibleByVp: Record = {}; + const sizingByVp: Record = {}; + for (const vp of XVPS) { + 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 xIr(root: IRNode): IR { + const ir = irWith(root); + ir.doc.viewports = XVPS; + ir.doc.sampleViewports = XVPS; + ir.doc.perViewport = Object.fromEntries(XVPS.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; +} +/** Every `.c{…}` body (base + banded) concatenated, for asserting a value appears at some vp. */ +function allRulesX(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; +} +/** The `.c{…}` body inside the first @media block whose query matches `mediaRe`. */ +function xBandRule(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 ""; +} + +// BUG A — a padded pill/section with LITERAL equal side margins (a fraction of the viewport, varying +// across widths) must keep those px margins per band. The width-fill sizing probe is what tells the +// centring detector these are load-bearing spacing, not margin-auto centring slack: on a box the +// probe reads as a container-fill (width:100% reproduces it), `margin:auto` resolves to 0 and would +// blow the box out to full-bleed, deleting the real margins. +describe("generateCss literal-margin vs auto-centring", () => { + const fill = (): RawSizing => ({ wAuto: false, wFill: true, hAuto: true, hFill: true }); + // A flex parent spanning the whole viewport, holding one padded pill child that fills the space + // BETWEEN its literal side margins (box + 2×margin == container at every width). + function pill() { + const child = xNode("n1", "div", { + 375: { cs: { display: "flex", marginLeft: "15px", marginRight: "15px", width: "345px" }, bbox: { x: 15, y: 0, width: 345, height: 62 }, sizing: fill() }, + 768: { cs: { display: "flex", marginLeft: "30.7188px", marginRight: "30.7188px", width: "706.562px" }, bbox: { x: 30.72, y: 0, width: 706.56, height: 62 }, sizing: fill() }, + 1280: { cs: { display: "flex", marginLeft: "25.5938px", marginRight: "25.5938px", width: "1228.81px" }, bbox: { x: 25.59, y: 0, width: 1228.81, height: 62 }, sizing: fill() }, + }); + const parent = xNode("n0", "body", { + 375: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 375, height: 62 } }, + 768: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 768, height: 62 } }, + 1280: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 1280, height: 62 } }, + }, [child]); + return parent; + } + + it("keeps the literal px side margins on a width-filling pill (no mx-auto)", () => { + const css = generateCss(xIr(pill()), new Map()); + const all = allRulesX(css, "n1"); + assert.ok(!/margin-left:auto/.test(all), `filling pill must not be centred with auto margins, got: ${all}`); + assert.ok(baseRule(css, "n1").includes("margin-left:25.5938px"), `base keeps the 1280 literal margin, got: ${baseRule(css, "n1")}`); + // The narrowest band (a `max-width` query with no `min-width`) carries the 375-vp literal margin. + const mobile = xBandRule(css, /^\(max-width/, "n1"); + assert.ok(/margin-left:15px/.test(mobile), `mobile band keeps its literal 15px margin, got: ${mobile}`); + }); + + it("still emits margin:auto for a genuinely centred, width-CONSTRAINED block", () => { + // Content-sized (not a fill): the probe says width:auto re-derives it, width narrower than the + // container with symmetric slack that varies with width — real margin-auto centring. + const auto = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false }); + const child = xNode("n1", "div", { + 375: { cs: { display: "block", marginLeft: "15px", marginRight: "15px", width: "345px" }, bbox: { x: 15, y: 0, width: 345, height: 40 }, sizing: auto() }, + 768: { cs: { display: "block", marginLeft: "84px", marginRight: "84px", width: "600px" }, bbox: { x: 84, y: 0, width: 600, height: 40 }, sizing: auto() }, + 1280: { cs: { display: "block", marginLeft: "340px", marginRight: "340px", width: "600px" }, bbox: { x: 340, y: 0, width: 600, height: 40 }, sizing: auto() }, + }); + const parent = xNode("n0", "body", { + 375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 375, height: 40 } }, + 768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 768, height: 40 } }, + 1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 1280, height: 40 } }, + }, [child]); + const css = generateCss(xIr(parent), new Map()); + const all = allRulesX(css, "n1"); + assert.ok(/margin-left:auto/.test(all), `a constrained centred block should still auto-centre, got: ${all}`); + }); +}); + +// BUG C — a single-line text leaf whose unwrapped width nearly fills its column at every width gets +// `white-space:nowrap`, so a sub-pixel column shortfall in the clone can't wrap it to a second line. +describe("generateCss wrap-vulnerable single-line text", () => { + // Text bbox exactly fills the column (wMax == avail == bbox.width) at every width, single line + // (height == line-height), and is genuinely wrappable (wMin < wMax). + function edgeText(wMin: number) { + const szAt = (w: number): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false, wMin, wMax: w }); + const leaf = xNode("n1", "div", { + 375: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 107.81, height: 24 }, sizing: szAt(107.81) }, + 768: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 107.81, height: 24 }, sizing: szAt(107.81) }, + 1280: { cs: { display: "block", lineHeight: "20px" }, bbox: { x: 0, y: 0, width: 107.81, height: 20 }, sizing: szAt(107.81) }, + }, [{ text: "CEO — Academy" }]); + const parent = xNode("n0", "body", { + 375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 107.81, height: 24 } }, + 768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 107.81, height: 24 } }, + 1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 107.81, height: 20 } }, + }, [leaf]); + return parent; + } + + it("emits white-space:nowrap for text flush against its column edge", () => { + const css = generateCss(xIr(edgeText(58.33)), new Map()); + assert.ok(baseRule(css, "n1").includes("white-space:nowrap"), `edge-flush single-line text should get nowrap, got: ${baseRule(css, "n1")}`); + }); + + it("does NOT emit nowrap for a single unbreakable token (wMin == wMax — can't wrap)", () => { + const css = generateCss(xIr(edgeText(107.81)), new Map()); + assert.ok(!baseRule(css, "n1").includes("white-space:nowrap"), `an unbreakable token needs no nowrap, got: ${baseRule(css, "n1")}`); + }); + + it("does NOT emit nowrap for a genuinely wrapping multi-line paragraph", () => { + // Two line boxes tall (height ≈ 2×line-height) → already wrapping, must stay wrappable. + const wMin = 100, wMax = 400; + const szAt = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false, wMin, wMax }); + const para = xNode("n1", "p", { + 375: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 345, height: 72 }, sizing: szAt() }, + 768: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 700, height: 48 }, sizing: szAt() }, + 1280: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 400, height: 48 }, sizing: szAt() }, + }, [{ text: "A longer paragraph that wraps across multiple lines depending on the width." }]); + const parent = xNode("n0", "body", { + 375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 345, height: 72 } }, + 768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 700, height: 48 } }, + 1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 400, height: 48 } }, + }, [para]); + const css = generateCss(xIr(parent), new Map()); + assert.ok(!allRulesX(css, "n1").includes("white-space:nowrap"), `a wrapping paragraph must not be forced nowrap, got: ${allRulesX(css, "n1")}`); + }); + + it("does NOT emit nowrap for text with comfortable slack in its container", () => { + const szAt = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false, wMin: 60, wMax: 90 }); + const leaf = xNode("n1", "div", { + 375: { cs: { display: "block", lineHeight: "20px" }, bbox: { x: 0, y: 0, width: 90, height: 20 }, sizing: szAt() }, + 768: { cs: { display: "block", lineHeight: "20px" }, bbox: { x: 0, y: 0, width: 90, height: 20 }, sizing: szAt() }, + 1280: { cs: { display: "block", lineHeight: "20px" }, bbox: { x: 0, y: 0, width: 90, height: 20 }, sizing: szAt() }, + }, [{ text: "Nav link" }]); + // Wide container (300px+) — the 90px text has plenty of room, no wrap risk. + const parent = xNode("n0", "body", { + 375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 375, height: 20 } }, + 768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 768, height: 20 } }, + 1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 1280, height: 20 } }, + }, [leaf]); + const css = generateCss(xIr(parent), new Map()); + assert.ok(!baseRule(css, "n1").includes("white-space:nowrap"), `slack text needs no nowrap, got: ${baseRule(css, "n1")}`); + }); +}); + +// Cross-band transform identity — a node with a NON-identity transform at one band must emit the +// explicit identity `transform:none` at the bands where the source is untransformed, so the +// transform can't cascade across bands and freeze at a width the source left untransformed. +describe("generateCss cross-band transform identity", () => { + it("emits transform:none at a band where a node with a non-identity transform elsewhere is identity", () => { + const el = xNode("n1", "div", { + 375: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 375, height: 40 } }, + 768: { cs: { transform: "matrix(1, 0, 0, 1, 40, 0)" }, bbox: { x: 40, y: 0, width: 375, height: 40 } }, + 1280: { cs: { transform: "matrix(1, 0, 0, 1, 40, 0)" }, bbox: { x: 40, y: 0, width: 375, height: 40 } }, + }); + const root = xNode("n0", "body", { 375: { bbox: { x: 0, y: 0, width: 375, height: 40 } }, 768: { bbox: { x: 0, y: 0, width: 768, height: 40 } }, 1280: { bbox: { x: 0, y: 0, width: 1280, height: 40 } } }, [el]); + const css = generateCss(xIr(root), new Map()); + const all = allRulesX(css, "n1"); + assert.ok(/transform:matrix/.test(all), `the non-identity transform must be emitted, got: ${all}`); + assert.ok(/transform:none/.test(all), `the identity band must emit transform:none so it can't cascade, got: ${all}`); + }); + + it("does NOT emit transform:none for a node that is identity at every band", () => { + const el = xNode("n1", "div", { + 375: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 375, height: 40 } }, + 768: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 375, height: 40 } }, + 1280: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 375, height: 40 } }, + }); + const root = xNode("n0", "body", { 375: { bbox: { x: 0, y: 0, width: 375, height: 40 } }, 768: { bbox: { x: 0, y: 0, width: 768, height: 40 } }, 1280: { bbox: { x: 0, y: 0, width: 1280, height: 40 } } }, [el]); + const css = generateCss(xIr(root), new Map()); + assert.ok(!allRulesX(css, "n1").includes("transform:none"), `an always-identity node needs no explicit transform, got: ${allRulesX(css, "n1")}`); + }); +}); diff --git a/compiler/test/dotlottie.test.ts b/compiler/test/dotlottie.test.ts new file mode 100644 index 0000000..e7e607a --- /dev/null +++ b/compiler/test/dotlottie.test.ts @@ -0,0 +1,146 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { deflateRawSync } from "node:zlib"; +import { isZipArchive, extractDotLottieJson, readZipEntry } from "../src/capture/dotlottie.js"; +import { extFromUrl } from "../src/capture/capture.js"; + +/** + * Build a minimal, spec-correct ZIP archive in-memory from named entries. Supports both + * stored (method 0) and deflated (method 8) compression so the extractor is exercised on both. + * No CRC validation is needed for our reader, so CRC fields are left 0. + */ +function buildZip(entries: Array<{ name: string; data: Buffer; deflate?: boolean }>): Buffer { + const locals: Buffer[] = []; + const centrals: Buffer[] = []; + let offset = 0; + + for (const e of entries) { + const nameBuf = Buffer.from(e.name, "utf8"); + const stored = e.deflate ? deflateRawSync(e.data) : e.data; + const method = e.deflate ? 8 : 0; + + const local = Buffer.alloc(30 + nameBuf.length); + local.writeUInt32LE(0x04034b50, 0); // local file header sig + local.writeUInt16LE(20, 4); // version needed + local.writeUInt16LE(0, 6); // flags + local.writeUInt16LE(method, 8); + local.writeUInt16LE(0, 10); // time + local.writeUInt16LE(0, 12); // date + local.writeUInt32LE(0, 14); // crc + local.writeUInt32LE(stored.length, 18); // comp size + local.writeUInt32LE(e.data.length, 22); // uncomp size + local.writeUInt16LE(nameBuf.length, 26); + local.writeUInt16LE(0, 28); // extra len + nameBuf.copy(local, 30); + const localFull = Buffer.concat([local, stored]); + locals.push(localFull); + + const central = Buffer.alloc(46 + nameBuf.length); + central.writeUInt32LE(0x02014b50, 0); // central dir sig + central.writeUInt16LE(20, 4); // version made by + central.writeUInt16LE(20, 6); // version needed + central.writeUInt16LE(0, 8); // flags + central.writeUInt16LE(method, 10); + central.writeUInt16LE(0, 12); // time + central.writeUInt16LE(0, 14); // date + central.writeUInt32LE(0, 16); // crc + central.writeUInt32LE(stored.length, 20); // comp size + central.writeUInt32LE(e.data.length, 24); // uncomp size + central.writeUInt16LE(nameBuf.length, 28); + central.writeUInt16LE(0, 30); // extra + central.writeUInt16LE(0, 32); // comment + central.writeUInt16LE(0, 34); // disk + central.writeUInt16LE(0, 36); // internal attrs + central.writeUInt32LE(0, 38); // external attrs + central.writeUInt32LE(offset, 42); // local header offset + nameBuf.copy(central, 46); + centrals.push(central); + + offset += localFull.length; + } + + const centralStart = offset; + const centralDir = Buffer.concat(centrals); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(0, 4); // disk + eocd.writeUInt16LE(0, 6); // cd start disk + eocd.writeUInt16LE(entries.length, 8); + eocd.writeUInt16LE(entries.length, 10); + eocd.writeUInt32LE(centralDir.length, 12); + eocd.writeUInt32LE(centralStart, 16); + eocd.writeUInt16LE(0, 20); // comment len + + return Buffer.concat([...locals, centralDir, eocd]); +} + +const ANIM_1 = { v: "5.7.4", nm: "one", layers: [{ ind: 1 }] }; +const ANIM_2 = { v: "5.7.4", nm: "two", layers: [{ ind: 2 }] }; +const MANIFEST = { version: "1.0", animations: [{ id: "animation_default" }, { id: "animation_2" }] }; + +describe("dotLottie ZIP extraction", () => { + it("detects ZIP archives by the PK local-header magic", () => { + assert.equal(isZipArchive(Buffer.from([0x50, 0x4b, 0x03, 0x04, 0, 0])), true); + assert.equal(isZipArchive(Buffer.from('{"v":"5.7"}', "utf8")), false); + assert.equal(isZipArchive(Buffer.alloc(2)), false); + }); + + it("extracts the manifest's default animation JSON from a stored dotLottie ZIP", () => { + const zip = buildZip([ + { name: "manifest.json", data: Buffer.from(JSON.stringify(MANIFEST), "utf8") }, + { name: "animations/animation_default.json", data: Buffer.from(JSON.stringify(ANIM_1), "utf8") }, + { name: "animations/animation_2.json", data: Buffer.from(JSON.stringify(ANIM_2), "utf8") }, + ]); + const out = extractDotLottieJson(zip); + assert.ok(out, "expected an extracted animation buffer"); + assert.deepEqual(JSON.parse(out!.toString("utf8")), ANIM_1); + }); + + it("extracts a DEFLATE-compressed animation entry (the common real-world case)", () => { + const zip = buildZip([ + { name: "manifest.json", data: Buffer.from(JSON.stringify(MANIFEST), "utf8"), deflate: true }, + { name: "animations/animation_default.json", data: Buffer.from(JSON.stringify(ANIM_1), "utf8"), deflate: true }, + ]); + const out = extractDotLottieJson(zip); + assert.ok(out); + assert.deepEqual(JSON.parse(out!.toString("utf8")), ANIM_1); + }); + + it("falls back to the name-sorted first animation when the manifest is absent", () => { + const zip = buildZip([ + { name: "animations/b.json", data: Buffer.from(JSON.stringify(ANIM_2), "utf8") }, + { name: "animations/a.json", data: Buffer.from(JSON.stringify(ANIM_1), "utf8") }, + ]); + const out = extractDotLottieJson(zip); + assert.ok(out); + assert.deepEqual(JSON.parse(out!.toString("utf8")), ANIM_1); // "a.json" sorts first + }); + + it("returns null for non-ZIP bytes and for a ZIP with no animations", () => { + assert.equal(extractDotLottieJson(Buffer.from('{"v":"5.7"}', "utf8")), null); + const noAnims = buildZip([{ name: "manifest.json", data: Buffer.from("{}", "utf8") }]); + assert.equal(extractDotLottieJson(noAnims), null); + }); + + it("reads a named entry directly", () => { + const zip = buildZip([{ name: "manifest.json", data: Buffer.from(JSON.stringify(MANIFEST), "utf8"), deflate: true }]); + const m = readZipEntry(zip, "manifest.json"); + assert.ok(m); + assert.deepEqual(JSON.parse(m!.toString("utf8")), MANIFEST); + assert.equal(readZipEntry(zip, "missing.json"), null); + }); +}); + +describe("asset extension preservation (extFromUrl)", () => { + it("preserves real long extensions instead of truncating to 5 chars", () => { + assert.equal(extFromUrl("https://x/anim.lottie"), "lottie"); + assert.equal(extFromUrl("https://x/site.webmanifest"), "webmanifest"); + assert.equal(extFromUrl("https://x/pic.png?v=2"), "png"); + assert.equal(extFromUrl("https://x/font.woff2"), "woff2"); + }); + + it("rejects absurdly long or non-alphanumeric trailing segments", () => { + assert.equal(extFromUrl("https://x/name.thisisnotanextension"), ""); + assert.equal(extFromUrl("https://x/dir.with.dots/file"), ""); // dot is in a path segment, not the file + }); +}); diff --git a/compiler/test/lottiePlaceholder.test.ts b/compiler/test/lottiePlaceholder.test.ts new file mode 100644 index 0000000..c220df5 --- /dev/null +++ b/compiler/test/lottiePlaceholder.test.ts @@ -0,0 +1,77 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { DITTO_LOTTIE_TSX } from "../src/generate/lottie.js"; +import { isIdentityTransform, canonicalizeTransforms } from "../src/normalize/ir.js"; +import type { StyleMap } from "../src/normalize/ir.js"; + +/** + * The DittoLottie runtime must NOT erase the captured placeholder frame before the animation + * has successfully loaded — a failed load would otherwise blank the container (erasing hero / + * logo / footer media). These are string assertions on the emitted 'use client' template. + */ +describe("DittoLottie placeholder retention", () => { + it("does not clear the container's innerHTML before mounting", () => { + assert.ok( + !/el\.innerHTML\s*=\s*""/.test(DITTO_LOTTIE_TSX), + "template must not eagerly clear the placeholder via el.innerHTML = \"\"", + ); + }); + + it("mounts the live animation into a separate overlay child, not the container itself", () => { + assert.match(DITTO_LOTTIE_TSX, /createElement\(["']div["']\)/); + assert.match(DITTO_LOTTIE_TSX, /container:\s*mount/); + }); + + it("only removes the placeholder after a successful load event (DOMLoaded)", () => { + // The reveal/swap must be gated behind lottie's ready event, and the placeholder removal + // must live inside that gated path (removing original children once mount is ready). + assert.match(DITTO_LOTTIE_TSX, /addEventListener\(\s*["']DOMLoaded["']/); + assert.match(DITTO_LOTTIE_TSX, /removeChild/); + // The removal must reference the ready swap, not run unconditionally at mount time. + const revealIdx = DITTO_LOTTIE_TSX.indexOf("DOMLoaded"); + const clearIdx = DITTO_LOTTIE_TSX.indexOf("removeChild"); + assert.ok(revealIdx >= 0 && clearIdx >= 0, "both the ready gate and the placeholder removal must be present"); + }); + + it("handles a failed load without leaving a broken mount stacked over the placeholder", () => { + assert.match(DITTO_LOTTIE_TSX, /data_failed/); + }); +}); + +describe("identity-transform canonicalization (isIdentityTransform)", () => { + it("treats none and identity matrices as identity", () => { + assert.equal(isIdentityTransform("none"), true); + assert.equal(isIdentityTransform(undefined), true); + assert.equal(isIdentityTransform("matrix(1, 0, 0, 1, 0, 0)"), true); + assert.equal(isIdentityTransform("matrix(1,0,0,1,0,0)"), true); + assert.equal( + isIdentityTransform("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)"), + true, + ); + }); + + it("does NOT treat a real (non-identity) transform as identity", () => { + assert.equal(isIdentityTransform("matrix(1, 0, 0, 1, 0, 67.75)"), false); + assert.equal(isIdentityTransform("translateY(67.75px)"), false); + assert.equal(isIdentityTransform("matrix(0.5, 0, 0, 0.5, 0, 0)"), false); + assert.equal(isIdentityTransform("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 10, 0, 1)"), false); + }); + + it("rewrites identity values to `none` while leaving a real transform observable at other widths", () => { + // The contamination scenario: the base viewport (1280) baked a scroll-linked translateY, + // while the other widths report identity (none / identity matrix). After canonicalization + // the identity widths read literal `none`, so the generator's per-band delta emits the + // explicit reset and the base transform can't cascade across bands. + const computedByVp: Record = { + 375: { transform: "none" } as StyleMap, + 768: { transform: "matrix(1, 0, 0, 1, 0, 0)" } as StyleMap, + 1280: { transform: "matrix(1, 0, 0, 1, 0, 67.75)" } as StyleMap, // contaminated base + 1920: { transform: "none" } as StyleMap, + }; + canonicalizeTransforms(computedByVp); + assert.equal(computedByVp[375]!.transform, "none"); + assert.equal(computedByVp[768]!.transform, "none"); // identity matrix normalized to none + assert.equal(computedByVp[1280]!.transform, "matrix(1, 0, 0, 1, 0, 67.75)"); // real transform preserved + assert.equal(computedByVp[1920]!.transform, "none"); + }); +}); diff --git a/compiler/test/marqueeDiscriminator.test.ts b/compiler/test/marqueeDiscriminator.test.ts new file mode 100644 index 0000000..ee594d9 --- /dev/null +++ b/compiler/test/marqueeDiscriminator.test.ts @@ -0,0 +1,104 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + medianVelocityPxPerSec, + classifyVelocitySamples, + hasRepeatedChildren, +} from "../src/capture/motion.js"; + +// These cover the pure discriminators extracted from detectMarquees' in-browser sampling. +// The regression they defend: on a scroll-LINKED-easing page, a static logo row is still +// lerping toward its scroll-target right after scrollIntoView, reading as a constant +// velocity over ONE short window → four phantom marquees at identical -34px/s. The layered +// tests below make that classification fail. + +describe("medianVelocityPxPerSec", () => { + it("converts a steady per-120ms delta to px/s and ignores the wrap-reset outlier", () => { + // steady -4px per 120ms ≈ -33px/s; one big +200 wrap jump is an outlier the median drops. + const deltas = [-4, -4, 200, -4, -4]; + assert.equal(medianVelocityPxPerSec(deltas, 120), Math.round((-4 / 120) * 1000)); + }); + it("returns 0 for empty deltas or a non-positive cadence", () => { + assert.equal(medianVelocityPxPerSec([], 120), 0); + assert.equal(medianVelocityPxPerSec([-4, -4], 0), 0); + }); +}); + +describe("classifyVelocitySamples — sustained constant velocity (discriminator 2)", () => { + const MS = 120; + it("PASSES a real marquee: velocity holds across both windows", () => { + const w1 = [-4, -4, -4, -4, -4]; + const w2 = [-4, -4, -4, -4, -4]; + const r = classifyVelocitySamples(w1, w2, MS); + assert.equal(r.isMarquee, true); + assert.equal(r.pxPerSec, medianVelocityPxPerSec(w1, MS)); + }); + + it("REJECTS a scroll-settle lerp: velocity decays sharply in window 2 (the false positive)", () => { + // window1 still lerping fast toward scroll-target; window2 nearly settled. + const w1 = [-4, -4, -4, -4, -4]; // ≈ -33px/s (the phantom "-34px/s") + const w2 = [-0.4, -0.4, -0.4, -0.4, -0.4]; // decayed to ~-3px/s + assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, false); + }); + + it("REJECTS when window 2 has fully settled (velocity ~0)", () => { + const w1 = [-4, -4, -4, -4, -4]; + const w2 = [0, 0, 0, 0, 0]; + assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, false); + }); + + it("REJECTS when the direction reverses between windows", () => { + const w1 = [-4, -4, -4, -4, -4]; + const w2 = [4, 4, 4, 4, 4]; + assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, false); + }); + + it("REJECTS a static row: no motion in either window", () => { + assert.equal(classifyVelocitySamples([0, 0, 0], [0, 0, 0], MS).isMarquee, false); + }); + + it("PASSES a slightly slower-but-still-moving second window (within tolerance)", () => { + // small natural jitter (10% slower) must not disqualify a genuine ticker. + const w1 = [-4, -4, -4, -4, -4]; + const w2 = [-3.6, -3.6, -3.6, -3.6, -3.6]; + assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, true); + }); +}); + +describe("hasRepeatedChildren — genuine duplication (discriminator 4)", () => { + it("PASSES two consecutive children with an identical outerHTML hash (a cloned copy)", () => { + const hashes = [111, 111, 222]; // first two are a literal duplicate + const widths = [80, 80, 120]; + assert.equal(hasRepeatedChildren(hashes, widths), true); + }); + + it("PASSES a duplicated content block via the repeated width sequence [A B A B]", () => { + // hashes differ (cloned then attribute-tweaked) but geometry repeats: [100,60,100,60]. + const hashes = [1, 2, 3, 4]; + const widths = [100, 60, 100, 60]; + assert.equal(hasRepeatedChildren(hashes, widths), true); + }); + + it("REJECTS a row of distinct logos: no consecutive repetition (the false-positive shape)", () => { + // four DIFFERENT logos — the exact static-logo-row case that produced phantom marquees. + const hashes = [10, 20, 30, 40]; + const widths = [90, 110, 75, 130]; + assert.equal(hasRepeatedChildren(hashes, widths), false); + }); + + it("REJECTS fewer than two children", () => { + assert.equal(hasRepeatedChildren([5], [50]), false); + assert.equal(hasRepeatedChildren([], []), false); + }); + + it("does NOT treat a run of zero-width nodes as repetition (hash 0 / width 0 guards)", () => { + assert.equal(hasRepeatedChildren([0, 0], [0, 0]), false); + assert.equal(hasRepeatedChildren([1, 2, 3, 4], [0, 0, 0, 0]), false); + }); + + it("REJECTS an odd-length width sequence that can't split into halves", () => { + const hashes = [1, 2, 3]; + const widths = [100, 60, 100]; + assert.equal(hasRepeatedChildren(hashes, widths), false); + }); +}); diff --git a/compiler/test/qualityScore.test.ts b/compiler/test/qualityScore.test.ts new file mode 100644 index 0000000..13ef61c --- /dev/null +++ b/compiler/test/qualityScore.test.ts @@ -0,0 +1,256 @@ +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 { + inlineBlobBytes, + filePayload, + whitespaceLiterals, + subpixelArbitraries, + customPropTokens, + probeArtifacts, + uncleanedListeners, + tagHistogram, + ariaHiddenFocusables, + duplicateHelpers, + svgPathDuplication, + nearDuplicateComponents, + componentNames, + toLetter, + scoreApp, + type SrcFile, +} from "../src/runner/qualityScore.js"; + +const srcFile = (rel: string, text: string): SrcFile => ({ + path: rel, rel, ext: rel.slice(rel.lastIndexOf(".")), text, lines: text.split("\n").length, +}); + +// --------------------------------------------------------------------------- +// Metric extractors +// --------------------------------------------------------------------------- + +describe("inlineBlobBytes", () => { + it("counts base64 data-URI payload bytes", () => { + const b64 = "A".repeat(500); + const text = `const img = "data:image/png;base64,${b64}";`; + assert.equal(inlineBlobBytes(text), 500); + }); + + it("counts a long markup string handed in as a prop", () => { + const html = "
" + "x".repeat(250) + "
"; + const text = ``; + assert.ok(inlineBlobBytes(text) > 1000, "flags HTML-as-string blob"); + }); + + it("ignores ordinary short strings", () => { + assert.equal(inlineBlobBytes(`const s = "hello world";`), 0); + }); +}); + +describe("filePayload", () => { + it("reports a giant single line", () => { + const giant = "x".repeat(200_000); + const p = filePayload(srcFile("a.tsx", `const a = 1;\nconst b = "${giant}";\n`)); + assert.ok(p.maxLine >= 200_000, "detects the giant line"); + assert.ok(p.bytes >= 200_000, "counts the bytes"); + }); + + it("a normal formatted file has a small max line", () => { + const p = filePayload(srcFile("a.tsx", "const a = 1;\nconst b = 2;\nexport default a;\n")); + assert.ok(p.maxLine < 40, "small max line"); + }); +}); + +describe("whitespaceLiterals", () => { + it("counts {\" \"} capture-whitespace literals", () => { + const text = `

Hi{" "}there{" "}world{' '}!

`; + assert.equal(whitespaceLiterals(text), 3); + }); + + it("does not flag meaningful expression literals", () => { + assert.equal(whitespaceLiterals(`

{name}{count}

`), 0); + }); +}); + +describe("subpixelArbitraries", () => { + it("flags non-integer px/rem arbitraries but not whole ones", () => { + // 713.938px (frozen) + 12.5rem (=200px, whole) → only the first counts. + const text = `
`; + assert.equal(subpixelArbitraries(text), 1); + }); +}); + +describe("customPropTokens", () => { + it("splits opaque --clr-N / hash tokens from named ones", () => { + const css = `:root{ --clr-7:#fff; --c12:#000; --a1b2c3:#111; --brand-primary:#f00; --space-4:1rem; }`; + const t = customPropTokens(css); + assert.equal(t.total, 5); + assert.equal(t.opaque, 3, "clr-7, c12, hash a1b2c3 are opaque; brand-primary/space-4 are named"); + }); +}); + +describe("probeArtifacts", () => { + it("flags off-screen capture-probe scaffolding", () => { + const text = `m`; + assert.ok(probeArtifacts(text) >= 2); + }); +}); + +describe("uncleanedListeners", () => { + it("counts addEventListener with no cleanup", () => { + const text = `el.addEventListener("scroll", fn);\nwin.addEventListener("resize", fn);`; + assert.equal(uncleanedListeners(text), 2); + }); + + it("does not flag when a matching removeEventListener / teardown exists", () => { + const text = `useEffect(() => { el.addEventListener("scroll", fn); return () => el.removeEventListener("scroll", fn); });`; + assert.equal(uncleanedListeners(text), 0); + }); +}); + +describe("tagHistogram", () => { + it("counts opening element tags by name", () => { + const h = tagHistogram(`
x
`); + assert.equal(h["div"], 2); + assert.equal(h["span"], 1); + assert.equal(h["button"], 1); + }); +}); + +describe("ariaHiddenFocusables", () => { + it("flags aria-hidden on a focusable element", () => { + const text = `y`; + assert.equal(ariaHiddenFocusables(text), 2); + }); + + it("ignores aria-hidden on a decorative div", () => { + assert.equal(ariaHiddenFocusables(`