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 { writeJSON, writeJSONCompact, writeBytes, ensureDir } from "../util/fsx.js";
import { sha1_12, round } from "../util/canonical.js";
import { isZipArchive, extractDotLottieJson } from "./dotlottie.js";
export const REQUIRED_VIEWPORTS = [375, 768, 1280, 1920] as const;
// The dense width set captured for SIZE INFERENCE: a node sampled at 9 widths reveals its sizing
@@ -115,13 +116,19 @@ export function isRetryableAssetFailure(type: string, status: number | null): bo
return status >= 500 || status === 429;
}
function extFromUrl(url: string): string {
// Bound on a preserved file extension. Real extensions are short, but a hard 5-char cap
// silently truncates legitimate ones (`.lottie` → `.lotti`, `.webmanifest` → `.webma`),
// which then mis-materializes the asset. Keep the guard generous enough for the longest
// real extensions and reject anything absurdly long (a dotted path segment, not an ext).
const MAX_EXT_LEN = 12;
export function extFromUrl(url: string): string {
try {
const p = new URL(url).pathname;
const dot = p.lastIndexOf(".");
if (dot >= 0 && dot > p.lastIndexOf("/")) {
const ext = p.slice(dot + 1).toLowerCase().slice(0, 5);
if (/^[a-z0-9]+$/.test(ext)) return ext;
const ext = p.slice(dot + 1).toLowerCase();
if (ext.length <= MAX_EXT_LEN && /^[a-z0-9]+$/.test(ext)) return ext;
}
} catch { /* ignore */ }
return "";
@@ -209,6 +216,41 @@ async function settle(page: import("playwright").Page, maxMs = 2500): Promise<vo
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 };
/**
@@ -579,7 +621,18 @@ export async function captureSite(opts: {
if (type === "video" && !looksLikeVideoFile(bytes)) return;
const a = assetMap.get(url) ?? recordAsset(url, type, null, null, "network");
if (a.storedAs) return;
const ext = extFromUrl(url) || extFromContentType(a.contentType) ||
// A `.lottie` (dotLottie) asset is a ZIP archive, not bare lottie-web JSON. lottie-web's
// `path:` loader does a JSON.parse and throws on the ZIP bytes, blanking the container. So
// unwrap it here: extract the default animation JSON and store THAT, materializing every
// lottie source as plain JSON regardless of the container it arrived in.
let extOverride: string | null = null;
if (type === "lottie" && isZipArchive(bytes)) {
const json = extractDotLottieJson(bytes);
if (!json) return; // unreadable dotLottie — leave unstored rather than ship a broken ZIP
bytes = json;
extOverride = "json";
}
const ext = extOverride || extFromUrl(url) || extFromContentType(a.contentType) ||
(type === "css" ? "css" : type === "font" ? "woff2" : type === "svg" ? "svg" :
type === "video" ? "mp4" : type === "lottie" ? "json" : "png");
const name = `${sha1_12(url)}.${ext}`;
@@ -973,6 +1026,12 @@ export async function captureSite(opts: {
const scrollAnimsCanceled = await neutralizeScrollTimelineAnimations(page);
if (scrollAnimsCanceled) log({ event: "scroll_timeline_anims_canceled", viewport: vw, count: scrollAnimsCanceled });
// Final scroll-state reset immediately before the walk: every preceding pass (dwell
// scroll, carousel settle, element screenshots) can leave the page scrolled, which bakes
// scroll-linked transforms/offsets into this viewport's computed styles. Scroll to top and
// let scroll-linked effects settle so the snapshot records the genuine at-rest values.
await settleScrollTopBeforeSnapshot(page);
// Bound the in-page DOM walk: page.evaluate has no default timeout, so a
// pathologically large/animated DOM (e.g. asana.com) could hang forever.
const snapshot: PageSnapshot = await Promise.race([
+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)
};
// ---- Pure marquee discriminators (extracted so the in-browser sampling logic is unit
// testable). These are duplicated verbatim inside `detectMarquees`' page.evaluate body —
// module-scope functions do NOT cross the serialization boundary — so any change here must
// be mirrored there (and vice versa). They are deterministic: no randomness, no clock reads.
/**
* Median signed velocity (px/s) from a series of per-sample translateX deltas taken at a
* fixed cadence. Median (not mean) ignores the single per-loop wrap-reset outlier, which
* is a large jump opposite the travel direction when the track seamlessly restarts.
*/
export function medianVelocityPxPerSec(deltas: number[], sampleMs: number): number {
if (!deltas.length || sampleMs <= 0) return 0;
const med = [...deltas].sort((a, b) => a - b)[Math.floor(deltas.length / 2)]!;
return Math.round((med / sampleMs) * 1000);
}
/**
* Discriminator 2 — sustained-constant-velocity test. A real marquee holds a steady
* velocity across time; a scroll-settle lerp (still easing toward its scroll-target after
* `scrollIntoView`) decays, so its velocity in a later observation window is a small
* fraction of the earlier one. Two windows of per-sample deltas separated by ≥1.5s; pass
* only if the later window's speed is BOTH non-trivial AND close to the earlier window's
* (within a relative tolerance) — i.e. it did not decay and did not reverse.
*
* @param window1Deltas per-sample translateX deltas from the first observation window
* @param window2Deltas per-sample translateX deltas from the second (later) window
* @param sampleMs cadence between samples within a window
* @param minPxPerSec minimum sustained |velocity| to be considered moving at all
* @param relTol fractional tolerance: |v2| must be ≥ (1-relTol)·|v1| and same sign
*/
export function classifyVelocitySamples(
window1Deltas: number[],
window2Deltas: number[],
sampleMs: number,
minPxPerSec = 4,
relTol = 0.5,
): { isMarquee: boolean; pxPerSec: number; v1: number; v2: number } {
const v1 = medianVelocityPxPerSec(window1Deltas, sampleMs);
const v2 = medianVelocityPxPerSec(window2Deltas, sampleMs);
// The reported velocity is the first window's (measured closest to a clean, post-settle
// marquee; also what the old code reported), kept for output determinism vs. the old path.
const pxPerSec = v1;
if (Math.abs(v1) < minPxPerSec || Math.abs(v2) < minPxPerSec) return { isMarquee: false, pxPerSec, v1, v2 };
if (Math.sign(v1) !== Math.sign(v2)) return { isMarquee: false, pxPerSec, v1, v2 }; // reversed → not a steady ticker
// v2 must NOT have decayed relative to v1 (a lerp settling toward target loses speed).
const sustained = Math.abs(v2) >= Math.abs(v1) * (1 - relTol);
return { isMarquee: sustained, pxPerSec, v1, v2 };
}
/**
* Discriminator 4 — genuine duplicated content. A marquee duplicates its content ≥2×
* (that is how a seamless loop works); "≥2 children" alone is far too weak (any flex row
* of distinct logos passes). Require actual repetition: ≥2 CONSECUTIVE children whose
* shape repeats — equal outerHTML hash (exact duplicate) or, as a looser structural
* fallback, an equal consecutive width sequence (some marquees clone then tweak attributes
* so hashes differ but the geometry repeats).
*
* @param hashes per-child outerHTML hash (cheap 32-bit), index-aligned with `widths`
* @param widths per-child rounded offsetWidth
*/
export function hasRepeatedChildren(hashes: number[], widths: number[]): boolean {
const n = Math.min(hashes.length, widths.length);
if (n < 2) return false;
// (a) two consecutive children with an identical hash — a literal cloned copy.
for (let i = 1; i < n; i++) if (hashes[i] === hashes[i - 1] && hashes[i] !== 0) return true;
// (b) structural fallback: the first half's width sequence repeats in the second half
// (content duplicated as a block, e.g. [A B C A B C]). Require an even count and a real
// width so a row of zero-width nodes can't spuriously "repeat".
if (n >= 4 && n % 2 === 0) {
const half = n / 2;
let repeats = true;
for (let i = 0; i < half; i++) { if (widths[i] === 0 || widths[i] !== widths[i + half]) { repeats = false; break; } }
if (repeats) return true;
}
return false;
}
export type MotionCapture = {
waapi: WaapiAnim[];
rotators: RotatorSpec[];
@@ -132,6 +209,25 @@ export async function probeReveals(page: Page): Promise<void> {
* and record its signed velocity + seamless-loop period (one duplicated copy ≈ half the
* scroll width) so the clone can replay the loop. Read-only beyond scrolling (restores
* scroll to top); does not touch the captured snapshot/IR.
*
* Discriminators (ALL must pass — a candidate is only classified as a marquee if every one
* holds), added to defeat scroll-LINKED easing false positives (a static logo row on a
* scroll-eased page is still lerping toward its scroll-target right after `scrollIntoView`,
* which reads as a constant velocity over a single short window):
* 1. Content overflow — scrollWidth > 1.35·clientWidth (a marquee has content to scroll;
* a static row that fits its box, scrollWidth ≈ clientWidth, cannot be a marquee).
* 2. Sustained constant velocity — two observation windows separated by ≥1.5s; a settle
* lerp decays (v2 ≪ v1), a real marquee holds v2 ≈ v1 (see classifyVelocitySamples).
* 3. Scroll independence (jiggle) — nudge scroll by ±50px and re-sample; if the velocity
* responds to the nudge the motion is scroll-linked, not a self-driven ticker.
* 4. Genuine duplication — ≥2 consecutive children repeat (equal outerHTML hash or an
* equal consecutive width sequence), the shape real marquees use for a seamless loop.
*
* Added latency is bounded: the expensive per-candidate windows/jiggle run ONLY for the
* few candidates that already pass the cheap synchronous gates (overflow + translated +
* clip + duplication), and the candidate list is capped at 8. Per surviving candidate the
* async cost is ≈ 300ms settle + 2×~600ms windows + ~1.4s inter-window gap + 2×~250ms
* jiggle ≈ 3.4s; with the cap the whole pass is bounded regardless of page size.
*/
async function detectMarquees(page: Page): Promise<MarqueeSpec[]> {
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++; }
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
// duplicated content (≥2 children) inside an overflow-clip viewport — the marquee shape.
// GENUINE duplicated content inside an overflow-clip viewport, AND real content
// overflow (scrollWidth > 1.35·clientWidth) — the marquee shape. All cheap/synchronous
// so the expensive async windows only run for the few survivors.
const cand: Element[] = [];
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) {
if (Math.abs(txOf(el)) <= 0.5) continue;
if (el.children.length < 2) continue;
if (!inClip(el)) continue;
// Discriminator 1: content overflow. scrollWidth must meaningfully exceed clientWidth;
// a static row that fits (scrollWidth ≈ clientWidth) has nothing to scroll.
if (el.scrollWidth <= el.clientWidth * 1.35) continue;
// Discriminator 4: genuine repetition, not merely ≥2 distinct children.
const sig = childSignature(el);
if (!hasRepeatedChildren(sig.hashes, sig.widths)) continue;
cand.push(el);
if (cand.length >= 16) break;
if (cand.length >= 8) break;
}
const out: Array<{ cap: string; axis: "x"; pxPerSec: number; periodPx: number }> = [];
const seen = new Set<string>();
for (const el of cand) {
const cap = el.getAttribute("data-cid-cap"); if (!cap || seen.has(cap)) continue;
try { el.scrollIntoView({ block: "center" }); } catch { /* ignore */ }
await sleep(450); // wake the off-screen-paused ticker + let the scroll settle
const xs: number[] = [];
for (let i = 0; i < 6; i++) { xs.push(txOf(el)); await sleep(120); }
const dxs: number[] = []; for (let i = 1; i < xs.length; i++) dxs.push(xs[i]! - xs[i - 1]!);
if (dxs.length < 3) continue;
// velocity = median per-120ms delta (median ignores the single wrap-reset outlier).
const med = [...dxs].sort((a, b) => a - b)[Math.floor(dxs.length / 2)]!;
const pxPerSec = Math.round((med / 120) * 1000);
if (Math.abs(pxPerSec) < 4) continue; // not actually moving (e.g. a frozen scroll-scrub offset)
// direction must be consistent in all but (at most) the one wrap step.
if (dxs.filter((d) => Math.sign(d) === Math.sign(med)).length < dxs.length - 1) continue;
await sleep(300); // wake the off-screen-paused ticker + let the scroll settle
// Discriminator 2: two velocity windows ≥1.5s apart. A scroll-settle lerp decays;
// a real marquee holds constant velocity.
const w1 = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
await sleep(INTER_WINDOW_MS);
const w2 = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
if (w1.length < 3 || w2.length < 3) continue;
// direction must be consistent within window 1, but for (at most) the one wrap step.
const med1 = [...w1].sort((a, b) => a - b)[Math.floor(w1.length / 2)]!;
if (w1.filter((d) => Math.sign(d) === Math.sign(med1)).length < w1.length - 1) continue;
const cls = classifyVelocitySamples(w1, w2, SAMPLE_MS);
if (!cls.isMarquee) continue;
const pxPerSec = cls.pxPerSec;
// Discriminator 3: scroll independence (jiggle). Nudge the scroll ±50px and re-sample
// velocity; a self-driven ticker is unaffected, a scroll-linked animation changes.
const baseY = window.scrollY;
const jiggle = async (dy: number): Promise<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);
if (periodPx < 40) continue;
seen.add(cap);
+61 -2
View File
@@ -380,9 +380,22 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
const hf = el.getBoundingClientRect().height;
// Tight 0.5px: only call a dimension "reproduced" when auto/100% lands essentially exactly,
// so a drop can't accumulate a visible shift across many elements (favours fidelity).
let hAuto = Math.abs(ha - r.height) <= 0.5;
let hFill = Math.abs(hf - r.height) <= 0.5;
// Circular-height guard: a box whose fill child (height:100%) pins it back up makes BOTH
// `height:auto` and `height:100%` reproduce the box, so the raw verdict reads hAuto (drop) —
// even though the height is authored (e.g. `100vh` on a hero, an explicit px section). Both
// sides then wait on each other and the box collapses. When this element's own cascade/inline
// style declares an explicit definite height, trust that declaration: the height is authored,
// so it is neither content-sized (hAuto) nor a parent fill (hFill). Only overrides when auto
// actually reproduced — a genuine explicit height that auto already shrinks stays hAuto:false.
if ((hAuto || hFill) && r.height > 2 && authorsExplicitHeight(el, sh)) {
hAuto = false;
hFill = false;
}
sizing = {
wAuto: Math.abs(wa - r.width) <= 0.5, wFill: Math.abs(wf - r.width) <= 0.5, hAuto: Math.abs(ha - r.height) <= 0.5,
hFill: Math.abs(hf - r.height) <= 0.5,
wAuto: Math.abs(wa - r.width) <= 0.5, wFill: Math.abs(wf - r.width) <= 0.5, hAuto,
hFill,
wMin: Math.round(wmin * 100) / 100, wMax: Math.round(wmax * 100) / 100,
};
} finally {
@@ -492,6 +505,14 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
const cssUrlSet = new Set<string>();
const keyframes: 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 => {
try { return new URL(u, document.baseURI).href; } catch { return u; }
@@ -507,6 +528,39 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
}
};
// True when a `height`/`min-height` value is an explicit, definite length the browser resolves
// to a fixed px box (px/rem/em/vh/vw/vmin/vmax/ch/…, or a calc/min/max/clamp over them). False for
// `auto`, an empty value, a pure percentage (resolves against the parent — that's the fill case the
// hFill probe already handles), `0`, and intrinsic keywords (fit-/min-/max-content). A definite
// authored height is load-bearing and must survive even when the reflow probe reads hAuto:true.
const isExplicitHeight = (raw: string): boolean => {
const v = (raw || "").trim().toLowerCase();
if (!v || v === "auto" || v === "0" || v === "0px" || v === "none") return false;
if (v === "fit-content" || v === "min-content" || v === "max-content" || v === "inherit" ||
v === "initial" || v === "unset" || v === "revert" || v === "revert-layer") return false;
// A bare percentage resolves against the parent (fill), not an authored definite length.
if (/^[\d.]+%$/.test(v)) return false;
// A definite length unit (or a calc()/min()/max()/clamp() that contains one) anchors the box.
return /(?:^|[\s(*/+-])[\d.]+(?:px|rem|em|vh|vw|vmin|vmax|svh|lvh|dvh|cm|mm|in|pt|pc|ex|ch|q)\b/.test(v);
};
// Does this element author an explicit definite height — via its own inline style (passed as
// `inlineHeight`, already read by the probe) or via any matched cascade rule harvested into
// `explicitHeightSelectors`? getComputedStyle resolves height to used px (so `100vh` reads as a
// plain number and is indistinguishable from a content height there); the specified value is only
// recoverable from the inline declaration and the cascade, which is why we consult both.
const authorsExplicitHeight = (el: Element, inlineHeight: string): boolean => {
if (isExplicitHeight(inlineHeight)) return true;
try {
const inlineMin = (el as HTMLElement).style?.getPropertyValue("min-height") || "";
if (isExplicitHeight(inlineMin)) return true;
} catch { /* ignore */ }
for (const sel of explicitHeightSelectors) {
try { if (el.matches(sel)) return true; } catch { /* invalid/unsupported selector */ }
}
return false;
};
const readRules = (rules: CSSRuleList): void => {
for (const rule of Array.from(rules)) {
const type = rule.constructor.name;
@@ -532,6 +586,11 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
} else if (type === "CSSStyleRule") {
const r = rule as CSSStyleRule;
if (r.style && r.style.cssText.includes("url(")) harvestUrlsFromText(r.style.cssText);
if (r.selectorText && r.style &&
(isExplicitHeight(r.style.getPropertyValue("height")) ||
isExplicitHeight(r.style.getPropertyValue("min-height")))) {
explicitHeightSelectors.push(r.selectorText);
}
} else if (type === "CSSMediaRule" || type === "CSSSupportsRule") {
try { readRules((rule as CSSGroupingRule).cssRules); } catch { /* ignore */ }
} else if (type === "CSSImportRule") {
+69 -5
View File
@@ -1,7 +1,7 @@
import { join } from "node:path";
import { rmSync } from "node:fs";
import { writeText } from "../util/fsx.js";
import type { IR, IRNode, IRChild, IRTextNode } from "../normalize/ir.js";
import type { IR, IRNode, IRChild, IRTextNode, StyleMap } from "../normalize/ir.js";
import { isTextChild } from "../normalize/ir.js";
import { generateCss, RESET_CSS } from "./css.js";
import { generateInteractionCss } from "./interactionCss.js";
@@ -2384,17 +2384,75 @@ ${input}
`;
}
function recipeResponsiveClassCleaner(recipes: RecipeReport | undefined, opts: { tailwind: boolean }): (cid: string, className: string | undefined) => string | undefined {
// Number of explicit column tracks in a computed `grid-template-columns` value. The capture
// resolves the property to a used track list (e.g. `284px 284px 284px`), so track count is the
// whitespace-separated token count; `none`/empty means the element is not a track grid.
function computedTrackCount(value: string | undefined): number {
if (!value || value === "none") return 0;
return value.trim().split(/\s+/).filter(Boolean).length;
}
// Track span of a grid item from its resolved `grid-column-start`/`grid-column-end`. A spanning
// item (`span 2`, or an explicit line pair `1 / 3`) means row-length item-counting under-reports
// the real track count, so the column-count heuristic is not trustworthy for this container.
function computedColumnSpan(cs: StyleMap | undefined): number {
if (!cs) return 1;
const end = cs["gridColumnEnd"];
const start = cs["gridColumnStart"];
const span = /^span\s+(\d+)$/.exec(end ?? "") ?? /^span\s+(\d+)$/.exec(start ?? "");
if (span) return Math.max(1, Number(span[1]));
if (start && end && /^-?\d+$/.test(start) && /^-?\d+$/.test(end)) {
const diff = Number(end) - Number(start);
if (Number.isFinite(diff) && diff > 1) return diff;
}
return 1;
}
export function recipeResponsiveClassCleaner(ir: IR, recipes: RecipeReport | undefined, opts: { tailwind: boolean }): (cid: string, className: string | undefined) => string | undefined {
const recipeParents = new Set<string>();
const repeatedGridParents = 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 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 => {
if (c.kind !== "card-grid" && c.kind !== "feature-grid" && c.kind !== "product-grid") return null;
const regimes = c.responsiveRegimes
.filter((r) => (r.visibleItems ?? 0) > 0 && (r.columns ?? 0) > 0)
.sort((a, b) => a.viewport - b.viewport);
if (regimes.length < 2) return null;
// The computed track geometry disagrees with the item-count heuristic (spanning items or a
// differing track count) — defer to the authored grid rather than synthesize a column plan.
if (!planAgreesWithComputed(c, regimes.map((r) => r.viewport))) return null;
const prefixFor = (vp: number): string => vp >= 1536 ? "2xl:" : vp >= 1024 ? "lg:" : vp >= 768 ? "md:" : "";
const tokens: string[] = [];
let last: number | undefined;
@@ -2416,6 +2474,12 @@ function recipeResponsiveClassCleaner(recipes: RecipeReport | undefined, opts: {
for (const c of recipes?.candidates ?? []) {
if ((c.kind === "card-grid" || c.kind === "feature-grid" || c.kind === "product-grid" || c.kind === "gallery-showcase" || c.kind === "logo-cloud") && c.confidence >= 0.86 && c.itemParentCid) {
recipeParents.add(c.itemParentCid);
// A container is a uniform repeated grid only if its computed track geometry agrees with the
// item-count heuristic at every sampled regime viewport (no spanning items, matching track
// count). Non-grid containers (flex/stack) have no computed tracks to disagree with, so they
// stay eligible; a track grid whose geometry disagrees is excluded and keeps its authored rows.
const sampledVps = c.responsiveRegimes.map((r) => r.viewport);
if (planAgreesWithComputed(c, sampledVps)) uniformGridParents.add(c.itemParentCid);
if (c.kind === "card-grid" || c.kind === "feature-grid" || c.kind === "product-grid" || c.kind === "gallery-showcase") repeatedGridParents.add(c.itemParentCid);
if (c.kind === "card-grid" || c.kind === "feature-grid" || c.kind === "product-grid") fillGridParents.add(c.itemParentCid);
const columns = gridColumnTokens(c);
@@ -2429,8 +2493,8 @@ function recipeResponsiveClassCleaner(recipes: RecipeReport | undefined, opts: {
const columnPlan = opts.tailwind ? columnPlans.get(cid) : undefined;
const keep = tokens.filter((token) => {
if (columnPlan && /^(?:[a-z0-9-]+:)*grid-cols-(?:\d+|\[[^\]]+\])$/.test(token)) return false;
if (/^(?:[a-z0-9-]+:)*grid-rows-\[(?:auto_?)+\]$/.test(token)) return false;
if (repeatedGridParents.has(cid) && /^(?:[a-z0-9-]+:)*grid-rows-\d+$/.test(token)) return false;
if (uniformGridParents.has(cid) && /^(?:[a-z0-9-]+:)*grid-rows-\[(?:auto_?)+\]$/.test(token)) return false;
if (uniformGridParents.has(cid) && repeatedGridParents.has(cid) && /^(?:[a-z0-9-]+:)*grid-rows-\d+$/.test(token)) return false;
const initialCols = /^(?:(.*):)?grid-cols-\[initial\]$/.exec(token);
if (initialCols) {
const prefix = initialCols[1] ? `${initialCols[1]}:` : "";
@@ -2477,7 +2541,7 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
// CSS mode: dedup into shared semantic CSS classes. Both fidelity-neutral.
const tw = humanize && mode === "tailwind" ? buildTailwind(ir, assetMap, input.colorVar, { interaction: input.interaction, reflow: input.reflow }) : undefined;
const classMap = humanize && mode === "css" ? buildClassMap(ir, assetMap, input.colorVar, input.primitives, input.tokenResolver) : undefined;
const cleanRecipeClass = recipeResponsiveClassCleaner(input.recipeReport, { tailwind: !!tw });
const cleanRecipeClass = recipeResponsiveClassCleaner(ir, input.recipeReport, { tailwind: !!tw });
const classOf = tw ? (cid: string) => cleanRecipeClass(cid, tw.classOf.get(cid)) : classMap ? (cid: string) => classMap.classOf.get(cid) : undefined;
const styleOf = tw ? (cid: string) => tw.styleOf.get(cid) : undefined;
// Section split (single-page humanized): plan section roots, render each into its own
+82 -3
View File
@@ -2208,6 +2208,8 @@ function declsForViewport(
geometry: GeometryPlan = GEOMETRY_NONE,
dropGridRows = false,
dropViewportMaxWidth = false,
nowrapText = false,
keepIdentityTransform = false,
): Map<string, string> {
const cs = node.computedByVp[vp];
const out = new Map<string, string>();
@@ -2478,6 +2480,13 @@ function declsForViewport(
// list reset is none; emit whatever the source uses (incl. none).
} else if (prop === "minWidth") {
if (value === "auto" || (value === "0px" && !isFlexGridItem) || (value === "0px" && inScrollXFlexStrip)) continue;
} else if (prop === "transform") {
// Emit the identity `transform:none` explicitly at a viewport whose value is `none` WHEN the node
// carries a non-identity transform at some OTHER band — otherwise the non-identity value cascades
// across bands and freezes at a width the source left untransformed. (Identity matrices are already
// canonicalized to the literal "none" upstream, so the string compare is reliable.) When the node
// is identity everywhere, this stays a normal default elision.
if (value === "none" && !keepIdentityTransform) continue;
} else if (def === "__never__") {
// always emit (display/color/fontFamily/fontSize handled below for inherit)
} else if (isDefault(def, value)) {
@@ -2506,6 +2515,15 @@ function declsForViewport(
out.set(kebab(prop), outValue);
}
// Wrap-vulnerable single-line text: the text renders on one line at every captured width and its
// unwrapped width nearly equals the container's available width, so a sub-pixel width shortfall in
// the clone (a column resolving ~0.6px narrower) tips it onto a second line, shifting everything
// below. `white-space:nowrap` holds it to one line — identical to the capture (already single-line
// at every width) but immune to the rounding shortfall. Only the source `whiteSpace` value that
// already collapses runs (`normal`/`nowrap`) is overridden; `pre`/`pre-wrap`/`break-spaces` preserve
// authored whitespace and are left untouched (the caller's detector also excludes them).
if (nowrapText) out.set("white-space", "nowrap");
// Centered max-width container: emit auto side margins so the browser centers
// it at every width. getComputedStyle sometimes reports 0 for resolved auto
// margins at wide viewports, so replaying px would left-align the clone.
@@ -2641,7 +2659,14 @@ function centeredAtVp(node: IRNode, parentNode: IRNode, vp: number): boolean {
// marquee). Converting fixed spacing to `auto` deletes it when the parent shrink-wraps the child
// (auto resolves to 0) — exactly how the testimonial inter-card gap vanished. Literal margins
// reproduce that spacing at every width regardless of the parent's resolved width.
if (ml > 0.5 && Math.abs(ml - mr) < 1 && nb.width < pb.width - 4 && marginsVaryAcrossVps(node)) return true;
// The width must ALSO be genuinely constrained: a box the sizing probe reads as a container-FILL
// (`width:100%` reproduces it) has no free space for auto margins to absorb — the emitted width
// is set to 100% (fill/fillcap), so `margin:auto` resolves to 0 and silently deletes real literal
// px side margins, blowing a padded pill/section out to full-bleed. Equal literal margins are the
// geometric TWIN of centering slack (both split the free space symmetrically), so this probe read
// is the only reliable discriminator between them; when the box fills, keep the literal margins.
const fillsAtVp = node.sizingByVp?.[vp]?.wFill === true;
if (ml > 0.5 && Math.abs(ml - mr) < 1 && nb.width < pb.width - 4 && marginsVaryAcrossVps(node) && !fillsAtVp) return true;
if (parentFlexGrid) return false;
// Signal 2: bbox-centered within the parent content box with ~0 reported margins.
if (Math.abs(ml) > 0.5 || Math.abs(mr) > 0.5) return false; // margins already explain position
@@ -2654,6 +2679,45 @@ function centeredAtVp(node: IRNode, parentNode: IRNode, vp: number): boolean {
return leftGap > 1 && rightGap > 1 && Math.abs(leftGap - rightGap) < 1.5 && nb.width < contentRight - contentLeft - 2;
}
/** A single-line text leaf whose unwrapped width nearly fills its container's available width at
* EVERY captured viewport where it's painted so any sub-pixel width shortfall in the clone (a
* column resolving fractionally narrower) tips it onto a second line. Such a node earns an explicit
* `white-space:nowrap` (see declsForViewport) to stay one line as it did in the capture.
* Conservative by construction every guard must hold at every painted viewport:
* text leaf (direct text, no element children), whitespace not already preserved (`pre*`);
* single line: box height one line box (line-height + vertical padding/border), so a genuinely
* wrapping paragraph (2 line boxes tall) is excluded;
* wrap-vulnerable: max-content (unwrapped) width available container width 2 and it + 1
* the text needs essentially all the available width, with no slack for a rounding shortfall;
* genuinely wrappable: min-content < max-content 2, so a single unbreakable token (which can
* never wrap, making nowrap a redundant no-op) is skipped to keep the emission minimal.
* Relies on the sizing probe's wMin/wMax (present only for in-flow probed leaves); absent no emit. */
function nowrapWrapVulnerable(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (!parentNode) return false;
if (hasElementChild(node)) return false;
if (!node.children.some((c) => isTextChild(c) && c.text.trim() !== "")) return false;
if (REPLACED.has(node.tag) || node.tag === "canvas" || node.tag.includes("-")) return false;
let painted = 0;
for (const vp of viewports) {
const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp];
if (!cs || !nb || !node.visibleByVp[vp] || (cs.display || "") === "none") continue;
if (!(nb.width > 0) || !(nb.height > 0)) continue;
const ws = cs.whiteSpace || "normal";
if (ws !== "normal" && ws !== "nowrap") return false; // pre/pre-wrap/break-spaces preserve authored whitespace
const sz = node.sizingByVp?.[vp];
const wMax = sz?.wMax; const wMin = sz?.wMin;
if (wMax == null || wMin == null) return false;
const lineBox = pf(cs.lineHeight) + pf(cs.paddingTop) + pf(cs.paddingBottom) + pf(cs.borderTopWidth) + pf(cs.borderBottomWidth);
if (!(lineBox > 0) || Math.abs(nb.height - lineBox) > 2.5) return false; // not single-line
const avail = containingWidthAt(node, parentNode, vp);
if (avail == null) return false;
if (!(wMax >= avail - 2 && wMax <= avail + 1)) return false; // not right at the container edge
if (!(wMin < wMax - 2)) return false; // single unbreakable token — can't wrap
painted++;
}
return painted >= 1;
}
function hasPxMaxWidthCap(node: IRNode, viewports: number[]): boolean {
const caps: number[] = [];
for (const vp of viewports) {
@@ -3106,6 +3170,21 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
: inferred.plan;
const centerAlways = inferred.centerAlways;
const centeredAtAnySample = !!layoutParent && sampleVps.some((vp) => centeredAtVp(node, layoutParent, vp));
// A single-line text leaf sitting flush against its container's available width at every painted
// width → `white-space:nowrap` so a sub-pixel column shortfall in the clone can't wrap it. Decided
// once per node (uniform across bands); measured against layoutParent to see through contents wrappers.
const nowrapText = !isContents && nowrapWrapVulnerable(node, layoutParent, sampleVps);
// If this node has a NON-identity transform at any painted band, the identity `none` must be
// emitted at the bands that have it so the transform can't cascade across bands (freezing a
// width the source left untransformed). Identity matrices are canonicalized to "none" upstream.
let hasNonIdentityTransform = false, hasIdentityTransform = false;
for (const vp of sampleVps) {
const t = node.computedByVp[vp]?.transform;
if (t === undefined) continue;
if (t === "none") hasIdentityTransform = true;
else hasNonIdentityTransform = true;
}
const keepIdentityTransform = hasNonIdentityTransform && hasIdentityTransform;
const stableCenter =
centerAlways ||
sourceMarginAutoIntent(node) ||
@@ -3175,7 +3254,7 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
dropInsets.delete("left");
}
const animOwned = animOwnedProps(node, baseVp); // opacity/transform driven by an infinite animation
const baseDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[baseVp], baseVp, assetMap, centeredBase, colorVar, ir.doc.perViewport[baseVp]?.scrollHeight, widthPlan, gridColsByVp?.get(baseVp), gridRowsByVp?.get(baseVp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth), tokenResolver);
const baseDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[baseVp], baseVp, assetMap, centeredBase, colorVar, ir.doc.perViewport[baseVp]?.scrollHeight, widthPlan, gridColsByVp?.get(baseVp), gridRowsByVp?.get(baseVp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth, nowrapText, keepIdentityTransform), tokenResolver);
const nr: NodeRule = { base: baseDecls, bands: [] };
// Per-band overrides (delta vs base), using the parent's value AT THAT viewport.
@@ -3247,7 +3326,7 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
}
}
const centeredVp = stableCenter || (layoutParent ? centeredAtVp(node, layoutParent, b.vp) : false);
const vpDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[b.vp], b.vp, assetMap, centeredVp, colorVar, ir.doc.perViewport[b.vp]?.scrollHeight, widthPlan, gridColsByVp?.get(b.vp), gridRowsByVp?.get(b.vp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth), tokenResolver);
const vpDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[b.vp], b.vp, assetMap, centeredVp, colorVar, ir.doc.perViewport[b.vp]?.scrollHeight, widthPlan, gridColsByVp?.get(b.vp), gridRowsByVp?.get(b.vp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth, nowrapText, keepIdentityTransform), tokenResolver);
const delta = new Map<string, string>();
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));
+25 -4
View File
@@ -126,7 +126,7 @@ const byCid = (cid: string): HTMLElement | null => document.querySelector('[data
export default function DittoLottie({ spec }: { spec: LottieSpec }) {
useEffect(() => {
const stopped = (window as any).__dittoMotionStopped === true;
const anims: Array<{ destroy: () => void; goToAndStop: (value: number, isFrame?: boolean) => void }> = [];
const anims: Array<{ destroy: () => void; goToAndStop: (value: number, isFrame?: boolean) => void; addEventListener: (name: string, cb: () => void) => void }> = [];
let cancelled = false;
void (async () => {
const lottie = (await import("lottie-web")).default;
@@ -134,17 +134,38 @@ export default function DittoLottie({ spec }: { spec: LottieSpec }) {
for (const it of spec.items) {
const el = byCid(it.cid);
if (!el) continue;
// clear the captured placeholder frame so it never stacks with the live render
el.innerHTML = "";
try {
// Mount the live animation into an OVERLAY child, keeping the captured placeholder
// frame in the DOM until lottie signals a successful load — a failed load (bad JSON,
// network) then leaves the placeholder intact instead of erasing the container to blank.
const mount = document.createElement("div");
mount.style.position = "absolute";
mount.style.inset = "0";
mount.style.opacity = "0";
const cs = getComputedStyle(el);
if (cs.position === "static") el.style.position = "relative";
el.appendChild(mount);
const anim = lottie.loadAnimation({
container: el,
container: mount,
renderer: it.renderer === "canvas" ? "canvas" : "svg",
loop: it.loop,
autoplay: it.autoplay && !stopped,
...(it.path ? { path: it.path } : { animationData: it.animationData as object }),
rendererSettings: { preserveAspectRatio: "xMidYMid meet" },
});
// Swap only once the animation is genuinely ready: reveal the live render and remove
// every original child (the placeholder) so the two never stack.
const reveal = () => {
mount.style.opacity = "1";
for (const child of Array.from(el.childNodes)) if (child !== mount) el.removeChild(child);
mount.style.position = "";
mount.style.inset = "";
};
// DOMLoaded fires only after the JSON parsed and the first frame rendered — the
// right moment to reveal the live render and drop the placeholder. data_failed
// (bad JSON / fetch error) tears the empty mount back out, leaving the placeholder.
anim.addEventListener("DOMLoaded", reveal);
anim.addEventListener("data_failed", () => { try { el.removeChild(mount); } catch {} });
if (stopped) { try { anim.goToAndStop(0, true); } catch {} }
anims.push(anim);
} catch {
+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
// the markup reads hand-authored. Bounded ε + the layout & perceptual gates backstop any accumulation;
// values not near a step stay arbitrary because they are genuine one-offs.
const SNAP_SPACE_PX = 1.0; // padding/margin/gap/inset/size → nearest 0.25rem scale step within this
const SNAP_TYPE_PX = 0.6; // font-size/line-height/radius → nearest integer px within this
const NAMED_K_SORTED = [...NAMED_K].sort((a, b) => a - b);
function nearestNamedK(k: number): number {
@@ -571,7 +570,7 @@ function nearestNamedK(k: number): number {
for (const c of NAMED_K_SORTED) if (Math.abs(c - k) < Math.abs(best - k)) best = c;
return best;
}
function snapBase(base: string): string {
export function snapBase(base: string): string {
const m = /^(-?)([a-z][a-z-]*)-\[(-?\d*\.?\d+)(px|rem)\]$/.exec(base);
if (!m) return base;
const neg = m[1]!, prefix = m[2]!, px = parseFloat(m[3]!) * (m[4] === "rem" ? 16 : 1);
@@ -587,10 +586,16 @@ function snapBase(base: string): string {
if (Math.abs(r - px) <= SNAP_TYPE_PX && r in RADIUS_SUFFIX) return `${prefix}${RADIUS_SUFFIX[r]}`;
return base;
}
// spacing / size → nearest named scale step (0.25rem grid)
// spacing / size → nearest named scale step (0.25rem grid). Only snap a value that lands ESSENTIALLY
// ON a step (≤0.25px): the scale is 2px-granular (`p-0.5`=2px, `p-1.5`=6px), so a captured value ≥0.25px
// off the nearest step is a genuine sub-step measurement (3.5px is BETWEEN 2px and 4px — not on the
// scale). Snapping such a value up by +0.5px per side accumulates across the links of a fixed-width
// flex row until the items overflow and wrap to a second line; the exact `p-[3.5px]` cannot. The wider
// ±1px budget the gate tolerates in isolation is unsafe here because the error is systematic, not
// random. Values genuinely on a step (2px→`p-0.5`, 4px→`p-1`) still snap.
if (SCALE_PREFIXES.has(prefix)) {
const k = nearestNamedK(px / 4);
if (Math.abs(k * 4 - px) <= SNAP_SPACE_PX) return k === 0 ? `${neg}${prefix}-0` : `${neg}${prefix}-${k}`;
if (Math.abs(k * 4 - px) <= 0.25) return k === 0 ? `${neg}${prefix}-0` : `${neg}${prefix}-${k}`;
}
return base;
}
+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[];
}
/** 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). */
function sigFull(n: RawNode): string {
return `${n.tag}#${n.attrs?.id ?? ""}.${(n.attrs?.class ?? "").trim()}`;
@@ -328,6 +366,10 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
if (match.after) afterByVp[vw] = match.after;
if (match.placeholder) placeholderByVp[vw] = match.placeholder;
}
// Canonicalize identity transforms (none / identity matrix) to `none` at every viewport so
// the generator's per-band delta treats them uniformly and a scroll/composite-noise transform
// at one width can't leak across bands.
canonicalizeTransforms(computedByVp);
const node: IRNode = {
id: nextId(),
+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
* number of app source trees, side by side. Repeatable: point it at shipped deliverables and it
* prints one column per tree.
* Code-quality audit an honest, human-readable report over one or more generated app
* trees. Built directly on the dimension scorer in ./qualityScore, so the audit and the
* shipped `code-quality.md` quality number never disagree.
*
* npm run audit # auto: every output/<site>/app
* npm run audit -- <dir> [<dir> ...] # explicit trees; .clone resolves to sibling app/
* npm run audit -- output/example/app
* npm run audit # auto: every runs/<site>/latest/generated/app
* npm run audit -- <appDir> [<appDir> ...] # explicit app trees
* 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
* COUNT where lower is better, except the two "good" context rows (fluid width / standard scale).
* The decisive "decimal" tell is non-integer-PX: each arbitrary px/rem is converted to px and flagged
* if it isn't a whole pixel (a frozen measurement), so a clean 12.5rem (=200px) does NOT count.
* For each tree it prints:
* the LETTER GRADE + numeric score (and any hard-cap reason),
* a per-DIMENSION table (payload / decomposition / duplication / semantics / hygiene
* / runtime) with the visible sub-metrics behind each score,
* the TOP OFFENDERS (file, metric, value) the worst individual tells.
* When several trees are passed it also prints a side-by-side grade comparison.
*
* Pure static analysis over generated source (.tsx/.jsx/.ts/.astro/.css). No builds, no
* browser. See qualityScore.ts for the rubric + the (calibration-guide) thresholds.
*/
import { readdirSync, statSync, readFileSync, existsSync } from "node:fs";
import { join, resolve, basename, dirname, sep } from "node:path";
import { readdirSync, statSync, existsSync } from "node:fs";
import { join, resolve, basename } from "node:path";
import { scoreApp, type QualityReport } from "./qualityScore.js";
type Row = { label: string; lowerBetter: boolean; values: number[] };
type Target = { inputDir: string; scanDir: string; validationDir?: string };
// ---------------------------------------------------------------------------
// Target resolution — accept an app dir directly, or discover deliverables.
// ---------------------------------------------------------------------------
function readTree(dir: string): { tsx: string; css: string; all: string } {
const tsxParts: string[] = [], cssParts: string[] = [];
const walk = (d: string): void => {
for (const n of readdirSync(d)) {
if (n === "node_modules" || n === ".next" || n === "out" || n.startsWith(".")) continue;
const p = join(d, n);
const st = statSync(p);
if (st.isDirectory()) walk(p);
else if (/\.(tsx|jsx|ts)$/.test(n)) tsxParts.push(readFileSync(p, "utf8"));
else if (/\.css$/.test(n)) cssParts.push(readFileSync(p, "utf8"));
/** Is `dir` a scannable app tree (has a src/ with code in it, or is itself full of code)? */
function isAppDir(dir: string): boolean {
if (!existsSync(dir)) return false;
if (existsSync(join(dir, "src"))) return true;
try { return readdirSync(dir).some((n) => /\.(tsx|jsx|ts|css|astro)$/.test(n)); } catch { return false; }
}
/** Discover every runs/<site>/latest/generated/app deliverable, newest layout first.
* Checks both the cwd and its parent, so `npm run audit` works whether invoked from the
* repo root or from compiler/ (where runs/ lives one level up). */
function discoverTargets(): string[] {
const out: string[] = [];
const roots = ["runs", "output", "../runs", "../output"].map((r) => resolve(r)).filter(existsSync);
for (const root of roots) {
let sites: string[];
try { sites = readdirSync(root); } catch { continue; }
for (const site of sites) {
for (const app of [
join(root, site, "latest", "generated", "app"),
join(root, site, "app"),
]) {
if (isAppDir(app)) { out.push(app); break; }
}
}
};
walk(dir);
const tsx = tsxParts.join("\n"), css = cssParts.join("\n");
return { tsx, css, all: tsx + "\n" + css };
}
return out;
}
/** All metric counts for one tree. */
function audit(target: Target): Record<string, number> {
const dir = target.scanDir;
const { tsx, css, all } = readTree(dir);
const n = (re: RegExp, s = all): number => (s.match(re) || []).length;
const validationDataCid = target.validationDir && existsSync(target.validationDir)
? (readTree(target.validationDir).all.match(/data-cid/g) || []).length
: 0;
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
// Arbitrary px/rem values + the non-integer-px ("decimal") subset, the decisive measurement tell.
let arb = 0, decimal = 0;
for (const m of all.matchAll(/\[(-?[0-9]+\.?[0-9]*)(px|rem)\]/g)) {
arb++;
const px = parseFloat(m[1]!) * (m[2] === "rem" ? 16 : 1);
if (Math.abs(px - Math.round(px)) > 0.02) decimal++;
}
return {
"files (tsx+css)": (tsx ? 1 : 0), // overwritten below with real file counts
"── BAD (lower=better) ──": -1,
"fixed width w-[Npx/rem]": n(/\bw-\[-?[0-9.]+(?:px|rem)\]/g),
"fixed height h-[Npx/rem]": n(/\bh-\[-?[0-9.]+(?:px|rem)\]/g),
"breakpoint utilities": n(/\b(?:sm|md|lg|xl|2xl|max-sm|max-md|max-lg|max-xl):/g),
"arbitrary bands min/max-[Npx]:": n(/\b(?:min|max)-\[[0-9]+px\]:/g),
"arbitrary […px/rem] total": arb,
"decimal (non-integer px)": decimal,
"baked position top/left/inset-[N]": n(/\b(?:top|left|right|bottom|inset(?:-x|-y)?)-\[-?[0-9.]+(?:px|rem)\]/g),
"raw color literal [#hex/rgb]": n(/\[(?:#[0-9a-fA-F]{3,8}|rgba?\([^\]]*)\]/g),
"per-side border longhand": n(/\[border-(?:top|right|bottom|left)-(?:style|color):/g),
"data-cid (shipped)": n(/data-cid/g),
"data-cid (validation-only)": validationDataCid,
"dangerouslySetInnerHTML": n(/dangerouslySetInnerHTML/g),
"': any' props": n(/:\s*any\b/g),
"robotic 'Generated by' comments": n(/Generated by clone|clone-static/g),
"── GOOD (higher=better) ──": -1,
"fluid width (w-full/auto/fraction)": n(/\bw-(?:full|auto|fit|screen|\d{1,2}\/\d{1,2})\b/g),
"standard scale (gap-2/w-10/p-4…)": n(/\b(?:gap|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|w|h)-(?:0|0\.5|1|1\.5|2|2\.5|3|3\.5|4|5|6|7|8|9|10|11|12|14|16|20|24|28|32|36|40|44|48|52|56|60|64|72|80|96|px)\b/g),
};
/** A readable column label for a target (its site/run name where possible). */
function labelFor(dir: string): string {
const parts = resolve(dir).split("/");
const runsIdx = parts.lastIndexOf("runs");
const outIdx = parts.lastIndexOf("output");
const i = runsIdx >= 0 ? runsIdx : outIdx;
if (i >= 0 && parts[i + 1]) return parts[i + 1]!.slice(0, 22);
return (parts[parts.length - 2] ?? basename(dir)).slice(0, 22);
}
function fileCount(dir: string): number {
let c = 0;
const walk = (d: string): void => { for (const x of readdirSync(d)) { if (x === "node_modules" || x === ".next" || x === "out" || x.startsWith(".")) continue; const p = join(d, x); statSync(p).isDirectory() ? walk(p) : (/\.(tsx|jsx|ts|css)$/.test(x) && c++); } };
walk(dir);
return c;
const pad = (s: string, w: number): string => s + " ".repeat(Math.max(0, w - s.length));
const padL = (s: string, w: number): string => " ".repeat(Math.max(0, w - s.length)) + s;
function renderReport(label: string, rep: QualityReport): string {
const L: string[] = [];
L.push("");
L.push(`━━━ ${label} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
L.push(` GRADE ${rep.grade} (${rep.total}/100)`);
if (rep.caps.length) {
L.push(` HARD CAP → grade limited to D-range:`);
for (const c of rep.caps) L.push(`${c}`);
}
L.push("");
L.push(` ${pad("dimension", 15)}${padL("score", 9)} sub-metrics`);
L.push(` ${"─".repeat(66)}`);
for (const [dim, cat] of Object.entries(rep.categories)) {
const metrics = Object.entries(cat.metrics).map(([k, v]) => `${k}=${v}`).join(" ");
L.push(` ${pad(dim, 15)}${padL(`${cat.score}/${cat.max}`, 9)} ${metrics}`);
}
if (rep.offenders.length) {
L.push("");
L.push(` top offenders`);
L.push(` ${pad("metric", 34)}${padL("value", 12)} file`);
L.push(` ${"─".repeat(66)}`);
for (const o of rep.offenders.slice(0, 10)) {
L.push(` ${pad(o.metric, 34)}${padL(String(o.value), 12)} ${o.file}`);
}
}
return L.join("\n");
}
function normalizeTarget(input: string): Target {
const d = resolve(input);
const asApp = (appDir: string, validationDir?: string): Target => ({ inputDir: d, scanDir: appDir, validationDir });
if (basename(d) === ".clone") {
const app = join(dirname(d), "app");
const validation = join(d, "generated", "app");
if (existsSync(join(app, "src"))) return asApp(app, existsSync(validation) ? validation : undefined);
function renderComparison(labels: string[], reps: QualityReport[]): string {
const L: string[] = [];
const colW = Math.max(12, ...labels.map((l) => l.length + 2));
L.push("");
L.push(`━━━ comparison ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
L.push(" " + pad("dimension", 15) + labels.map((l) => padL(l, colW)).join(""));
const dims = Object.keys(reps[0]!.categories);
for (const d of dims) {
L.push(" " + pad(d, 15) + reps.map((r) => padL(String(r.categories[d]!.score), colW)).join(""));
}
const generatedSuffix = `${sep}.clone${sep}generated${sep}app`;
if (d.endsWith(generatedSuffix)) {
const cloneDir = d.slice(0, -`${sep}generated${sep}app`.length);
const app = join(dirname(cloneDir), "app");
if (existsSync(join(app, "src"))) return asApp(app, d);
}
if (basename(d) === "generated" && existsSync(join(d, "app", "src"))) {
const cloneDir = dirname(d);
const app = join(dirname(cloneDir), "app");
if (basename(cloneDir) === ".clone" && existsSync(join(app, "src"))) return asApp(app, join(d, "app"));
}
return { inputDir: d, scanDir: d };
L.push(" " + pad("GRADE", 15) + reps.map((r) => padL(`${r.grade} (${r.total})`, colW)).join(""));
return L.join("\n");
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main(): void {
const args = process.argv.slice(2).filter((a) => !a.startsWith("--"));
let targets: Target[];
if (args.length) targets = args.map(normalizeTarget);
else {
// Auto: every output/<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); }
const asJson = process.argv.includes("--json");
const targets = (args.length ? args.map((a) => resolve(a)) : discoverTargets()).filter(isAppDir);
if (!targets.length) { console.error("no app trees found — pass app dirs explicitly (e.g. runs/<site>/latest/generated/app)"); process.exit(1); }
// A readable column label: the deliverable dir's parent name, or the scanned dir name.
const label = (t: Target): string => (basename(dirname(t.scanDir)) || basename(t.scanDir)).slice(0, 16);
const labels = targets.map(label);
const audits = targets.map(audit);
audits.forEach((a, i) => { a["files (tsx+css)"] = fileCount(targets[i]!.scanDir); });
const labels = targets.map(labelFor);
const reports = targets.map((t) => scoreApp(t));
const keys = Object.keys(audits[0]!);
const w0 = Math.max(...keys.map((k) => k.length));
const colW = Math.max(11, ...labels.map((l) => l.length));
const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - s.length));
console.log("\n" + pad("metric", w0) + " " + labels.map((l) => pad(l, colW)).join(""));
console.log("─".repeat(w0 + 2 + colW * labels.length));
for (const k of keys) {
if (audits[0]![k] === -1) { console.log(pad(k, w0)); continue; } // section header
const cells = audits.map((a) => pad(String(a[k] ?? 0), colW)).join("");
console.log(pad(k, w0) + " " + cells);
if (asJson) {
console.log(JSON.stringify(reports.map((r, i) => ({ label: labels[i], ...r })), null, 2));
return;
}
console.log("\n(counts; lower is better in the BAD block, higher in the GOOD block. 'decimal' = the\n arbitrary value's px isn't a whole pixel — the frozen-measurement tell.)\n");
for (let i = 0; i < reports.length; i++) console.log(renderReport(labels[i]!, reports[i]!));
if (reports.length > 1) console.log(renderComparison(labels, reports));
console.log("\n(scores are out of each dimension's max; grade is the weighted blend, capped to\n D-range if any single file/line/blob is in catastrophic payload territory.)\n");
}
main();
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
+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
* behaves* like the source. They say nothing about whether the generated CODE is
* good componentized, semantically named, styled through a reusable token/class
* system, editable, and well organized. That "developer-facing quality" is exactly
* where a deterministic converter is judged once fidelity is a given.
* good small files, decomposed into real components, semantic and accessible,
* free of capture artifacts, and safe at runtime. That "developer-facing quality"
* is what a deterministic converter is judged on once fidelity is a given.
*
* This module statically analyzes a generated app directory and scores it 0100 on
* six categories. It reads only source text (`.tsx/.jsx/.ts/.astro/.css`). The
* metrics are chosen to be framework-agnostic: e.g. "style reuse" rewards shared
* classes whether they're Tailwind utilities or semantic clone classes, and
* penalizes per-node unique rules (`.c1{...} .c2{...}`) regardless of framework.
* This module statically analyzes a generated app directory (source text only
* `.tsx/.jsx/.ts/.astro/.css`) and scores it 0100 across SIX dimensions, each
* with visible subscores. The score is a weighted blend of the dimensions EXCEPT
* that a single dimension in catastrophic territory (a multi-megabyte source file,
* a 100KB+ single line) HARD-CAPS the overall grade into D-range no matter how
* clean everything else is because such a file is unopenable, un-diffable, and
* un-editable, which is the whole point of "good code".
*
* The signals are deliberately general (payload bytes, LOC distribution, repeated
* definitions, semantic-tag ratios, whitespace/capture artifacts, uncleaned
* listeners) so the scorer is not tuned to any specific site it measures the
* failure *modes* a robotic converter falls into, not any one output.
*
* Pure + deterministic: same directory same score.
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, extname, basename, relative } from "node:path";
// ===========================================================================
// CALIBRATION CONSTANTS
// ---------------------------------------------------------------------------
// These are CALIBRATION GUIDES, expected to be tuned as the generator and our
// taste evolve — they are NOT compliance targets or contractual thresholds. A
// number here answers "where does a human reviewer start wincing?", set from
// observed good vs. bad outputs, and should be re-fit (not litigated) whenever
// calibration drifts. Every knob lives in this one block on purpose.
// ===========================================================================
const K = {
// ---- payload discipline (a file/line so big it is not human-readable) ----
FILE_BYTES_GOOD: 30_000, // ≤30KB source file: comfortable to open
FILE_BYTES_BAD: 160_000, // ≥160KB: a reviewer scrolls forever; scores ~0
LINE_CHARS_GOOD: 2_000, // a formatted line, allowing for a dense data/JSX row
LINE_CHARS_BAD: 30_000, // a giant one-liner (minified / HTML-as-string prop)
INLINE_BYTES_GOOD: 50_000, // total base64 / inline-HTML bytes that's forgivable
INLINE_BYTES_BAD: 2_000_000, // 2MB+ of embedded blobs: catastrophic payload
// Catastrophe caps: any ONE of these forces the whole app into D-range.
CATASTROPHE_FILE_BYTES: 1_000_000, // a >1MB source file
CATASTROPHE_LINE_CHARS: 100_000, // a >100KB single line
CATASTROPHE_INLINE_BYTES: 5_000_000, // >5MB of embedded base64/HTML
CAP_GRADE_D: 68, // ceiling applied when a catastrophe fires — top of the D band, so a
// catastrophic payload can grade no better than D+ regardless of other dimensions
// Softer cap for "very bad but not unopenable" payloads.
WARN_FILE_BYTES: 350_000,
WARN_LINE_CHARS: 40_000,
CAP_GRADE_C: 78, // ceiling for the softer warning band (top of C+)
// ---- component decomposition (is the page a monolith?) ----
MONOLITH_DOMINANCE_GOOD: 0.3, // biggest file ≤30% of all tags → well spread
MONOLITH_DOMINANCE_BAD: 0.75, // one file holds ≥75% of the page → monolith
BIG_SECTION_LINES: 600, // a "section" this long is really a whole page
COMPONENTS_GOOD: 12, // this many real components → full decomposition credit
// ---- duplication ----
DUP_HELPER_RATIO_BAD: 0.4, // ≥40% of helper defs are copy-paste duplicates
DUP_SVG_PATH_RATIO_BAD: 0.85, // ≥85% of inline <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
// ---------------------------------------------------------------------------
@@ -51,13 +121,43 @@ export function collectFiles(root: string): SrcFile[] {
}
// ---------------------------------------------------------------------------
// Primitive extraction
// Small helpers
// ---------------------------------------------------------------------------
const clamp01 = (x: number): number => Math.max(0, Math.min(1, x));
/** Linear ramp: v in [lo,hi] → [0,1]. Handles lo>hi (descending / "lower is better"). */
function ramp(v: number, lo: number, hi: number): number {
if (lo === hi) return v >= hi ? 1 : 0;
return clamp01((v - lo) / (hi - lo));
}
/** A value where HIGHER is worse: good at `good`, zero at `bad`. */
const penalize = (v: number, good: number, bad: number): number => 1 - ramp(v, good, bad);
const r1 = (n: number): number => Math.round(n * 10) / 10;
/** Longest single line (in chars) in a file. */
function maxLineLen(text: string): number {
let max = 0, start = 0;
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10) { if (i - start > max) max = i - start; start = i + 1; }
}
if (text.length - start > max) max = text.length - start;
return max;
}
/** Count JSX/HTML opening element tags — a framework-agnostic page-size proxy. */
function countTags(text: string): number {
const m = text.match(/<[a-zA-Z][a-zA-Z0-9.]*(\s|\/|>)/g);
return m ? m.length : 0;
}
// ---------------------------------------------------------------------------
// Structural classification
// ---------------------------------------------------------------------------
const CONFIG_NAME_RE = /(config|tsconfig|next-env|site-config|content\.config|\.config\.)/i;
const ROOT_NAME_RE = /(^|\/)(layout|_app|_document|root-layout)\.[jt]sx$/i;
/** Files that compose the whole page (the "entry"): page.tsx (Next), index/page in a
* pages/ dir, home.tsx, or an .astro route shell. Used for the dominance metric. */
const SECTION_WORDS = ["hero", "footer", "navbar", "nav", "header", "about", "cta", "feature", "pricing", "faq", "logo", "logocloud", "testimonial", "gallery", "stories", "spotlight", "knowledge", "news", "apply", "contact", "banner", "team", "stats", "partners", "alumni", "founder", "section"];
function isEntryFile(rel: string): boolean {
const b = basename(rel).toLowerCase();
if (/^(page|index|home|app)\.[jt]sx$/.test(b)) return true;
@@ -66,54 +166,8 @@ function isEntryFile(rel: string): boolean {
return false;
}
const SECTION_WORDS = ["hero", "footer", "navbar", "nav", "header", "about", "cta", "feature", "pricing", "faq", "logo", "logocloud", "testimonial", "gallery", "stories", "spotlight", "knowledge", "news", "apply", "contact", "banner", "team", "stats", "partners", "alumni", "founder", "section"];
/** Count JSX/HTML opening element tags a framework-agnostic proxy for page size
* (how many DOM nodes the output describes). Excludes React fragments + closing tags. */
function countTags(text: string): number {
const m = text.match(/<[a-zA-Z][a-zA-Z0-9.]*(\s|\/|>)/g);
return m ? m.length : 0;
}
/** All className / class string-literal tokens used on elements (space split).
* Catches JSX attribute form (className="…"), object/spread form (className: "…"
* what our generator emits), and hoisted className consts. A dynamic per-node class
* (className: "c" + d._cid / className={"c"+}) renders a UNIQUE opaque class per
* instance, so each occurrence is recorded as its own opaque token (keeping the
* reuse + semantic metrics honest for the per-node-CSS strategy). */
function classTokens(text: string): string[] {
const out: string[] = [];
// className="..." / className: "..." / "class": "..." (attr + object form)
const re = /\b(?:className|class)\s*(?:=|:)\s*"([^"]*)"/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
for (const t of m[1]!.split(/\s+/)) if (t) out.push(t);
}
// hoisted className consts: const xClassName = "..."
const re2 = /\b(?:const|let)\s+\w*[Cc]lassName\w*\s*=\s*"([^"]*)"/g;
while ((m = re2.exec(text)) !== null) {
for (const t of m[1]!.split(/\s+/)) if (t) out.push(t);
}
// dynamic per-node class: className: "c" + d._cid / className={"c"+ ...}
const dyn = (text.match(/\bclassName\s*(?:=\s*\{|:)\s*"c[a-z]?"\s*\+/g) ?? []).length
+ (text.match(/\bclassName\s*(?:=\s*\{|:)\s*"c[a-z]?\d*"\s*\+/g) ?? []).length;
for (let i = 0; i < dyn; i++) out.push(`c__dyn${out.length}`); // unique opaque per occurrence
return out;
}
const CID_CLASS_RE = /^c[a-z]?\d+$/; // our legacy per-node class: c12 / cn12
/** A class token that carries no human meaning (pure id / hash). */
function isOpaqueClass(t: string): boolean {
if (CID_CLASS_RE.test(t)) return true;
if (/^[a-z]?\d+$/.test(t)) return true;
// hashed CSS-module-ish tokens: _foo_ab12 or 6-hex tails
if (/_[a-z0-9]{5,}$/i.test(t) && /\d/.test(t)) return true;
return false;
}
const GENERIC_COMP_RE = /^(Item|Card|List|ListItem|Link|LinkItem|Wrapper|Box|Div|El|Node|Group|Block|Comp|Component|Row|Col|Cell|Thing|Unit|Part|Chunk)\d*$/;
/** Exported / defined component names in a file. */
function componentNames(text: string): string[] {
export function componentNames(text: string): string[] {
const names = new Set<string>();
let m: RegExpExecArray | null;
const re1 = /export\s+default\s+function\s+([A-Z]\w*)/g;
@@ -125,221 +179,428 @@ function componentNames(text: string): string[] {
return [...names];
}
/** Data/content field names declared in a content or data module. */
function contentFieldNames(text: string): string[] {
const out: string[] = [];
// type Foo = { a: string; b?: number } — capture keys
const typeBlocks = text.match(/(?:type|interface)\s+\w+\s*=?\s*\{([^}]*)\}/g) ?? [];
for (const blk of typeBlocks) {
const re = /(\w+)\s*\??\s*:/g; let m: RegExpExecArray | null;
while ((m = re.exec(blk)) !== null) out.push(m[1]!);
// ===========================================================================
// METRIC EXTRACTORS (exported for unit tests — each is a pure text→number/obj)
// ===========================================================================
/** Bytes of embedded base64 blobs + HTML passed as string props/dangerous html. */
export function inlineBlobBytes(text: string): number {
let bytes = 0;
// base64 data URIs — the payload after the comma.
for (const m of text.matchAll(/data:[^;,'"`)\s]*;base64,([A-Za-z0-9+/=]+)/g)) bytes += m[1]!.length;
// dangerouslySetInnerHTML / html-string props: a big string literal handed to markup.
for (const m of text.matchAll(/dangerouslySetInnerHTML/g)) void m;
// A very long string literal that is actually markup ("<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 Offender = { file: string; metric: string; value: number };
export type QualityReport = {
dir: string;
total: number;
grade: LetterGrade;
caps: string[];
categories: Record<string, CategoryScore>;
offenders: Offender[];
raw: Record<string, number>;
};
const clamp01 = (x: number): number => Math.max(0, Math.min(1, x));
/** Linear ramp: value v mapped from [lo,hi] → [0,1]. */
const ramp = (v: number, lo: number, hi: number): number => clamp01((v - lo) / (hi - lo));
const r1 = (n: number): number => Math.round(n * 10) / 10;
/** Dimension weights (sum = 100). Guiding, not exact see CALIBRATION note.
* Payload + decomposition carry the most weight because an unopenable, monolithic
* file is the defect a reviewer notices first; hygiene/duplication are lighter
* because this converter's baseline (some {" "}, shared icons) is tolerable. */
const WEIGHTS = {
payload: 26,
decomposition: 18,
duplication: 12,
semantics: 18,
hygiene: 14,
runtime: 12,
} as const;
export function scoreApp(root: string): QualityReport {
const files = collectFiles(root);
const tsx = files.filter((f) => f.ext === ".tsx" || f.ext === ".jsx" || f.ext === ".astro");
const markup = files.filter((f) => f.ext === ".tsx" || f.ext === ".jsx" || f.ext === ".astro");
const css = files.filter((f) => f.ext === ".css");
const runtimeFiles = files.filter((f) => f.ext === ".ts" || f.ext === ".tsx" || f.ext === ".jsx");
// Component modules = JSX-bearing files that aren't config/root-layout.
const componentModules = tsx.filter((f) =>
const componentModules = markup.filter((f) =>
!CONFIG_NAME_RE.test(f.rel) && !ROOT_NAME_RE.test(f.rel) && countTags(f.text) > 0);
const nonEntry = componentModules.filter((f) => !isEntryFile(f.rel));
const allText = files.map((f) => f.text).join("\n");
const markupText = markup.map((f) => f.text).join("\n");
const totalTags = componentModules.reduce((s, f) => s + countTags(f.text), 0) || 1;
const entryFiles = componentModules.filter((f) => isEntryFile(f.rel));
const maxFileTags = Math.max(0, ...componentModules.map((f) => countTags(f.text)));
const dominance = maxFileTags / totalTags; // 1.0 = one giant file
const kTags = totalTags / 1000;
// Section-named components (semantic top-level blocks). Tokenize the file path +
// component names into words (splitting camelCase, kebab, snake, slashes) so both
// `hero-section.tsx` and `HeroSection` are credited.
const isSectionFile = (f: SrcFile): boolean => {
const raw = f.rel + " " + componentNames(f.text).join(" ");
const words = new Set(raw.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
return SECTION_WORDS.some((w) => words.has(w));
const offenders: Offender[] = [];
const caps: string[] = [];
// =========================================================================
// 1. PAYLOAD DISCIPLINE — no file/line/blob a human cannot open.
// =========================================================================
let worstFileBytes = 0, worstFileRel = "";
let worstLine = 0, worstLineRel = "";
let totalInlineBytes = 0, worstInlineRel = "", worstInlineBytes = 0;
for (const f of files) {
const p = filePayload(f);
if (p.bytes > worstFileBytes) { worstFileBytes = p.bytes; worstFileRel = f.rel; }
if (p.maxLine > worstLine) { worstLine = p.maxLine; worstLineRel = f.rel; }
totalInlineBytes += p.inlineBytes;
if (p.inlineBytes > worstInlineBytes) { worstInlineBytes = p.inlineBytes; worstInlineRel = f.rel; }
}
if (worstFileRel) offenders.push({ file: worstFileRel, metric: "file bytes", value: worstFileBytes });
if (worstLineRel) offenders.push({ file: worstLineRel, metric: "max line chars", value: worstLine });
if (worstInlineBytes > 0) offenders.push({ file: worstInlineRel, metric: "inline blob bytes", value: worstInlineBytes });
// Worst-file penalty, tempered by how WIDESPREAD oversized files are: one big section
// in an otherwise-small tree is forgivable; many oversized files is systemic bloat.
const oversized = files.filter((f) => f.text.length >= K.FILE_BYTES_GOOD * 2).length;
const pFileMax = penalize(worstFileBytes, K.FILE_BYTES_GOOD, K.FILE_BYTES_BAD);
const pFileSpread = penalize(oversized, 0, Math.max(3, files.length * 0.25));
const pFile = 0.6 * pFileMax + 0.4 * pFileSpread;
const pLine = penalize(worstLine, K.LINE_CHARS_GOOD, K.LINE_CHARS_BAD);
const pInline = penalize(totalInlineBytes, K.INLINE_BYTES_GOOD, K.INLINE_BYTES_BAD);
const payloadSub = 0.4 * pFile + 0.35 * pLine + 0.25 * pInline;
// =========================================================================
// 2. COMPONENT DECOMPOSITION — is the page a monolith?
// =========================================================================
const dominance = maxFileTags / totalTags; // 1.0 = one giant file holds the page
const bigSections = componentModules.filter((f) => f.lines >= K.BIG_SECTION_LINES && !isEntryFile(f.rel)).length;
for (const f of componentModules) {
if (f.lines >= K.BIG_SECTION_LINES && !isEntryFile(f.rel)) offenders.push({ file: f.rel, metric: "section LOC", value: f.lines });
}
const dMono = penalize(dominance, K.MONOLITH_DOMINANCE_GOOD, K.MONOLITH_DOMINANCE_BAD);
const dCount = ramp(nonEntry.length, 0, K.COMPONENTS_GOOD);
const dBig = penalize(bigSections, 0, 3);
const decompositionSub = 0.45 * dMono + 0.3 * dCount + 0.25 * dBig;
// =========================================================================
// 3. DUPLICATION — repeated helpers, near-identical components, repeated SVG paths.
// =========================================================================
let helperDefs = 0, helperDups = 0;
for (const f of runtimeFiles) { const d = duplicateHelpers(f.text); helperDefs += d.defs; helperDups += d.dups; }
const svgDup = svgPathDuplication(markupText);
// Near-dup signatures over NON-trivial CONTENT components only: vectorized assets
// (icons, logos, illustration frames) legitimately share a tag shape, so counting
// them as "duplicates" would punish healthy asset sets rather than real copy-paste.
const isAssetFile = (rel: string): boolean => /(^|\/)svgs?\//i.test(rel) || /(icon|illustration|logo)\d*\.[jt]sx$/i.test(basename(rel));
const compSigs = nonEntry.filter((f) => !isAssetFile(f.rel)).map((f) => {
const h = tagHistogram(f.text);
const tags = Object.values(h).reduce((a, b) => a + b, 0);
if (tags < K.NEAR_DUP_MIN_TAGS) return "";
return Object.keys(h).sort().map((k) => `${k}:${h[k]}`).join(",");
}).filter((s) => s.length > 0);
const nearDup = nearDuplicateComponents(compSigs);
const helperDupRatio = helperDefs ? helperDups / helperDefs : 0;
const svgDupRatio = svgDup.total ? svgDup.repeats / svgDup.total : 0;
if (helperDups > 0) offenders.push({ file: "(helpers)", metric: "duplicate helper defs", value: helperDups });
if (svgDup.repeats > 0) offenders.push({ file: "(inline svg)", metric: "repeated <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;
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;
let total = 0;
for (const [k, sub] of Object.entries(dims)) total += WEIGHTS[k as keyof typeof WEIGHTS] * sub;
// ---- naming ----
const allComponentNames = new Set<string>();
for (const f of componentModules) for (const n of componentNames(f.text)) allComponentNames.add(n);
// exclude the page/Page entry symbol from the semantic judgement
const judgedNames = [...allComponentNames].filter((n) => n !== "Page" && n !== "default" && n !== "RootLayout" && n !== "Layout");
const genericNames = judgedNames.filter((n) => GENERIC_COMP_RE.test(n)).length;
const compNameSemanticRatio = judgedNames.length ? 1 - genericNames / judgedNames.length : 0;
const allClassToks: string[] = [];
for (const f of tsx) allClassToks.push(...classTokens(f.text));
const classTotal = allClassToks.length || 1;
const opaqueClasses = allClassToks.filter(isOpaqueClass).length;
const classSemanticRatio = 1 - opaqueClasses / classTotal;
const distinctClasses = new Set(allClassToks).size || 1;
const classReuse = 1 - distinctClasses / classTotal; // 0 = every class used once (per-node), →1 = heavy reuse
// content field naming + editability
const contentFiles = files.filter((f) => /(content|data)\.[jt]s$/i.test(basename(f.rel)) || /(^|\/)(content|lib)\//i.test(f.rel));
const fieldNames: string[] = [];
for (const f of contentFiles) fieldNames.push(...contentFieldNames(f.text));
const plumbingFields = fieldNames.filter((n) => PLUMBING_FIELD_RE.test(n)).length;
const fieldSemanticRatio = fieldNames.length ? 1 - plumbingFields / fieldNames.length : 0.5;
// ---- styling system ----
const cssBytes = css.reduce((s, f) => s + f.text.length, 0);
const cssBytesPerNode = cssBytes / totalTags;
let tokenRefs = 0, tokenDefs = 0;
for (const f of css) {
tokenRefs += (f.text.match(/var\(--/g) ?? []).length;
tokenDefs += (f.text.match(/^\s*--[\w-]+\s*:/gm) ?? []).length;
// Catastrophe caps: any ONE unopenable payload drags the whole grade into D-range.
if (worstFileBytes >= K.CATASTROPHE_FILE_BYTES) caps.push(`file ${worstFileRel} is ${(worstFileBytes / 1e6).toFixed(1)}MB (>1MB)`);
if (worstLine >= K.CATASTROPHE_LINE_CHARS) caps.push(`line in ${worstLineRel} is ${(worstLine / 1000).toFixed(0)}KB (>100KB)`);
if (totalInlineBytes >= K.CATASTROPHE_INLINE_BYTES) caps.push(`${(totalInlineBytes / 1e6).toFixed(1)}MB of embedded base64/HTML (>5MB)`);
if (caps.length) total = Math.min(total, K.CAP_GRADE_D);
else {
// Softer warning band.
if (worstFileBytes >= K.WARN_FILE_BYTES) caps.push(`file ${worstFileRel} is ${(worstFileBytes / 1000).toFixed(0)}KB (>${K.WARN_FILE_BYTES / 1000}KB)`);
if (worstLine >= K.WARN_LINE_CHARS) caps.push(`line in ${worstLineRel} is ${(worstLine / 1000).toFixed(0)}KB (>${K.WARN_LINE_CHARS / 1000}KB)`);
if (caps.length) total = Math.min(total, K.CAP_GRADE_C);
}
// Tailwind-style theme tokens count as token usage too (utility classes referencing roles)
const tailwindTokenClasses = allClassToks.filter((t) => /(^|[-:])(bg|text|border|fill|stroke|ring|from|to|via)-/.test(t)).length;
// per-node-unique-rule penalty: count `.cNNN{` selectors in CSS
let perNodeRules = 0;
for (const f of css) perNodeRules += (f.text.match(/\.c[a-z]?\d+\s*[\{,]/g) ?? []).length;
const perNodeRuleRatio = perNodeRules / totalTags; // 1.0 = a unique rule per node
total = r1(Math.max(0, Math.min(100, total)));
// magic literals in component markup (raw hex/rgb/px in className/style strings)
let magicLiterals = 0;
for (const f of componentModules) {
magicLiterals += (f.text.match(/#[0-9a-fA-F]{3,8}\b/g) ?? []).length;
magicLiterals += (f.text.match(/\brgba?\([^)]*\)/g) ?? []).length;
}
const magicPerNode = magicLiterals / totalTags;
// ---- idiomatic styling (the axes the old rubric was blind to) ----
// A px ARBITRARY value (`w-[713.938px]`, `py-[64px]`) is a measurement, not a design choice —
// the single biggest "machine-generated" tell. Count them across markup + hoisted class
// consts, vs the standard scale / rem a human writes.
let arbPx = 0, arbBands = 0, stdBreakpoints = 0, dataCidCount = 0;
for (const f of [...componentModules, ...css]) {
arbPx += (f.text.match(/\[[0-9]+(?:\.[0-9]+)?px\]/g) ?? []).length;
arbBands += (f.text.match(/(?:min|max)-\[[0-9]+px\]:/g) ?? []).length; // arbitrary midpoint media variants
stdBreakpoints += (f.text.match(/(?:^|[\s"'`])(?:max-)?(?:sm|md|lg|xl|2xl):/g) ?? []).length;
dataCidCount += (f.text.match(/data-cid/g) ?? []).length;
}
const arbPxPerNode = arbPx / totalTags; // low is hand-authored; high is machine replay
const bandShare = arbBands + stdBreakpoints > 0 ? arbBands / (arbBands + stdBreakpoints) : 0; // 1 = all arbitrary
const dataCidPerNode = dataCidCount / totalTags;
// Robotic, never-hand-written comment phrases.
let roboticComments = 0;
for (const f of [...componentModules, ...css, ...files.filter((x) => x.ext === ".ts")]) {
roboticComments += (f.text.match(/Generated by clone-static|render-identical to the source|Do not edit by hand/g) ?? []).length;
}
// ---- editability ----
const hasContentModule = contentFiles.some((f) => /export\s+(const|type)/.test(f.text)) ? 1 : 0;
// Destructured props with a default value, in any function/arrow param block — counts
// string, array, and identifier defaults alike (a default is a default).
let propsWithDefaults = 0;
for (const f of componentModules) {
for (const blk of f.text.match(/\(\s*\{[^{}]*\}/g) ?? []) {
propsWithDefaults += (blk.match(/\b\w+\s*=\s*(?![=>])/g) ?? []).length;
}
}
const defaultsSignal = clamp01(propsWithDefaults / Math.max(6, nonEntry.length));
// ---- file org ----
const dirSet = new Set(componentModules.map((f) => f.rel.split("/").slice(0, -1).join("/")));
const hasSectionsDir = [...dirSet].some((d) => /sections?$/i.test(d)) ? 1 : 0;
const hasComponentsDir = [...dirSet].some((d) => /(components|ui)$/i.test(d)) ? 1 : 0;
const hasLayoutDir = [...dirSet].some((d) => /(layout|svgs?)$/i.test(d)) ? 1 : 0;
const orgDirs = hasSectionsDir + hasComponentsDir + hasLayoutDir;
// ---- metadata ----
let docHeaders = 0, annotations = 0;
for (const f of componentModules) {
docHeaders += (f.text.match(/\/\*\*[\s\S]*?\*\//g) ?? []).length;
// component-type annotations in attribute, object, or doc-tag form.
annotations += (f.text.match(/@component|data-component/g) ?? []).length;
}
// =========================================================================
// Category scoring
// =========================================================================
// 1. Componentization (25): many components, low single-file dominance, sections present
const cCount = ramp(nonEntry.length, 0, 12); // 0 → 12+ components
const cDom = 1 - ramp(dominance, 0.3, 0.95); // penalize one giant file
const cSections = ramp(sectionComponents, 0, 6); // semantic sections
const componentization = 25 * (0.4 * cCount + 0.35 * cDom + 0.25 * cSections);
// 2. Semantic naming (25): component names, class names, content fields
const naming = 25 * (0.4 * compNameSemanticRatio + 0.4 * classSemanticRatio + 0.2 * fieldSemanticRatio);
// 3. Styling system (20): class reuse, tokens, low per-node verbosity, few magic literals AND
// — the axes the old rubric missed — idiomatic values (standard scale/rem, not a wall of
// px arbitraries) and standard breakpoints (not arbitrary midpoint bands).
const sReuse = clamp01(classReuse / 0.6); // 0.6+ reuse → full marks
const sTokens = ramp(tokenRefs + tailwindTokenClasses, 0, Math.max(40, totalTags * 0.5));
const sVerbosity = 1 - clamp01(perNodeRuleRatio); // per-node rules are bad
const sMagic = 1 - clamp01(magicPerNode / 0.25);
const sArb = 1 - clamp01(arbPxPerNode / 2); // ~0 arb-px/node → 1; 2+/node → 0
const sBp = 1 - bandShare; // 0 arbitrary bands → 1
const styling = 20 * (0.22 * sReuse + 0.13 * sTokens + 0.13 * sVerbosity + 0.07 * sMagic + 0.27 * sArb + 0.18 * sBp);
// 4. Editability (15): content module, typed fields, semantic fields, prop defaults
const editability = 15 * (0.4 * hasContentModule + 0.3 * fieldSemanticRatio + 0.3 * defaultsSignal);
// 5. File org (10): folder structure + svgs extracted
const fileOrg = 10 * (0.7 * (orgDirs / 3) + 0.3 * clamp01(svgFiles / 4));
// 6. Metadata (5): concise human doc headers + semantic component annotations — but a
// "Generated by clone-static / render-identical" robotic header is a NEGATIVE signal (no
// human writes it), and shipping a per-node data-cid on every element is markup noise.
const cleanComments = roboticComments === 0 ? 1 : clamp01(1 - roboticComments / Math.max(4, nonEntry.length));
const lowCidNoise = 1 - clamp01(dataCidPerNode); // ~0 data-cid/node → 1; 1/node → 0
const metadata = 5 * (0.35 * clamp01(docHeaders / Math.max(4, nonEntry.length)) * cleanComments
+ 0.35 * clamp01(annotations / Math.max(8, totalTags * 0.1))
+ 0.3 * lowCidNoise);
const total = componentization + naming + styling + editability + fileOrg + metadata;
// Order offenders by severity so the report's "top offenders" is meaningful.
offenders.sort((a, b) => severity(b) - severity(a));
return {
dir: root,
total: r1(total),
total,
grade: toLetter(total),
caps,
categories: {
componentization: { score: r1(componentization), max: 25, metrics: { components: nonEntry.length, dominancePct: r1(dominance * 100), sectionComponents } },
naming: { score: r1(naming), max: 25, metrics: { compNameSemanticPct: r1(compNameSemanticRatio * 100), classSemanticPct: r1(classSemanticRatio * 100), fieldSemanticPct: r1(fieldSemanticRatio * 100), genericNames } },
styling: { score: r1(styling), max: 20, metrics: { classReusePct: r1(classReuse * 100), tokenRefs, perNodeRules, magicLiterals, arbPx, arbPxPerNode: r1(arbPxPerNode), arbBands, stdBreakpoints, bandSharePct: r1(bandShare * 100) } },
editability: { score: r1(editability), max: 15, metrics: { hasContentModule, fieldSemanticPct: r1(fieldSemanticRatio * 100), propsWithDefaults } },
fileOrg: { score: r1(fileOrg), max: 10, metrics: { orgDirs, svgFiles, hasSectionsDir, hasComponentsDir } },
metadata: { score: r1(metadata), max: 5, metrics: { docHeaders, annotations, roboticComments, dataCidPerNode: r1(dataCidPerNode) } },
payload: { score: r1(WEIGHTS.payload * payloadSub), max: WEIGHTS.payload, metrics: { maxFileKB: r1(worstFileBytes / 1000), maxLineKB: r1(worstLine / 1000), inlineBlobKB: r1(totalInlineBytes / 1000) } },
decomposition: { score: r1(WEIGHTS.decomposition * decompositionSub), max: WEIGHTS.decomposition, metrics: { components: nonEntry.length, dominancePct: r1(dominance * 100), bigSections } },
duplication: { score: r1(WEIGHTS.duplication * duplicationSub), max: WEIGHTS.duplication, metrics: { helperDups, helperDupPct: r1(helperDupRatio * 100), svgPathRepeats: svgDup.repeats, nearDupPairs: nearDup } },
semantics: { score: r1(WEIGHTS.semantics * semanticsSub), max: WEIGHTS.semantics, metrics: { h1: h1Count, divRatioPct: r1(divRatio * 100), semanticTags, altCoveragePct: r1(altCoverage * 100), ariaHiddenFocusables: ariaHidden } },
hygiene: { score: r1(WEIGHTS.hygiene * hygieneSub), max: WEIGHTS.hygiene, metrics: { wsLiterals, subpixelArbitraries: subpixel, opaqueTokenPct: r1(opaqueRatio * 100), probeArtifacts: probes } },
runtime: { score: r1(WEIGHTS.runtime * runtimeSub), max: WEIGHTS.runtime, metrics: { uncleanedListeners: leaks, undeclaredImports } },
},
offenders: offenders.slice(0, 15),
raw: {
files: files.length, componentModules: componentModules.length, totalTags, maxFileTags,
classTokens: classTotal, distinctClasses, opaqueClasses, cssBytes,
worstFileBytes, worstLineChars: worstLine, inlineBytes: totalInlineBytes,
},
};
}
/** Undeclared imports: a bare-specifier import not present in any package.json/tsconfig
* alias and not a relative path a runtime crash waiting to happen. We approximate by
* flagging imports of local-looking modules that resolve nowhere is expensive, so instead
* we flag the cheap, robust tell: an import statement whose source is an empty string or a
* malformed specifier. (Kept conservative to avoid false positives across frameworks.) */
function countUndeclaredImports(files: SrcFile[]): number {
let n = 0;
for (const f of files) {
for (const m of f.text.matchAll(/\bimport\s+[^;]*?\bfrom\s*["'`]([^"'`]*)["'`]/g)) {
const spec = m[1]!;
if (spec.trim() === "" || /\s/.test(spec) || spec === "undefined" || spec === "null") n++;
}
}
return n;
}
/** Relative weight of an offender for ordering the top-offenders list. */
function severity(o: Offender): number {
switch (o.metric) {
case "file bytes": return o.value / 1000;
case "max line chars": return o.value / 1000;
case "inline blob bytes": return o.value / 2000;
case "section LOC": return o.value / 20;
case "opaque --token defs": return o.value * 0.5;
case "frozen sub-pixel arbitraries": return o.value * 0.3;
case "{\" \"} whitespace literals": return o.value * 0.1;
case "h1 count": return 300; // a missing h1 is always a headline offender
case "uncleaned addEventListener": return o.value * 3;
default: return o.value;
}
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
function fmt(rep: QualityReport): string {
const lines: string[] = [];
lines.push(`\n ${rep.dir}`);
lines.push(` TOTAL: ${rep.total}/100`);
lines.push(` GRADE: ${rep.grade} (${rep.total}/100)`);
if (rep.caps.length) lines.push(` CAPS: ${rep.caps.join("; ")}`);
for (const [k, c] of Object.entries(rep.categories)) {
const metrics = Object.entries(c.metrics).map(([mk, mv]) => `${mk}=${mv}`).join(" ");
lines.push(` ${k.padEnd(16)} ${String(c.score).padStart(5)}/${c.max} ${metrics}`);
lines.push(` ${k.padEnd(15)} ${String(c.score).padStart(5)}/${c.max} ${metrics}`);
}
if (rep.offenders.length) {
lines.push(` top offenders:`);
for (const o of rep.offenders.slice(0, 8)) lines.push(` ${o.metric.padEnd(34)} ${String(o.value).padStart(10)} ${o.file}`);
}
return lines.join("\n");
}
@@ -353,11 +614,11 @@ async function main(): Promise<void> {
for (const rep of reports) console.log(fmt(rep));
if (reports.length > 1) {
console.log("\n ── comparison ──");
console.log(" " + "category".padEnd(16) + reports.map((_, i) => `app${i + 1}`.padStart(10)).join(""));
console.log(" " + "dimension".padEnd(15) + reports.map((_, i) => `app${i + 1}`.padStart(10)).join(""));
for (const cat of Object.keys(reports[0]!.categories)) {
console.log(" " + cat.padEnd(16) + reports.map((r) => String(r.categories[cat]!.score).padStart(10)).join(""));
console.log(" " + cat.padEnd(15) + reports.map((r) => String(r.categories[cat]!.score).padStart(10)).join(""));
}
console.log(" " + "TOTAL".padEnd(16) + reports.map((r) => String(r.total).padStart(10)).join(""));
console.log(" " + "GRADE".padEnd(15) + reports.map((r) => `${r.grade}(${r.total})`.padStart(10)).join(""));
}
}