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:
co-authored by
Claude Fable 5
parent
77be868ee3
commit
d3ec154bae
@@ -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([
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -1,23 +1,93 @@
|
||||
/**
|
||||
* Output-quality rubric (deterministic, framework-agnostic).
|
||||
* Output-quality rubric — HONEST edition (deterministic, framework-agnostic).
|
||||
*
|
||||
* The fidelity gates (validate/gates.ts) measure whether the clone *looks and
|
||||
* behaves* like the source. They say nothing about whether the generated CODE is
|
||||
* good — componentized, semantically named, styled through a reusable token/class
|
||||
* system, editable, and well organized. That "developer-facing quality" is exactly
|
||||
* where a deterministic converter is judged once fidelity is a given.
|
||||
* good — small files, decomposed into real components, semantic and accessible,
|
||||
* free of capture artifacts, and safe at runtime. That "developer-facing quality"
|
||||
* is what a deterministic converter is judged on once fidelity is a given.
|
||||
*
|
||||
* This module statically analyzes a generated app directory and scores it 0–100 on
|
||||
* six categories. It reads only source text (`.tsx/.jsx/.ts/.astro/.css`). The
|
||||
* metrics are chosen to be framework-agnostic: e.g. "style reuse" rewards shared
|
||||
* classes whether they're Tailwind utilities or semantic clone classes, and
|
||||
* penalizes per-node unique rules (`.c1{...} .c2{...}`) regardless of framework.
|
||||
* This module statically analyzes a generated app directory (source text only —
|
||||
* `.tsx/.jsx/.ts/.astro/.css`) and scores it 0–100 across SIX dimensions, each
|
||||
* with visible subscores. The score is a weighted blend of the dimensions EXCEPT
|
||||
* that a single dimension in catastrophic territory (a multi-megabyte source file,
|
||||
* a 100KB+ single line) HARD-CAPS the overall grade into D-range no matter how
|
||||
* clean everything else is — because such a file is unopenable, un-diffable, and
|
||||
* un-editable, which is the whole point of "good code".
|
||||
*
|
||||
* The signals are deliberately general (payload bytes, LOC distribution, repeated
|
||||
* definitions, semantic-tag ratios, whitespace/capture artifacts, uncleaned
|
||||
* listeners) so the scorer is not tuned to any specific site — it measures the
|
||||
* failure *modes* a robotic converter falls into, not any one output.
|
||||
*
|
||||
* Pure + deterministic: same directory ⇒ same score.
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, extname, basename, relative } from "node:path";
|
||||
|
||||
// ===========================================================================
|
||||
// CALIBRATION CONSTANTS
|
||||
// ---------------------------------------------------------------------------
|
||||
// These are CALIBRATION GUIDES, expected to be tuned as the generator and our
|
||||
// taste evolve — they are NOT compliance targets or contractual thresholds. A
|
||||
// number here answers "where does a human reviewer start wincing?", set from
|
||||
// observed good vs. bad outputs, and should be re-fit (not litigated) whenever
|
||||
// calibration drifts. Every knob lives in this one block on purpose.
|
||||
// ===========================================================================
|
||||
const K = {
|
||||
// ---- payload discipline (a file/line so big it is not human-readable) ----
|
||||
FILE_BYTES_GOOD: 30_000, // ≤30KB source file: comfortable to open
|
||||
FILE_BYTES_BAD: 160_000, // ≥160KB: a reviewer scrolls forever; scores ~0
|
||||
LINE_CHARS_GOOD: 2_000, // a formatted line, allowing for a dense data/JSX row
|
||||
LINE_CHARS_BAD: 30_000, // a giant one-liner (minified / HTML-as-string prop)
|
||||
INLINE_BYTES_GOOD: 50_000, // total base64 / inline-HTML bytes that's forgivable
|
||||
INLINE_BYTES_BAD: 2_000_000, // 2MB+ of embedded blobs: catastrophic payload
|
||||
// Catastrophe caps: any ONE of these forces the whole app into D-range.
|
||||
CATASTROPHE_FILE_BYTES: 1_000_000, // a >1MB source file
|
||||
CATASTROPHE_LINE_CHARS: 100_000, // a >100KB single line
|
||||
CATASTROPHE_INLINE_BYTES: 5_000_000, // >5MB of embedded base64/HTML
|
||||
CAP_GRADE_D: 68, // ceiling applied when a catastrophe fires — top of the D band, so a
|
||||
// catastrophic payload can grade no better than D+ regardless of other dimensions
|
||||
// Softer cap for "very bad but not unopenable" payloads.
|
||||
WARN_FILE_BYTES: 350_000,
|
||||
WARN_LINE_CHARS: 40_000,
|
||||
CAP_GRADE_C: 78, // ceiling for the softer warning band (top of C+)
|
||||
|
||||
// ---- component decomposition (is the page a monolith?) ----
|
||||
MONOLITH_DOMINANCE_GOOD: 0.3, // biggest file ≤30% of all tags → well spread
|
||||
MONOLITH_DOMINANCE_BAD: 0.75, // one file holds ≥75% of the page → monolith
|
||||
BIG_SECTION_LINES: 600, // a "section" this long is really a whole page
|
||||
COMPONENTS_GOOD: 12, // this many real components → full decomposition credit
|
||||
|
||||
// ---- duplication ----
|
||||
DUP_HELPER_RATIO_BAD: 0.4, // ≥40% of helper defs are copy-paste duplicates
|
||||
DUP_SVG_PATH_RATIO_BAD: 0.85, // ≥85% of inline <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(""));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user