Capture & emission fidelity wave: dotLottie support, scroll-state isolation, authored-geometry trust, honest quality scoring

Capture:
- Preserve real asset extensions (.lottie no longer truncated); extract the
  default animation JSON from dotLottie ZIP containers at materialization
  (minimal built-in ZIP reader, no new deps)
- Reset scroll + settle before every viewport snapshot so scroll-linked
  styles can't bake into captured computed values
- Marquee detection now requires content overflow (>1.35x), sustained
  velocity across separated windows, scroll independence (jiggle test), and
  genuine content repetition — kills scroll-settle-lerp false positives
- Sizing probe trusts authored explicit heights (px/vh/calc) over circular
  parent/child reflow verdicts, fixing dropped 100vh heroes

Normalize/generate:
- Canonicalize identity transforms to "none" per viewport; emit explicit
  transform resets across bands when any band has a real transform
- DittoLottie keeps the captured placeholder until DOMLoaded; failed loads
  no longer blank the container
- mx-auto only for true auto-margin centering; literal per-band margins are
  preserved (fixes full-bleed regressions)
- Spacing-scale snap tightened to 0.25px (3.5px stays p-[3.5px]); emit
  whitespace-nowrap for wrap-vulnerable single-line text leaves
- Grid recipes no longer override captured track counts or column spans;
  responsive re-flow plans apply only when geometry agrees

Quality scoring:
- Rewritten 6-dimension rubric (payload, decomposition, duplication,
  semantics, hygiene, runtime) with catastrophe caps and per-dimension
  reporting; thresholds are documented calibration guides

218 tests pass (74 new), typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-03 23:09:16 -07:00
co-authored by Claude Fable 5
parent 77be868ee3
commit d3ec154bae
19 changed files with 2345 additions and 386 deletions
+63 -4
View File
@@ -14,6 +14,7 @@ import {
import { discoverBreakpoints } from "./breakpoints.js"; import { discoverBreakpoints } from "./breakpoints.js";
import { writeJSON, writeJSONCompact, writeBytes, ensureDir } from "../util/fsx.js"; import { writeJSON, writeJSONCompact, writeBytes, ensureDir } from "../util/fsx.js";
import { sha1_12, round } from "../util/canonical.js"; import { sha1_12, round } from "../util/canonical.js";
import { isZipArchive, extractDotLottieJson } from "./dotlottie.js";
export const REQUIRED_VIEWPORTS = [375, 768, 1280, 1920] as const; 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 // 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; 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 { try {
const p = new URL(url).pathname; const p = new URL(url).pathname;
const dot = p.lastIndexOf("."); const dot = p.lastIndexOf(".");
if (dot >= 0 && dot > p.lastIndexOf("/")) { if (dot >= 0 && dot > p.lastIndexOf("/")) {
const ext = p.slice(dot + 1).toLowerCase().slice(0, 5); const ext = p.slice(dot + 1).toLowerCase();
if (/^[a-z0-9]+$/.test(ext)) return ext; if (ext.length <= MAX_EXT_LEN && /^[a-z0-9]+$/.test(ext)) return ext;
} }
} catch { /* ignore */ } } catch { /* ignore */ }
return ""; return "";
@@ -209,6 +216,41 @@ async function settle(page: import("playwright").Page, maxMs = 2500): Promise<vo
await page.waitForTimeout(250); await page.waitForTimeout(250);
} }
/**
* Stage 2 — scroll-state reset immediately before a per-viewport snapshot. The motion /
* dwell-scroll / carousel / element-screenshot passes above all leave the page scrolled or
* mid-transition; scroll-linked styles (Webflow scroll-state transforms, position:sticky
* offsets) then get baked into the captured computed styles for THAT viewport only, so a
* scroll-driven translateY leaks into one band and cascades. Reset window + inner scrollers
* to the top, wait for scroll-linked effects to settle across a few rAF ticks plus a short
* quiescence window, THEN let the caller snapshot. Bounded and deterministic.
*/
async function settleScrollTopBeforeSnapshot(page: import("playwright").Page): Promise<void> {
try {
await Promise.race([
page.evaluate(async () => {
const raf = () => new Promise<void>((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<void>((r) => setTimeout(r, 120));
resetAll();
await raf();
}),
new Promise<void>((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 }; 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; if (type === "video" && !looksLikeVideoFile(bytes)) return;
const a = assetMap.get(url) ?? recordAsset(url, type, null, null, "network"); const a = assetMap.get(url) ?? recordAsset(url, type, null, null, "network");
if (a.storedAs) return; if (a.storedAs) return;
const ext = extFromUrl(url) || extFromContentType(a.contentType) || // 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 === "css" ? "css" : type === "font" ? "woff2" : type === "svg" ? "svg" :
type === "video" ? "mp4" : type === "lottie" ? "json" : "png"); type === "video" ? "mp4" : type === "lottie" ? "json" : "png");
const name = `${sha1_12(url)}.${ext}`; const name = `${sha1_12(url)}.${ext}`;
@@ -973,6 +1026,12 @@ export async function captureSite(opts: {
const scrollAnimsCanceled = await neutralizeScrollTimelineAnimations(page); const scrollAnimsCanceled = await neutralizeScrollTimelineAnimations(page);
if (scrollAnimsCanceled) log({ event: "scroll_timeline_anims_canceled", viewport: vw, count: scrollAnimsCanceled }); 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 // Bound the in-page DOM walk: page.evaluate has no default timeout, so a
// pathologically large/animated DOM (e.g. asana.com) could hang forever. // pathologically large/animated DOM (e.g. asana.com) could hang forever.
const snapshot: PageSnapshot = await Promise.race([ const snapshot: PageSnapshot = await Promise.race([
+123
View File
@@ -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;
}
+190 -13
View File
@@ -60,6 +60,83 @@ export type MarqueeSpec = {
periodPx: number; // distance per seamless loop (one duplicated copy ≈ scrollWidth/2) 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 = { export type MotionCapture = {
waapi: WaapiAnim[]; waapi: WaapiAnim[];
rotators: RotatorSpec[]; rotators: RotatorSpec[];
@@ -132,6 +209,25 @@ export async function probeReveals(page: Page): Promise<void> {
* and record its signed velocity + seamless-loop period (one duplicated copy ≈ half the * 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 width) so the clone can replay the loop. Read-only beyond scrolling (restores
* scroll to top); does not touch the captured snapshot/IR. * 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<MarqueeSpec[]> { async function detectMarquees(page: Page): Promise<MarqueeSpec[]> {
try { try {
@@ -143,32 +239,113 @@ async function detectMarquees(page: Page): Promise<MarqueeSpec[]> {
while (p && depth < 6) { const ox = getComputedStyle(p).overflowX; if (ox === "hidden" || ox === "clip") return true; p = p.parentElement; depth++; } while (p && depth < 6) { const ox = getComputedStyle(p).overflowX; if (ox === "hidden" || ox === "clip") return true; p = p.parentElement; depth++; }
return false; 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<number[]> => {
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 // 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[] = []; const cand: Element[] = [];
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) { for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) {
if (Math.abs(txOf(el)) <= 0.5) continue; if (Math.abs(txOf(el)) <= 0.5) continue;
if (el.children.length < 2) continue; if (el.children.length < 2) continue;
if (!inClip(el)) 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); 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 out: Array<{ cap: string; axis: "x"; pxPerSec: number; periodPx: number }> = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const el of cand) { for (const el of cand) {
const cap = el.getAttribute("data-cid-cap"); if (!cap || seen.has(cap)) continue; const cap = el.getAttribute("data-cid-cap"); if (!cap || seen.has(cap)) continue;
try { el.scrollIntoView({ block: "center" }); } catch { /* ignore */ } try { el.scrollIntoView({ block: "center" }); } catch { /* ignore */ }
await sleep(450); // wake the off-screen-paused ticker + let the scroll settle await sleep(300); // 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); } // Discriminator 2: two velocity windows ≥1.5s apart. A scroll-settle lerp decays;
const dxs: number[] = []; for (let i = 1; i < xs.length; i++) dxs.push(xs[i]! - xs[i - 1]!); // a real marquee holds constant velocity.
if (dxs.length < 3) continue; const w1 = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
// velocity = median per-120ms delta (median ignores the single wrap-reset outlier). await sleep(INTER_WINDOW_MS);
const med = [...dxs].sort((a, b) => a - b)[Math.floor(dxs.length / 2)]!; const w2 = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
const pxPerSec = Math.round((med / 120) * 1000); if (w1.length < 3 || w2.length < 3) continue;
if (Math.abs(pxPerSec) < 4) continue; // not actually moving (e.g. a frozen scroll-scrub offset) // direction must be consistent within window 1, but for (at most) the one wrap step.
// direction must be consistent in all but (at most) the one wrap step. const med1 = [...w1].sort((a, b) => a - b)[Math.floor(w1.length / 2)]!;
if (dxs.filter((d) => Math.sign(d) === Math.sign(med)).length < dxs.length - 1) continue; 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<number> => {
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); const periodPx = Math.round(el.scrollWidth / 2);
if (periodPx < 40) continue; if (periodPx < 40) continue;
seen.add(cap); seen.add(cap);
+61 -2
View File
@@ -380,9 +380,22 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
const hf = el.getBoundingClientRect().height; const hf = el.getBoundingClientRect().height;
// Tight 0.5px: only call a dimension "reproduced" when auto/100% lands essentially exactly, // 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). // 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 = { 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, wAuto: Math.abs(wa - r.width) <= 0.5, wFill: Math.abs(wf - r.width) <= 0.5, hAuto,
hFill: Math.abs(hf - r.height) <= 0.5, hFill,
wMin: Math.round(wmin * 100) / 100, wMax: Math.round(wmax * 100) / 100, wMin: Math.round(wmin * 100) / 100, wMax: Math.round(wmax * 100) / 100,
}; };
} finally { } finally {
@@ -492,6 +505,14 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
const cssUrlSet = new Set<string>(); const cssUrlSet = new Set<string>();
const keyframes: string[] = []; const keyframes: string[] = [];
const cssVars: Record<string, string> = {}; const cssVars: Record<string, string> = {};
// 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 => { const absUrl = (u: string): string => {
try { return new URL(u, document.baseURI).href; } catch { return u; } 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 => { const readRules = (rules: CSSRuleList): void => {
for (const rule of Array.from(rules)) { for (const rule of Array.from(rules)) {
const type = rule.constructor.name; const type = rule.constructor.name;
@@ -532,6 +586,11 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
} else if (type === "CSSStyleRule") { } else if (type === "CSSStyleRule") {
const r = rule as CSSStyleRule; const r = rule as CSSStyleRule;
if (r.style && r.style.cssText.includes("url(")) harvestUrlsFromText(r.style.cssText); 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") { } else if (type === "CSSMediaRule" || type === "CSSSupportsRule") {
try { readRules((rule as CSSGroupingRule).cssRules); } catch { /* ignore */ } try { readRules((rule as CSSGroupingRule).cssRules); } catch { /* ignore */ }
} else if (type === "CSSImportRule") { } else if (type === "CSSImportRule") {
+69 -5
View File
@@ -1,7 +1,7 @@
import { join } from "node:path"; import { join } from "node:path";
import { rmSync } from "node:fs"; import { rmSync } from "node:fs";
import { writeText } from "../util/fsx.js"; 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 { isTextChild } from "../normalize/ir.js";
import { generateCss, RESET_CSS } from "./css.js"; import { generateCss, RESET_CSS } from "./css.js";
import { generateInteractionCss } from "./interactionCss.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<string>(); const recipeParents = new Set<string>();
const repeatedGridParents = new Set<string>(); const repeatedGridParents = new Set<string>();
const fillGridParents = new Set<string>(); const fillGridParents = new Set<string>();
// 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<string>();
const columnPlans = new Map<string, string[]>(); const columnPlans = new Map<string, string[]>();
const nodeByCid = new Map<string, IRNode>();
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 => { const gridColumnTokens = (c: RecipeReport["candidates"][number]): string[] | null => {
if (c.kind !== "card-grid" && c.kind !== "feature-grid" && c.kind !== "product-grid") return null; if (c.kind !== "card-grid" && c.kind !== "feature-grid" && c.kind !== "product-grid") return null;
const regimes = c.responsiveRegimes const regimes = c.responsiveRegimes
.filter((r) => (r.visibleItems ?? 0) > 0 && (r.columns ?? 0) > 0) .filter((r) => (r.visibleItems ?? 0) > 0 && (r.columns ?? 0) > 0)
.sort((a, b) => a.viewport - b.viewport); .sort((a, b) => a.viewport - b.viewport);
if (regimes.length < 2) return null; 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 prefixFor = (vp: number): string => vp >= 1536 ? "2xl:" : vp >= 1024 ? "lg:" : vp >= 768 ? "md:" : "";
const tokens: string[] = []; const tokens: string[] = [];
let last: number | undefined; let last: number | undefined;
@@ -2416,6 +2474,12 @@ function recipeResponsiveClassCleaner(recipes: RecipeReport | undefined, opts: {
for (const c of recipes?.candidates ?? []) { 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) { 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); 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" || 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); if (c.kind === "card-grid" || c.kind === "feature-grid" || c.kind === "product-grid") fillGridParents.add(c.itemParentCid);
const columns = gridColumnTokens(c); const columns = gridColumnTokens(c);
@@ -2429,8 +2493,8 @@ function recipeResponsiveClassCleaner(recipes: RecipeReport | undefined, opts: {
const columnPlan = opts.tailwind ? columnPlans.get(cid) : undefined; const columnPlan = opts.tailwind ? columnPlans.get(cid) : undefined;
const keep = tokens.filter((token) => { const keep = tokens.filter((token) => {
if (columnPlan && /^(?:[a-z0-9-]+:)*grid-cols-(?:\d+|\[[^\]]+\])$/.test(token)) return false; if (columnPlan && /^(?:[a-z0-9-]+:)*grid-cols-(?:\d+|\[[^\]]+\])$/.test(token)) return false;
if (/^(?:[a-z0-9-]+:)*grid-rows-\[(?:auto_?)+\]$/.test(token)) return false; if (uniformGridParents.has(cid) && /^(?:[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) && repeatedGridParents.has(cid) && /^(?:[a-z0-9-]+:)*grid-rows-\d+$/.test(token)) return false;
const initialCols = /^(?:(.*):)?grid-cols-\[initial\]$/.exec(token); const initialCols = /^(?:(.*):)?grid-cols-\[initial\]$/.exec(token);
if (initialCols) { if (initialCols) {
const prefix = initialCols[1] ? `${initialCols[1]}:` : ""; 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. // 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 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 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 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; const styleOf = tw ? (cid: string) => tw.styleOf.get(cid) : undefined;
// Section split (single-page humanized): plan section roots, render each into its own // Section split (single-page humanized): plan section roots, render each into its own
+82 -3
View File
@@ -2208,6 +2208,8 @@ function declsForViewport(
geometry: GeometryPlan = GEOMETRY_NONE, geometry: GeometryPlan = GEOMETRY_NONE,
dropGridRows = false, dropGridRows = false,
dropViewportMaxWidth = false, dropViewportMaxWidth = false,
nowrapText = false,
keepIdentityTransform = false,
): Map<string, string> { ): Map<string, string> {
const cs = node.computedByVp[vp]; const cs = node.computedByVp[vp];
const out = new Map<string, string>(); const out = new Map<string, string>();
@@ -2478,6 +2480,13 @@ function declsForViewport(
// list reset is none; emit whatever the source uses (incl. none). // list reset is none; emit whatever the source uses (incl. none).
} else if (prop === "minWidth") { } else if (prop === "minWidth") {
if (value === "auto" || (value === "0px" && !isFlexGridItem) || (value === "0px" && inScrollXFlexStrip)) continue; 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__") { } else if (def === "__never__") {
// always emit (display/color/fontFamily/fontSize handled below for inherit) // always emit (display/color/fontFamily/fontSize handled below for inherit)
} else if (isDefault(def, value)) { } else if (isDefault(def, value)) {
@@ -2506,6 +2515,15 @@ function declsForViewport(
out.set(kebab(prop), outValue); 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 // Centered max-width container: emit auto side margins so the browser centers
// it at every width. getComputedStyle sometimes reports 0 for resolved auto // it at every width. getComputedStyle sometimes reports 0 for resolved auto
// margins at wide viewports, so replaying px would left-align the clone. // 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 // 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 // (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. // 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; if (parentFlexGrid) return false;
// Signal 2: bbox-centered within the parent content box with ~0 reported margins. // 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 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; 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 { function hasPxMaxWidthCap(node: IRNode, viewports: number[]): boolean {
const caps: number[] = []; const caps: number[] = [];
for (const vp of viewports) { for (const vp of viewports) {
@@ -3106,6 +3170,21 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
: inferred.plan; : inferred.plan;
const centerAlways = inferred.centerAlways; const centerAlways = inferred.centerAlways;
const centeredAtAnySample = !!layoutParent && sampleVps.some((vp) => centeredAtVp(node, layoutParent, vp)); 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 = const stableCenter =
centerAlways || centerAlways ||
sourceMarginAutoIntent(node) || sourceMarginAutoIntent(node) ||
@@ -3175,7 +3254,7 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
dropInsets.delete("left"); dropInsets.delete("left");
} }
const animOwned = animOwnedProps(node, baseVp); // opacity/transform driven by an infinite animation 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: [] }; const nr: NodeRule = { base: baseDecls, bands: [] };
// Per-band overrides (delta vs base), using the parent's value AT THAT viewport. // 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<string, string>, includeN
} }
} }
const centeredVp = stableCenter || (layoutParent ? centeredAtVp(node, layoutParent, b.vp) : false); const centeredVp = stableCenter || (layoutParent ? centeredAtVp(node, layoutParent, b.vp) : false);
const vpDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[b.vp], b.vp, assetMap, centeredVp, colorVar, ir.doc.perViewport[b.vp]?.scrollHeight, widthPlan, gridColsByVp?.get(b.vp), gridRowsByVp?.get(b.vp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth), tokenResolver); const vpDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[b.vp], b.vp, assetMap, centeredVp, colorVar, ir.doc.perViewport[b.vp]?.scrollHeight, widthPlan, gridColsByVp?.get(b.vp), gridRowsByVp?.get(b.vp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth, nowrapText, keepIdentityTransform), tokenResolver);
const delta = new Map<string, string>(); const delta = new Map<string, string>();
for (const [k, v] of vpDecls) if (baseDecls.get(k) !== v) delta.set(k, v); 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)); for (const [k] of baseDecls) if (!vpDecls.has(k)) delta.set(k, resetValue(k));
+25 -4
View File
@@ -126,7 +126,7 @@ const byCid = (cid: string): HTMLElement | null => document.querySelector('[data
export default function DittoLottie({ spec }: { spec: LottieSpec }) { export default function DittoLottie({ spec }: { spec: LottieSpec }) {
useEffect(() => { useEffect(() => {
const stopped = (window as any).__dittoMotionStopped === true; 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; let cancelled = false;
void (async () => { void (async () => {
const lottie = (await import("lottie-web")).default; const lottie = (await import("lottie-web")).default;
@@ -134,17 +134,38 @@ export default function DittoLottie({ spec }: { spec: LottieSpec }) {
for (const it of spec.items) { for (const it of spec.items) {
const el = byCid(it.cid); const el = byCid(it.cid);
if (!el) continue; if (!el) continue;
// clear the captured placeholder frame so it never stacks with the live render
el.innerHTML = "";
try { 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({ const anim = lottie.loadAnimation({
container: el, container: mount,
renderer: it.renderer === "canvas" ? "canvas" : "svg", renderer: it.renderer === "canvas" ? "canvas" : "svg",
loop: it.loop, loop: it.loop,
autoplay: it.autoplay && !stopped, autoplay: it.autoplay && !stopped,
...(it.path ? { path: it.path } : { animationData: it.animationData as object }), ...(it.path ? { path: it.path } : { animationData: it.animationData as object }),
rendererSettings: { preserveAspectRatio: "xMidYMid meet" }, 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 {} } if (stopped) { try { anim.goToAndStop(0, true); } catch {} }
anims.push(anim); anims.push(anim);
} catch { } catch {
+9 -4
View File
@@ -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 // 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; // 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. // 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 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); const NAMED_K_SORTED = [...NAMED_K].sort((a, b) => a - b);
function nearestNamedK(k: number): number { 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; for (const c of NAMED_K_SORTED) if (Math.abs(c - k) < Math.abs(best - k)) best = c;
return best; 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); const m = /^(-?)([a-z][a-z-]*)-\[(-?\d*\.?\d+)(px|rem)\]$/.exec(base);
if (!m) return base; if (!m) return base;
const neg = m[1]!, prefix = m[2]!, px = parseFloat(m[3]!) * (m[4] === "rem" ? 16 : 1); 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]}`; if (Math.abs(r - px) <= SNAP_TYPE_PX && r in RADIUS_SUFFIX) return `${prefix}${RADIUS_SUFFIX[r]}`;
return base; 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)) { if (SCALE_PREFIXES.has(prefix)) {
const k = nearestNamedK(px / 4); 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; return base;
} }
+42
View File
@@ -188,6 +188,44 @@ function elementChildren(n: RawNode): RawNode[] {
return n.children.filter((c) => (c as { text?: string }).text === undefined) as 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<number, StyleMap>): 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). */ /** Full identity signature (tag + id + class). */
function sigFull(n: RawNode): string { function sigFull(n: RawNode): string {
return `${n.tag}#${n.attrs?.id ?? ""}.${(n.attrs?.class ?? "").trim()}`; 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.after) afterByVp[vw] = match.after;
if (match.placeholder) placeholderByVp[vw] = match.placeholder; 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 = { const node: IRNode = {
id: nextId(), id: nextId(),
+117 -118
View File
@@ -1,142 +1,141 @@
/** /**
* Code-quality audit count the "bad code" tells that make a generated clone read robotic, for any * Code-quality audit an honest, human-readable report over one or more generated app
* number of app source trees, side by side. Repeatable: point it at shipped deliverables and it * trees. Built directly on the dimension scorer in ./qualityScore, so the audit and the
* prints one column per tree. * shipped `code-quality.md` quality number never disagree.
* *
* npm run audit # auto: every output/<site>/app * npm run audit # auto: every runs/<site>/latest/generated/app
* npm run audit -- <dir> [<dir> ...] # explicit trees; .clone resolves to sibling app/ * npm run audit -- <appDir> [<appDir> ...] # explicit app trees
* npm run audit -- output/example/app * npm run audit -- runs/example/latest/generated/app
* npm run audit -- <dir> --json # machine-readable
* *
* Scans .tsx/.jsx/.ts/.css under each dir (skips node_modules/.next/out/dotfiles). Every metric is a * For each tree it prints:
* COUNT where lower is better, except the two "good" context rows (fluid width / standard scale). * the LETTER GRADE + numeric score (and any hard-cap reason),
* The decisive "decimal" tell is non-integer-PX: each arbitrary px/rem is converted to px and flagged * a per-DIMENSION table (payload / decomposition / duplication / semantics / hygiene
* if it isn't a whole pixel (a frozen measurement), so a clean 12.5rem (=200px) does NOT count. * / 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 { readdirSync, statSync, existsSync } from "node:fs";
import { join, resolve, basename, dirname, sep } from "node:path"; 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 } { /** Is `dir` a scannable app tree (has a src/ with code in it, or is itself full of code)? */
const tsxParts: string[] = [], cssParts: string[] = []; function isAppDir(dir: string): boolean {
const walk = (d: string): void => { if (!existsSync(dir)) return false;
for (const n of readdirSync(d)) { if (existsSync(join(dir, "src"))) return true;
if (n === "node_modules" || n === ".next" || n === "out" || n.startsWith(".")) continue; try { return readdirSync(dir).some((n) => /\.(tsx|jsx|ts|css|astro)$/.test(n)); } catch { return false; }
const p = join(d, n); }
const st = statSync(p);
if (st.isDirectory()) walk(p); /** Discover every runs/<site>/latest/generated/app deliverable, newest layout first.
else if (/\.(tsx|jsx|ts)$/.test(n)) tsxParts.push(readFileSync(p, "utf8")); * Checks both the cwd and its parent, so `npm run audit` works whether invoked from the
else if (/\.css$/.test(n)) cssParts.push(readFileSync(p, "utf8")); * 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); return out;
const tsx = tsxParts.join("\n"), css = cssParts.join("\n");
return { tsx, css, all: tsx + "\n" + css };
} }
/** All metric counts for one tree. */ // ---------------------------------------------------------------------------
function audit(target: Target): Record<string, number> { // Rendering
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;
// Arbitrary px/rem values + the non-integer-px ("decimal") subset, the decisive measurement tell. /** A readable column label for a target (its site/run name where possible). */
let arb = 0, decimal = 0; function labelFor(dir: string): string {
for (const m of all.matchAll(/\[(-?[0-9]+\.?[0-9]*)(px|rem)\]/g)) { const parts = resolve(dir).split("/");
arb++; const runsIdx = parts.lastIndexOf("runs");
const px = parseFloat(m[1]!) * (m[2] === "rem" ? 16 : 1); const outIdx = parts.lastIndexOf("output");
if (Math.abs(px - Math.round(px)) > 0.02) decimal++; 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);
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),
};
} }
function fileCount(dir: string): number { const pad = (s: string, w: number): string => s + " ".repeat(Math.max(0, w - s.length));
let c = 0; const padL = (s: string, w: number): string => " ".repeat(Math.max(0, w - s.length)) + s;
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); function renderReport(label: string, rep: QualityReport): string {
return c; 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 { function renderComparison(labels: string[], reps: QualityReport[]): string {
const d = resolve(input); const L: string[] = [];
const asApp = (appDir: string, validationDir?: string): Target => ({ inputDir: d, scanDir: appDir, validationDir }); const colW = Math.max(12, ...labels.map((l) => l.length + 2));
if (basename(d) === ".clone") { L.push("");
const app = join(dirname(d), "app"); L.push(`━━━ comparison ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
const validation = join(d, "generated", "app"); L.push(" " + pad("dimension", 15) + labels.map((l) => padL(l, colW)).join(""));
if (existsSync(join(app, "src"))) return asApp(app, existsSync(validation) ? validation : undefined); 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`; L.push(" " + pad("GRADE", 15) + reps.map((r) => padL(`${r.grade} (${r.total})`, colW)).join(""));
if (d.endsWith(generatedSuffix)) { return L.join("\n");
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 };
} }
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main(): void { function main(): void {
const args = process.argv.slice(2).filter((a) => !a.startsWith("--")); const args = process.argv.slice(2).filter((a) => !a.startsWith("--"));
let targets: Target[]; const asJson = process.argv.includes("--json");
if (args.length) targets = args.map(normalizeTarget); const targets = (args.length ? args.map((a) => resolve(a)) : discoverTargets()).filter(isAppDir);
else { if (!targets.length) { console.error("no app trees found — pass app dirs explicitly (e.g. runs/<site>/latest/generated/app)"); process.exit(1); }
// Auto: every output/<site>/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); }
// A readable column label: the deliverable dir's parent name, or the scanned dir name. const labels = targets.map(labelFor);
const label = (t: Target): string => (basename(dirname(t.scanDir)) || basename(t.scanDir)).slice(0, 16); const reports = targets.map((t) => scoreApp(t));
const labels = targets.map(label);
const audits = targets.map(audit);
audits.forEach((a, i) => { a["files (tsx+css)"] = fileCount(targets[i]!.scanDir); });
const keys = Object.keys(audits[0]!); if (asJson) {
const w0 = Math.max(...keys.map((k) => k.length)); console.log(JSON.stringify(reports.map((r, i) => ({ label: labels[i], ...r })), null, 2));
const colW = Math.max(11, ...labels.map((l) => l.length)); return;
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);
} }
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();
}
+493 -232
View File
@@ -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 * 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 * behaves* like the source. They say nothing about whether the generated CODE is
* good componentized, semantically named, styled through a reusable token/class * good small files, decomposed into real components, semantic and accessible,
* system, editable, and well organized. That "developer-facing quality" is exactly * free of capture artifacts, and safe at runtime. That "developer-facing quality"
* where a deterministic converter is judged once fidelity is a given. * is what a deterministic converter is judged on once fidelity is a given.
* *
* This module statically analyzes a generated app directory and scores it 0100 on * This module statically analyzes a generated app directory (source text only
* six categories. It reads only source text (`.tsx/.jsx/.ts/.astro/.css`). The * `.tsx/.jsx/.ts/.astro/.css`) and scores it 0100 across SIX dimensions, each
* metrics are chosen to be framework-agnostic: e.g. "style reuse" rewards shared * with visible subscores. The score is a weighted blend of the dimensions EXCEPT
* classes whether they're Tailwind utilities or semantic clone classes, and * that a single dimension in catastrophic territory (a multi-megabyte source file,
* penalizes per-node unique rules (`.c1{...} .c2{...}`) regardless of framework. * 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. * Pure + deterministic: same directory same score.
*/ */
import { readFileSync, readdirSync, statSync } from "node:fs"; import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, extname, basename, relative } from "node:path"; 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 <path> 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 <img> carry alt=
H1_REQUIRED: 1, // a page really should have exactly one <h1>
// ---- 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 // 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 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; 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 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"];
* pages/ dir, home.tsx, or an .astro route shell. Used for the dominance metric. */
function isEntryFile(rel: string): boolean { function isEntryFile(rel: string): boolean {
const b = basename(rel).toLowerCase(); const b = basename(rel).toLowerCase();
if (/^(page|index|home|app)\.[jt]sx$/.test(b)) return true; if (/^(page|index|home|app)\.[jt]sx$/.test(b)) return true;
@@ -66,54 +166,8 @@ function isEntryFile(rel: string): boolean {
return false; 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*$/; 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. */ export function componentNames(text: string): string[] {
function componentNames(text: string): string[] {
const names = new Set<string>(); const names = new Set<string>();
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
const re1 = /export\s+default\s+function\s+([A-Z]\w*)/g; const re1 = /export\s+default\s+function\s+([A-Z]\w*)/g;
@@ -125,221 +179,428 @@ function componentNames(text: string): string[] {
return [...names]; return [...names];
} }
/** Data/content field names declared in a content or data module. */ // ===========================================================================
function contentFieldNames(text: string): string[] { // METRIC EXTRACTORS (exported for unit tests — each is a pure text→number/obj)
const out: string[] = []; // ===========================================================================
// type Foo = { a: string; b?: number } — capture keys
const typeBlocks = text.match(/(?:type|interface)\s+\w+\s*=?\s*\{([^}]*)\}/g) ?? []; /** Bytes of embedded base64 blobs + HTML passed as string props/dangerous html. */
for (const blk of typeBlocks) { export function inlineBlobBytes(text: string): number {
const re = /(\w+)\s*\??\s*:/g; let m: RegExpExecArray | null; let bytes = 0;
while ((m = re.exec(blk)) !== null) out.push(m[1]!); // 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 ("<div ...</div>" 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(/</g)?.length ?? 0) > 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<string, number> {
const h: Record<string, number> = {};
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<string, number>();
// 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 <path d="…"> strings, and how many are exact repeats. */
export function svgPathDuplication(text: string): { total: number; repeats: number } {
const seen = new Map<string, number>();
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<string, number>();
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<string, number | string> }; export type CategoryScore = { score: number; max: number; metrics: Record<string, number | string> };
export type Offender = { file: string; metric: string; value: number };
export type QualityReport = { export type QualityReport = {
dir: string; dir: string;
total: number; total: number;
grade: LetterGrade;
caps: string[];
categories: Record<string, CategoryScore>; categories: Record<string, CategoryScore>;
offenders: Offender[];
raw: Record<string, number>; raw: Record<string, number>;
}; };
const clamp01 = (x: number): number => Math.max(0, Math.min(1, x)); /** Dimension weights (sum = 100). Guiding, not exact see CALIBRATION note.
/** Linear ramp: value v mapped from [lo,hi] → [0,1]. */ * Payload + decomposition carry the most weight because an unopenable, monolithic
const ramp = (v: number, lo: number, hi: number): number => clamp01((v - lo) / (hi - lo)); * file is the defect a reviewer notices first; hygiene/duplication are lighter
const r1 = (n: number): number => Math.round(n * 10) / 10; * 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 { export function scoreApp(root: string): QualityReport {
const files = collectFiles(root); 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 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 = markup.filter((f) =>
const componentModules = tsx.filter((f) =>
!CONFIG_NAME_RE.test(f.rel) && !ROOT_NAME_RE.test(f.rel) && countTags(f.text) > 0); !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 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 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 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 + const offenders: Offender[] = [];
// component names into words (splitting camelCase, kebab, snake, slashes) so both const caps: string[] = [];
// `hero-section.tsx` and `HeroSection` are credited.
const isSectionFile = (f: SrcFile): boolean => { // =========================================================================
const raw = f.rel + " " + componentNames(f.text).join(" "); // 1. PAYLOAD DISCIPLINE — no file/line/blob a human cannot open.
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)); 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 <path> 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; let total = 0;
const svgFiles = componentModules.filter((f) => /(^|\/)svgs?\//i.test(f.rel) || /icon/i.test(basename(f.rel)) || (countTags(f.text) > 0 && /^[^<]*<svg/m.test(f.text.replace(/import[^\n]*\n/g, "")))).length; for (const [k, sub] of Object.entries(dims)) total += WEIGHTS[k as keyof typeof WEIGHTS] * sub;
// ---- naming ---- // Catastrophe caps: any ONE unopenable payload drags the whole grade into D-range.
const allComponentNames = new Set<string>(); if (worstFileBytes >= K.CATASTROPHE_FILE_BYTES) caps.push(`file ${worstFileRel} is ${(worstFileBytes / 1e6).toFixed(1)}MB (>1MB)`);
for (const f of componentModules) for (const n of componentNames(f.text)) allComponentNames.add(n); if (worstLine >= K.CATASTROPHE_LINE_CHARS) caps.push(`line in ${worstLineRel} is ${(worstLine / 1000).toFixed(0)}KB (>100KB)`);
// exclude the page/Page entry symbol from the semantic judgement if (totalInlineBytes >= K.CATASTROPHE_INLINE_BYTES) caps.push(`${(totalInlineBytes / 1e6).toFixed(1)}MB of embedded base64/HTML (>5MB)`);
const judgedNames = [...allComponentNames].filter((n) => n !== "Page" && n !== "default" && n !== "RootLayout" && n !== "Layout"); if (caps.length) total = Math.min(total, K.CAP_GRADE_D);
const genericNames = judgedNames.filter((n) => GENERIC_COMP_RE.test(n)).length; else {
const compNameSemanticRatio = judgedNames.length ? 1 - genericNames / judgedNames.length : 0; // 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)`);
const allClassToks: string[] = []; if (worstLine >= K.WARN_LINE_CHARS) caps.push(`line in ${worstLineRel} is ${(worstLine / 1000).toFixed(0)}KB (>${K.WARN_LINE_CHARS / 1000}KB)`);
for (const f of tsx) allClassToks.push(...classTokens(f.text)); if (caps.length) total = Math.min(total, K.CAP_GRADE_C);
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;
} }
// Tailwind-style theme tokens count as token usage too (utility classes referencing roles) total = r1(Math.max(0, Math.min(100, total)));
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
// magic literals in component markup (raw hex/rgb/px in className/style strings) // Order offenders by severity so the report's "top offenders" is meaningful.
let magicLiterals = 0; offenders.sort((a, b) => severity(b) - severity(a));
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;
return { return {
dir: root, dir: root,
total: r1(total), total,
grade: toLetter(total),
caps,
categories: { categories: {
componentization: { score: r1(componentization), max: 25, metrics: { components: nonEntry.length, dominancePct: r1(dominance * 100), sectionComponents } }, payload: { score: r1(WEIGHTS.payload * payloadSub), max: WEIGHTS.payload, metrics: { maxFileKB: r1(worstFileBytes / 1000), maxLineKB: r1(worstLine / 1000), inlineBlobKB: r1(totalInlineBytes / 1000) } },
naming: { score: r1(naming), max: 25, metrics: { compNameSemanticPct: r1(compNameSemanticRatio * 100), classSemanticPct: r1(classSemanticRatio * 100), fieldSemanticPct: r1(fieldSemanticRatio * 100), genericNames } }, decomposition: { score: r1(WEIGHTS.decomposition * decompositionSub), max: WEIGHTS.decomposition, metrics: { components: nonEntry.length, dominancePct: r1(dominance * 100), bigSections } },
styling: { score: r1(styling), max: 20, metrics: { classReusePct: r1(classReuse * 100), tokenRefs, perNodeRules, magicLiterals, arbPx, arbPxPerNode: r1(arbPxPerNode), arbBands, stdBreakpoints, bandSharePct: r1(bandShare * 100) } }, duplication: { score: r1(WEIGHTS.duplication * duplicationSub), max: WEIGHTS.duplication, metrics: { helperDups, helperDupPct: r1(helperDupRatio * 100), svgPathRepeats: svgDup.repeats, nearDupPairs: nearDup } },
editability: { score: r1(editability), max: 15, metrics: { hasContentModule, fieldSemanticPct: r1(fieldSemanticRatio * 100), propsWithDefaults } }, semantics: { score: r1(WEIGHTS.semantics * semanticsSub), max: WEIGHTS.semantics, metrics: { h1: h1Count, divRatioPct: r1(divRatio * 100), semanticTags, altCoveragePct: r1(altCoverage * 100), ariaHiddenFocusables: ariaHidden } },
fileOrg: { score: r1(fileOrg), max: 10, metrics: { orgDirs, svgFiles, hasSectionsDir, hasComponentsDir } }, hygiene: { score: r1(WEIGHTS.hygiene * hygieneSub), max: WEIGHTS.hygiene, metrics: { wsLiterals, subpixelArbitraries: subpixel, opaqueTokenPct: r1(opaqueRatio * 100), probeArtifacts: probes } },
metadata: { score: r1(metadata), max: 5, metrics: { docHeaders, annotations, roboticComments, dataCidPerNode: r1(dataCidPerNode) } }, runtime: { score: r1(WEIGHTS.runtime * runtimeSub), max: WEIGHTS.runtime, metrics: { uncleanedListeners: leaks, undeclaredImports } },
}, },
offenders: offenders.slice(0, 15),
raw: { raw: {
files: files.length, componentModules: componentModules.length, totalTags, maxFileTags, 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 { function fmt(rep: QualityReport): string {
const lines: string[] = []; const lines: string[] = [];
lines.push(`\n ${rep.dir}`); 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)) { for (const [k, c] of Object.entries(rep.categories)) {
const metrics = Object.entries(c.metrics).map(([mk, mv]) => `${mk}=${mv}`).join(" "); 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"); return lines.join("\n");
} }
@@ -353,11 +614,11 @@ async function main(): Promise<void> {
for (const rep of reports) console.log(fmt(rep)); for (const rep of reports) console.log(fmt(rep));
if (reports.length > 1) { if (reports.length > 1) {
console.log("\n ── comparison ──"); 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)) { 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(""));
} }
} }
+194
View File
@@ -1,6 +1,7 @@
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import type { IR, IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js"; 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"; import { generateCss } from "../src/generate/css.js";
const VPS = [375, 1280]; 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}`); 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<number, XPerVp>, children: IRChild[] = []): IRNode {
const computedByVp: Record<number, StyleMap> = {};
const bboxByVp: Record<number, BBox> = {};
const visibleByVp: Record<number, boolean> = {};
const sizingByVp: Record<number, RawSizing> = {};
for (const vp of 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<id>{…}` 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<id>{…}` 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")}`);
});
});
+146
View File
@@ -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
});
});
+77
View File
@@ -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<number, StyleMap> = {
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");
});
});
+104
View File
@@ -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);
});
});
+256
View File
@@ -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 = "<div>" + "<span>x</span>".repeat(250) + "</div>";
const text = `<Frame html={${JSON.stringify(html)}} />`;
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 = `<p>Hi{" "}there{" "}world{' '}!</p>`;
assert.equal(whitespaceLiterals(text), 3);
});
it("does not flag meaningful expression literals", () => {
assert.equal(whitespaceLiterals(`<p>{name}{count}</p>`), 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 = `<div className="w-[713.938px] h-[12.5rem] p-[16px]" />`;
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 = `<span data-probe="1">m</span><i style={{clip: 'rect(0 0 0 0)'}} />`;
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(`<div><div/><span>x</span><button>b</button></div>`);
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 = `<button aria-hidden="true">x</button><a aria-hidden={true} href="#">y</a>`;
assert.equal(ariaHiddenFocusables(text), 2);
});
it("ignores aria-hidden on a decorative div", () => {
assert.equal(ariaHiddenFocusables(`<div aria-hidden="true" />`), 0);
});
});
describe("duplicateHelpers", () => {
it("counts copy-paste helper bodies (e.g. a repeated cn())", () => {
const body = `{ return classes.filter(Boolean).join(" ").replace(/\\s+/g, " ").trim(); }`;
const text = `function cn(...classes) ${body}\nfunction cx(...classes) ${body}\nfunction merge(...classes) ${body}`;
const d = duplicateHelpers(text);
assert.equal(d.defs, 3);
assert.equal(d.dups, 2, "two of the three are identical duplicates");
});
it("does not flag distinct helper bodies", () => {
const text = `function a(x) { return x + 1111111111; }\nfunction b(x) { return x - 2222222222; }`;
assert.equal(duplicateHelpers(text).dups, 0);
});
});
describe("svgPathDuplication", () => {
it("counts repeated inline <path d=...> strings", () => {
const d = "M10 10 L20 20 L30 30 Z aaaaaaaaaaaaaaaa";
const text = `<path d="${d}"/><path d="${d}"/><path d="M1 1 L2 2 different pathhhhhhhh"/>`;
const r = svgPathDuplication(text);
assert.equal(r.total, 3);
assert.equal(r.repeats, 1);
});
});
describe("nearDuplicateComponents", () => {
it("counts pairs sharing a tag signature", () => {
assert.equal(nearDuplicateComponents(["div:2,span:1", "div:2,span:1", "nav:1"]), 1);
assert.equal(nearDuplicateComponents(["a", "a", "a"]), 2);
});
});
describe("componentNames", () => {
it("extracts exported + declared component symbols", () => {
const text = `export default function HeroSection(){}\nexport function Footer(){}\nconst Navbar = () => {};`;
const names = componentNames(text);
assert.ok(names.includes("HeroSection"));
assert.ok(names.includes("Footer"));
assert.ok(names.includes("Navbar"));
});
});
describe("toLetter", () => {
it("maps scores to the expected grade bands", () => {
assert.equal(toLetter(95), "A");
assert.equal(toLetter(82), "B-");
assert.equal(toLetter(75), "C");
assert.equal(toLetter(67), "D+");
assert.equal(toLetter(59), "F");
});
});
// ---------------------------------------------------------------------------
// scoreApp — end-to-end on synthetic app trees (generic fixture content)
// ---------------------------------------------------------------------------
function makeTree(files: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), "qs-"));
for (const [rel, text] of Object.entries(files)) {
const p = join(root, rel);
mkdirSync(p.slice(0, p.lastIndexOf("/")), { recursive: true });
writeFileSync(p, text);
}
return root;
}
describe("scoreApp — hard cap on catastrophic payload", () => {
it("caps a tree containing a multi-megabyte source file into D-range", () => {
const giant = "x".repeat(1_200_000); // >1MB single file
const root = makeTree({
"src/app/page.tsx": `export default function Page(){ return <main><h1>Hi</h1><section><p>ok</p></section></main>; }`,
"src/app/svgs/blob.tsx": `export const blob = "${giant}";`,
});
try {
const rep = scoreApp(root);
assert.ok(rep.caps.length > 0, "a catastrophe cap is recorded");
assert.ok(rep.total <= 68, `grade is capped into D-range, got ${rep.total}`);
assert.ok(["D+", "D", "D-", "F"].includes(rep.grade), `grade ${rep.grade} is D-range or below`);
assert.ok(rep.categories.payload!.score < rep.categories.payload!.max * 0.5, "payload dimension collapses");
} finally { rmSync(root, { recursive: true, force: true }); }
});
});
describe("scoreApp — semantics", () => {
it("penalizes a page with no h1 vs one with a single h1", () => {
const withH1 = makeTree({
"src/app/page.tsx": `export default function Page(){ return <main><h1>Title</h1><section><p>a</p></section><nav><a href="#">x</a></nav></main>; }`,
});
const noH1 = makeTree({
"src/app/page.tsx": `export default function Page(){ return <div><div><div><div><span>a</span></div></div></div></div>; }`,
});
try {
const a = scoreApp(withH1);
const b = scoreApp(noH1);
assert.ok(a.categories.semantics!.score > b.categories.semantics!.score, "h1 + semantic tags score higher");
assert.equal(b.categories.semantics!.metrics.h1, 0);
} finally {
rmSync(withH1, { recursive: true, force: true });
rmSync(noH1, { recursive: true, force: true });
}
});
});
describe("scoreApp — decomposition", () => {
it("rates a decomposed tree above a single-file monolith of the same markup", () => {
const bodyTags = "<div><p>x</p><a href='#'>l</a></div>".repeat(40);
const monolith = makeTree({
"src/app/page.tsx": `export default function Page(){ return <main><h1>H</h1>${bodyTags}</main>; }`,
});
const decomposed = makeTree({
"src/app/page.tsx": `import Hero from "./sections/hero";\nimport Feature from "./sections/feature";\nexport default function Page(){ return <main><h1>H</h1><Hero/><Feature/></main>; }`,
"src/app/sections/hero.tsx": `export function Hero(){ return <section>${"<div><p>x</p></div>".repeat(20)}</section>; }`,
"src/app/sections/feature.tsx": `export function Feature(){ return <section>${"<div><a href='#'>l</a></div>".repeat(20)}</section>; }`,
});
try {
const m = scoreApp(monolith);
const d = scoreApp(decomposed);
assert.ok(d.categories.decomposition!.score > m.categories.decomposition!.score, "decomposed scores higher on decomposition");
assert.ok(d.total > m.total, "and grades higher overall");
} finally {
rmSync(monolith, { recursive: true, force: true });
rmSync(decomposed, { recursive: true, force: true });
}
});
});
+157
View File
@@ -0,0 +1,157 @@
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 { RecipeReport, RecipeCandidate, RecipeResponsiveRegime } from "../src/infer/recipes.js";
import { recipeResponsiveClassCleaner } from "../src/generate/app.js";
// The recipe pass infers a container's column count by grouping item bounding boxes into rows and
// taking the widest row. When an item spans multiple tracks that under-reports the real track count,
// so the synthesized column plan must NOT override the authored/computed grid geometry that the
// Tailwind emitter already baked into the className. These fixtures build a 3-track grid whose main
// card spans 2 columns (heuristic reports 2 columns) and assert the emitted classes keep 3 tracks
// and the span.
const VPS = [768, 1280];
function computed(over: StyleMap = {}): StyleMap {
return { display: "block", position: "static", visibility: "visible", whiteSpace: "normal", ...over };
}
function node(id: string, tag: string, byVp: Record<number, StyleMap>, children: IRChild[] = []): IRNode {
const computedByVp: Record<number, StyleMap> = {};
const bboxByVp: Record<number, BBox> = {};
const visibleByVp: Record<number, boolean> = {};
for (const vp of VPS) {
computedByVp[vp] = { ...computed(), ...(byVp[vp] ?? {}) };
bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 100 };
visibleByVp[vp] = true;
}
return { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
}
function irWith(root: IRNode): IR {
return {
doc: {
sourceUrl: "https://example.test/",
title: "Fixture",
lang: "en",
charset: "UTF-8",
metaViewport: "width=device-width, initial-scale=1",
viewports: VPS,
sampleViewports: VPS,
canonicalViewport: 1280,
perViewport: Object.fromEntries(VPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255,255,255)", bodyBg: "rgb(255,255,255)", bodyColor: "rgb(0,0,0)", bodyFont: "Arial" }])),
nodeCount: 4,
keyframes: [],
},
root,
};
}
function regime(viewport: number, columns: number, visibleItems: number): RecipeResponsiveRegime {
return { viewport, layout: "grid", rootBox: { x: 0, y: 0, width: viewport, height: 400 }, visibleItems, columns, rows: 1 };
}
function candidate(over: Partial<RecipeCandidate>): RecipeCandidate {
return {
id: "r0",
kind: "product-grid",
confidence: 0.89,
risk: "low",
rootCid: "n0",
rootTag: "section",
itemParentCid: "n1",
componentName: "ProductGridSection",
itemCount: 2,
repeatedItems: [],
responsiveRegimes: [regime(768, 2, 2), regime(1280, 2, 2)],
sourceHints: [],
signals: [],
emissionStatus: "report-only",
fallbackReason: "",
...over,
};
}
function report(candidates: RecipeCandidate[]): RecipeReport {
return {
version: 1,
sourceUrl: "https://example.test/",
canonicalViewport: 1280,
viewports: VPS,
sampledViewports: VPS,
summary: { totalCandidates: candidates.length, highConfidence: candidates.length, byKind: {}, templateReadyKinds: [] },
candidates,
};
}
// A 3-track grid: the main card spans 2 tracks (`grid-column: 1 / 3`) and a photo occupies track 3.
// The item-count heuristic groups the two items into one row → columns = 2, which disagrees with the
// computed 3 tracks.
function spanningGridIr(): IR {
const threeTracks = "284px 284px 284px";
const card = node("n2", "article", {
768: { gridColumnStart: "1", gridColumnEnd: "3" },
1280: { gridColumnStart: "1", gridColumnEnd: "3" },
});
const photo = node("n3", "img", {
768: { gridColumnStart: "3", gridColumnEnd: "4" },
1280: { gridColumnStart: "3", gridColumnEnd: "4" },
});
const parent = node("n1", "div", {
768: { display: "grid", gridTemplateColumns: threeTracks },
1280: { display: "grid", gridTemplateColumns: threeTracks },
}, [card, photo]);
return irWith(node("n0", "section", {}, [parent]));
}
describe("recipe grid geometry: computed tracks/spans are ground truth", () => {
it("does not override a 3-track grid with a heuristic 2-column plan (span-2 item present)", () => {
const ir = spanningGridIr();
const c = candidate({ itemParentCid: "n1", repeatedItems: [
{ cid: "n2", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 0, y: 0, width: 568, height: 300 } },
{ cid: "n3", tag: "img", textSample: "", mediaCount: 1, headingCount: 0, bbox: { x: 584, y: 0, width: 284, height: 300 } },
] });
const clean = recipeResponsiveClassCleaner(ir, report([c]), { tailwind: true });
// The Tailwind emitter has already put the authored 3-track grid on the container className.
const containerIn = "grid grid-cols-3 gap-4";
const containerOut = clean("n1", containerIn)!.split(/\s+/);
assert.ok(containerOut.includes("grid-cols-3"), "authored 3-track grid-cols-3 survives");
assert.ok(!containerOut.some((t) => /(?:^|:)grid-cols-2$/.test(t)), "no synthesized grid-cols-2 override");
// No responsive column-plan tokens are appended (the plan was rejected as untrustworthy).
assert.ok(!containerOut.some((t) => /^(?:md|lg|2xl):grid-cols-/.test(t)), "no responsive grid-cols plan appended");
// The span-2 item keeps its authored column span (emitter tokens pass through untouched).
const itemOut = clean("n2", "col-start-1 col-end-3 flex flex-col")!;
assert.equal(itemOut, "col-start-1 col-end-3 flex flex-col", "span-2 item className is unchanged");
});
it("still re-flows a genuinely uniform grid whose computed tracks match the heuristic", () => {
// 2-track grid at 768, 3-track at 1280, no spanning items → heuristic agrees with computed.
const item = (id: string): IRNode => node(id, "article", {
768: { gridColumnStart: "auto", gridColumnEnd: "auto" },
1280: { gridColumnStart: "auto", gridColumnEnd: "auto" },
});
const parent = node("n1", "div", {
768: { display: "grid", gridTemplateColumns: "300px 300px" },
1280: { display: "grid", gridTemplateColumns: "284px 284px 284px" },
}, [item("n2"), item("n3"), item("n4")]);
const ir = irWith(node("n0", "section", {}, [parent]));
const c = candidate({
itemParentCid: "n1",
itemCount: 3,
responsiveRegimes: [regime(768, 2, 3), regime(1280, 3, 3)],
repeatedItems: [
{ cid: "n2", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 0, y: 0, width: 300, height: 300 } },
{ cid: "n3", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 316, y: 0, width: 300, height: 300 } },
{ cid: "n4", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 0, y: 316, width: 300, height: 300 } },
],
});
const clean = recipeResponsiveClassCleaner(ir, report([c]), { tailwind: true });
const out = clean("n1", "grid grid-cols-2 gap-4")!.split(/\s+/);
// Geometry agreed → the responsive plan is applied: base 2 columns, lg:3 at the wider viewport.
assert.ok(out.includes("grid-cols-2"), "base column count applied");
assert.ok(out.includes("lg:grid-cols-3"), "responsive column bump applied");
});
});
+31 -1
View File
@@ -1,6 +1,6 @@
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { declToUtil } from "../src/generate/tailwind.js"; import { declToUtil, snapBase } from "../src/generate/tailwind.js";
// Zero-value gating. A named `-0` step only exists for props on Tailwind's spacing (or numeric) // Zero-value gating. A named `-0` step only exists for props on Tailwind's spacing (or numeric)
// scales; for the rest the class compiles to NOTHING — a silent no-op that ships the wrong style // scales; for the rest the class compiles to NOTHING — a silent no-op that ships the wrong style
@@ -41,3 +41,33 @@ describe("declToUtil zero values", () => {
assert.equal(declToUtil("letter-spacing", "-0.5px"), "tracking-[-0.5px]"); assert.equal(declToUtil("letter-spacing", "-0.5px"), "tracking-[-0.5px]");
}); });
}); });
// BUG B — the spacing-scale snap must only fire when a value lands ESSENTIALLY ON a step (≤0.25px).
// The scale is 2px-granular (`p-0.5`=2px, `p-1.5`=6px), so 3.5px is BETWEEN steps — snapping it up to
// p-1 (4px) adds +0.5px per side, which accumulates across a fixed-width flex row until items overflow
// and wrap. On-step values (2px→p-0.5, 4px→p-1) still snap.
describe("snapBase spacing-scale snapping", () => {
it("keeps a between-steps value arbitrary (3.5px does NOT snap to p-1)", () => {
assert.equal(snapBase("p-[3.5px]"), "p-[3.5px]");
});
it("does not snap the same sub-step delta on other spacing props", () => {
assert.equal(snapBase("pl-[3.5px]"), "pl-[3.5px]");
assert.equal(snapBase("gap-[3.5px]"), "gap-[3.5px]");
assert.equal(snapBase("mt-[13.5px]"), "mt-[13.5px]"); // between p-3 (12px) and p-3.5 (14px)
});
it("still snaps a value that sits on a 0.5-step (2px → p-0.5, 6px → p-1.5)", () => {
assert.equal(snapBase("p-[2px]"), "p-0.5");
assert.equal(snapBase("p-[6px]"), "p-1.5");
});
it("still snaps a near-exact on-step value within the tight budget (3.98px → p-1)", () => {
assert.equal(snapBase("p-[3.98px]"), "p-1");
});
it("leaves an already-clean scale utility unchanged and snaps 16px → mx-4", () => {
assert.equal(snapBase("p-1"), "p-1");
assert.equal(snapBase("mx-[16px]"), "mx-4");
});
});
+106
View File
@@ -211,3 +211,109 @@ describe("walker font-metric probe tagging (fix 4)", () => {
assert.ok(!sr.probe, "sr-only accessible text is not a probe"); assert.ok(!sr.probe, "sr-only accessible text is not a probe");
}); });
}); });
describe("walker sizing probe: circular authored-height guard", () => {
let browser: Browser;
let page: Page;
before(async () => {
browser = await chromium.launch();
page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
});
after(async () => {
await browser.close();
});
const capture = async (html: string) => {
await page.setContent(html);
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
return page.evaluate(collectPage);
};
it("keeps an explicit 100vh height (circular hero/fill-child pair)", async () => {
// A hero authored `height:100vh` with a `height:100%` fill child: setting the hero to
// `height:auto` still reproduces its box because the child pins it back, so the raw probe
// would read hAuto:true and the authored 100vh would be dropped — hero collapses to 0.
const snap = await capture(`
<style>
.hero { height: 100vh; display: flex; }
.fill { height: 100%; width: 100%; }
</style>
<section class="hero"><div class="fill"><p>content</p></div></section>`);
const hero = findByClass(snap.root, "hero")!;
assert.ok(hero.sizing, "hero was probed");
assert.equal(hero.sizing!.hAuto, false, "explicit 100vh is not content-sized");
assert.equal(hero.sizing!.hFill, false, "explicit 100vh is authored, not a parent fill");
// Box actually equals the viewport height (720), proving the circular reproduction.
assert.ok(Math.abs(hero.bbox.height - 720) <= 1, "hero rendered at 100vh");
});
it("keeps an explicit px height whose fill child reproduces it", async () => {
const snap = await capture(`
<style>
.section { height: 400px; display: flex; }
.fill { height: 100%; width: 100%; }
</style>
<div class="section"><div class="fill"><p>content</p></div></div>`);
const section = findByClass(snap.root, "section")!;
assert.ok(section.sizing, "section was probed");
assert.equal(section.sizing!.hAuto, false, "explicit 400px is not content-sized");
assert.equal(section.sizing!.hFill, false, "explicit 400px is authored, not a fill");
assert.ok(Math.abs(section.bbox.height - 400) <= 1, "section rendered at 400px");
});
it("keeps an explicit height authored via inline style", async () => {
const snap = await capture(`
<style>.fill { height: 100%; width: 100%; }</style>
<div class="box" style="height: 300px; display: flex;">
<div class="fill"><p>content</p></div>
</div>`);
const box = findByClass(snap.root, "box")!;
assert.ok(box.sizing, "box was probed");
assert.equal(box.sizing!.hAuto, false, "inline explicit height is kept");
assert.equal(box.sizing!.hFill, false, "inline explicit height is not a fill");
});
it("resolves the mutual parent/child pair without disturbing the fill child", async () => {
// The child is a GENUINE fill (height:100%) and must keep hFill:true / hAuto:false so the
// generator emits h-full for it; only the parent's circular verdict is corrected.
const snap = await capture(`
<style>
.outer { height: 500px; display: flex; }
.inner { height: 100%; width: 100%; }
</style>
<div class="outer"><div class="inner"><p>content</p></div></div>`);
const outer = findByClass(snap.root, "outer")!;
const inner = findByClass(snap.root, "inner")!;
assert.equal(outer.sizing!.hAuto, false, "parent explicit height kept");
assert.equal(outer.sizing!.hFill, false, "parent is authored, not a fill");
// The child authors only `height:100%` (a fill), so the explicit-height guard must NOT fire on
// it — hFill stays true so the generator can still emit h-full for the genuine fill child.
assert.equal(inner.sizing!.hFill, true, "child still fills the definite parent");
});
it("still detects a genuinely content-sized (auto) height as hAuto", async () => {
// No authored height anywhere: the box is content-sized and must stay droppable.
const snap = await capture(`
<style>.wrap { display: block; }</style>
<div class="wrap"><p>just some flowing text content</p></div>`);
const wrap = findByClass(snap.root, "wrap")!;
assert.ok(wrap.sizing, "wrap was probed");
assert.equal(wrap.sizing!.hAuto, true, "content-sized height is still auto");
});
it("does not treat a percentage or zero authored height as explicit", async () => {
// height:100% is the FILL case (handled by hFill), and height:0 is not definite; neither
// should trip the explicit-height override.
const snap = await capture(`
<style>
.pct-parent { height: 300px; }
.pct { height: 100%; }
</style>
<div class="pct-parent"><div class="pct"><p>x</p></div></div>`);
const pct = findByClass(snap.root, "pct")!;
assert.ok(pct.sizing, "pct was probed");
// A true fill child: hFill true, hAuto false — untouched by the explicit-height guard.
assert.equal(pct.sizing!.hFill, true, "percentage height stays a fill");
assert.equal(pct.sizing!.hAuto, false, "percentage fill is not content-sized");
});
});