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(),
|
||||
|
||||
+116
-117
@@ -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"));
|
||||
}
|
||||
};
|
||||
walk(dir);
|
||||
const tsx = tsxParts.join("\n"), css = cssParts.join("\n");
|
||||
return { tsx, css, all: tsx + "\n" + css };
|
||||
/** 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; }
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
// 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++;
|
||||
/** 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; }
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 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 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);
|
||||
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}`);
|
||||
}
|
||||
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);
|
||||
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 (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"));
|
||||
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 { inputDir: d, scanDir: d };
|
||||
}
|
||||
return L.join("\n");
|
||||
}
|
||||
|
||||
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(""));
|
||||
}
|
||||
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(""));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { IR, IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
|
||||
import type { RawSizing } from "../src/capture/walker.js";
|
||||
import { generateCss } from "../src/generate/css.js";
|
||||
|
||||
const VPS = [375, 1280];
|
||||
@@ -290,3 +291,196 @@ describe("generateCss scroll-timeline animation suppression", () => {
|
||||
assert.ok(rule.includes("animation-name:fadeInUp"), `time-based reveal keeps its animation, got: ${rule}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-viewport node builder (independent bbox / computed / sizing per width) plus a matching
|
||||
// 3-viewport IR wrapper — the fluid/centring/wrap detectors need ≥2 varying widths to run.
|
||||
const XVPS = [375, 768, 1280];
|
||||
type XPerVp = { cs?: StyleMap; bbox: BBox; sizing?: RawSizing; visible?: boolean };
|
||||
function xNode(id: string, tag: string, byVp: Record<number, XPerVp>, children: IRChild[] = []): IRNode {
|
||||
const computedByVp: Record<number, StyleMap> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
const sizingByVp: Record<number, RawSizing> = {};
|
||||
for (const vp of XVPS) {
|
||||
const s = byVp[vp]!;
|
||||
computedByVp[vp] = computed(s.cs);
|
||||
bboxByVp[vp] = s.bbox;
|
||||
visibleByVp[vp] = s.visible ?? true;
|
||||
if (s.sizing) sizingByVp[vp] = s.sizing;
|
||||
}
|
||||
const n: IRNode = { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
|
||||
if (Object.keys(sizingByVp).length) n.sizingByVp = sizingByVp;
|
||||
return n;
|
||||
}
|
||||
function xIr(root: IRNode): IR {
|
||||
const ir = irWith(root);
|
||||
ir.doc.viewports = XVPS;
|
||||
ir.doc.sampleViewports = XVPS;
|
||||
ir.doc.perViewport = Object.fromEntries(XVPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)", bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial" }]));
|
||||
return ir;
|
||||
}
|
||||
/** Every `.c<id>{…}` body (base + banded) concatenated, for asserting a value appears at some vp. */
|
||||
function allRulesX(css: string, id: string): string {
|
||||
const re = new RegExp(`\\.c${id}\\{([^}]*)\\}`, "g");
|
||||
let out = "", m: RegExpExecArray | null;
|
||||
while ((m = re.exec(css))) out += m[1] + ";";
|
||||
return out;
|
||||
}
|
||||
/** The `.c<id>{…}` body inside the first @media block whose query matches `mediaRe`. */
|
||||
function xBandRule(css: string, mediaRe: RegExp, id: string): string {
|
||||
for (const m of css.matchAll(/@media ([^{]+) \{\n([\s\S]*?)\n\}/g)) {
|
||||
if (!mediaRe.test(m[1]!)) continue;
|
||||
const r = m[2]!.match(new RegExp(`\\.c${id}\\{([^}]*)\\}`));
|
||||
if (r) return r[1]!;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// BUG A — a padded pill/section with LITERAL equal side margins (a fraction of the viewport, varying
|
||||
// across widths) must keep those px margins per band. The width-fill sizing probe is what tells the
|
||||
// centring detector these are load-bearing spacing, not margin-auto centring slack: on a box the
|
||||
// probe reads as a container-fill (width:100% reproduces it), `margin:auto` resolves to 0 and would
|
||||
// blow the box out to full-bleed, deleting the real margins.
|
||||
describe("generateCss literal-margin vs auto-centring", () => {
|
||||
const fill = (): RawSizing => ({ wAuto: false, wFill: true, hAuto: true, hFill: true });
|
||||
// A flex parent spanning the whole viewport, holding one padded pill child that fills the space
|
||||
// BETWEEN its literal side margins (box + 2×margin == container at every width).
|
||||
function pill() {
|
||||
const child = xNode("n1", "div", {
|
||||
375: { cs: { display: "flex", marginLeft: "15px", marginRight: "15px", width: "345px" }, bbox: { x: 15, y: 0, width: 345, height: 62 }, sizing: fill() },
|
||||
768: { cs: { display: "flex", marginLeft: "30.7188px", marginRight: "30.7188px", width: "706.562px" }, bbox: { x: 30.72, y: 0, width: 706.56, height: 62 }, sizing: fill() },
|
||||
1280: { cs: { display: "flex", marginLeft: "25.5938px", marginRight: "25.5938px", width: "1228.81px" }, bbox: { x: 25.59, y: 0, width: 1228.81, height: 62 }, sizing: fill() },
|
||||
});
|
||||
const parent = xNode("n0", "body", {
|
||||
375: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 375, height: 62 } },
|
||||
768: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 768, height: 62 } },
|
||||
1280: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 1280, height: 62 } },
|
||||
}, [child]);
|
||||
return parent;
|
||||
}
|
||||
|
||||
it("keeps the literal px side margins on a width-filling pill (no mx-auto)", () => {
|
||||
const css = generateCss(xIr(pill()), new Map());
|
||||
const all = allRulesX(css, "n1");
|
||||
assert.ok(!/margin-left:auto/.test(all), `filling pill must not be centred with auto margins, got: ${all}`);
|
||||
assert.ok(baseRule(css, "n1").includes("margin-left:25.5938px"), `base keeps the 1280 literal margin, got: ${baseRule(css, "n1")}`);
|
||||
// The narrowest band (a `max-width` query with no `min-width`) carries the 375-vp literal margin.
|
||||
const mobile = xBandRule(css, /^\(max-width/, "n1");
|
||||
assert.ok(/margin-left:15px/.test(mobile), `mobile band keeps its literal 15px margin, got: ${mobile}`);
|
||||
});
|
||||
|
||||
it("still emits margin:auto for a genuinely centred, width-CONSTRAINED block", () => {
|
||||
// Content-sized (not a fill): the probe says width:auto re-derives it, width narrower than the
|
||||
// container with symmetric slack that varies with width — real margin-auto centring.
|
||||
const auto = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false });
|
||||
const child = xNode("n1", "div", {
|
||||
375: { cs: { display: "block", marginLeft: "15px", marginRight: "15px", width: "345px" }, bbox: { x: 15, y: 0, width: 345, height: 40 }, sizing: auto() },
|
||||
768: { cs: { display: "block", marginLeft: "84px", marginRight: "84px", width: "600px" }, bbox: { x: 84, y: 0, width: 600, height: 40 }, sizing: auto() },
|
||||
1280: { cs: { display: "block", marginLeft: "340px", marginRight: "340px", width: "600px" }, bbox: { x: 340, y: 0, width: 600, height: 40 }, sizing: auto() },
|
||||
});
|
||||
const parent = xNode("n0", "body", {
|
||||
375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 375, height: 40 } },
|
||||
768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 768, height: 40 } },
|
||||
1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 1280, height: 40 } },
|
||||
}, [child]);
|
||||
const css = generateCss(xIr(parent), new Map());
|
||||
const all = allRulesX(css, "n1");
|
||||
assert.ok(/margin-left:auto/.test(all), `a constrained centred block should still auto-centre, got: ${all}`);
|
||||
});
|
||||
});
|
||||
|
||||
// BUG C — a single-line text leaf whose unwrapped width nearly fills its column at every width gets
|
||||
// `white-space:nowrap`, so a sub-pixel column shortfall in the clone can't wrap it to a second line.
|
||||
describe("generateCss wrap-vulnerable single-line text", () => {
|
||||
// Text bbox exactly fills the column (wMax == avail == bbox.width) at every width, single line
|
||||
// (height == line-height), and is genuinely wrappable (wMin < wMax).
|
||||
function edgeText(wMin: number) {
|
||||
const szAt = (w: number): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false, wMin, wMax: w });
|
||||
const leaf = xNode("n1", "div", {
|
||||
375: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 107.81, height: 24 }, sizing: szAt(107.81) },
|
||||
768: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 107.81, height: 24 }, sizing: szAt(107.81) },
|
||||
1280: { cs: { display: "block", lineHeight: "20px" }, bbox: { x: 0, y: 0, width: 107.81, height: 20 }, sizing: szAt(107.81) },
|
||||
}, [{ text: "CEO — Academy" }]);
|
||||
const parent = xNode("n0", "body", {
|
||||
375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 107.81, height: 24 } },
|
||||
768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 107.81, height: 24 } },
|
||||
1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 107.81, height: 20 } },
|
||||
}, [leaf]);
|
||||
return parent;
|
||||
}
|
||||
|
||||
it("emits white-space:nowrap for text flush against its column edge", () => {
|
||||
const css = generateCss(xIr(edgeText(58.33)), new Map());
|
||||
assert.ok(baseRule(css, "n1").includes("white-space:nowrap"), `edge-flush single-line text should get nowrap, got: ${baseRule(css, "n1")}`);
|
||||
});
|
||||
|
||||
it("does NOT emit nowrap for a single unbreakable token (wMin == wMax — can't wrap)", () => {
|
||||
const css = generateCss(xIr(edgeText(107.81)), new Map());
|
||||
assert.ok(!baseRule(css, "n1").includes("white-space:nowrap"), `an unbreakable token needs no nowrap, got: ${baseRule(css, "n1")}`);
|
||||
});
|
||||
|
||||
it("does NOT emit nowrap for a genuinely wrapping multi-line paragraph", () => {
|
||||
// Two line boxes tall (height ≈ 2×line-height) → already wrapping, must stay wrappable.
|
||||
const wMin = 100, wMax = 400;
|
||||
const szAt = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false, wMin, wMax });
|
||||
const para = xNode("n1", "p", {
|
||||
375: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 345, height: 72 }, sizing: szAt() },
|
||||
768: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 700, height: 48 }, sizing: szAt() },
|
||||
1280: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 400, height: 48 }, sizing: szAt() },
|
||||
}, [{ text: "A longer paragraph that wraps across multiple lines depending on the width." }]);
|
||||
const parent = xNode("n0", "body", {
|
||||
375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 345, height: 72 } },
|
||||
768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 700, height: 48 } },
|
||||
1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 400, height: 48 } },
|
||||
}, [para]);
|
||||
const css = generateCss(xIr(parent), new Map());
|
||||
assert.ok(!allRulesX(css, "n1").includes("white-space:nowrap"), `a wrapping paragraph must not be forced nowrap, got: ${allRulesX(css, "n1")}`);
|
||||
});
|
||||
|
||||
it("does NOT emit nowrap for text with comfortable slack in its container", () => {
|
||||
const szAt = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false, wMin: 60, wMax: 90 });
|
||||
const leaf = xNode("n1", "div", {
|
||||
375: { cs: { display: "block", lineHeight: "20px" }, bbox: { x: 0, y: 0, width: 90, height: 20 }, sizing: szAt() },
|
||||
768: { cs: { display: "block", lineHeight: "20px" }, bbox: { x: 0, y: 0, width: 90, height: 20 }, sizing: szAt() },
|
||||
1280: { cs: { display: "block", lineHeight: "20px" }, bbox: { x: 0, y: 0, width: 90, height: 20 }, sizing: szAt() },
|
||||
}, [{ text: "Nav link" }]);
|
||||
// Wide container (300px+) — the 90px text has plenty of room, no wrap risk.
|
||||
const parent = xNode("n0", "body", {
|
||||
375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 375, height: 20 } },
|
||||
768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 768, height: 20 } },
|
||||
1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 1280, height: 20 } },
|
||||
}, [leaf]);
|
||||
const css = generateCss(xIr(parent), new Map());
|
||||
assert.ok(!baseRule(css, "n1").includes("white-space:nowrap"), `slack text needs no nowrap, got: ${baseRule(css, "n1")}`);
|
||||
});
|
||||
});
|
||||
|
||||
// Cross-band transform identity — a node with a NON-identity transform at one band must emit the
|
||||
// explicit identity `transform:none` at the bands where the source is untransformed, so the
|
||||
// transform can't cascade across bands and freeze at a width the source left untransformed.
|
||||
describe("generateCss cross-band transform identity", () => {
|
||||
it("emits transform:none at a band where a node with a non-identity transform elsewhere is identity", () => {
|
||||
const el = xNode("n1", "div", {
|
||||
375: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 375, height: 40 } },
|
||||
768: { cs: { transform: "matrix(1, 0, 0, 1, 40, 0)" }, bbox: { x: 40, y: 0, width: 375, height: 40 } },
|
||||
1280: { cs: { transform: "matrix(1, 0, 0, 1, 40, 0)" }, bbox: { x: 40, y: 0, width: 375, height: 40 } },
|
||||
});
|
||||
const root = xNode("n0", "body", { 375: { bbox: { x: 0, y: 0, width: 375, height: 40 } }, 768: { bbox: { x: 0, y: 0, width: 768, height: 40 } }, 1280: { bbox: { x: 0, y: 0, width: 1280, height: 40 } } }, [el]);
|
||||
const css = generateCss(xIr(root), new Map());
|
||||
const all = allRulesX(css, "n1");
|
||||
assert.ok(/transform:matrix/.test(all), `the non-identity transform must be emitted, got: ${all}`);
|
||||
assert.ok(/transform:none/.test(all), `the identity band must emit transform:none so it can't cascade, got: ${all}`);
|
||||
});
|
||||
|
||||
it("does NOT emit transform:none for a node that is identity at every band", () => {
|
||||
const el = xNode("n1", "div", {
|
||||
375: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 375, height: 40 } },
|
||||
768: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 375, height: 40 } },
|
||||
1280: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 375, height: 40 } },
|
||||
});
|
||||
const root = xNode("n0", "body", { 375: { bbox: { x: 0, y: 0, width: 375, height: 40 } }, 768: { bbox: { x: 0, y: 0, width: 768, height: 40 } }, 1280: { bbox: { x: 0, y: 0, width: 1280, height: 40 } } }, [el]);
|
||||
const css = generateCss(xIr(root), new Map());
|
||||
assert.ok(!allRulesX(css, "n1").includes("transform:none"), `an always-identity node needs no explicit transform, got: ${allRulesX(css, "n1")}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { deflateRawSync } from "node:zlib";
|
||||
import { isZipArchive, extractDotLottieJson, readZipEntry } from "../src/capture/dotlottie.js";
|
||||
import { extFromUrl } from "../src/capture/capture.js";
|
||||
|
||||
/**
|
||||
* Build a minimal, spec-correct ZIP archive in-memory from named entries. Supports both
|
||||
* stored (method 0) and deflated (method 8) compression so the extractor is exercised on both.
|
||||
* No CRC validation is needed for our reader, so CRC fields are left 0.
|
||||
*/
|
||||
function buildZip(entries: Array<{ name: string; data: Buffer; deflate?: boolean }>): Buffer {
|
||||
const locals: Buffer[] = [];
|
||||
const centrals: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const e of entries) {
|
||||
const nameBuf = Buffer.from(e.name, "utf8");
|
||||
const stored = e.deflate ? deflateRawSync(e.data) : e.data;
|
||||
const method = e.deflate ? 8 : 0;
|
||||
|
||||
const local = Buffer.alloc(30 + nameBuf.length);
|
||||
local.writeUInt32LE(0x04034b50, 0); // local file header sig
|
||||
local.writeUInt16LE(20, 4); // version needed
|
||||
local.writeUInt16LE(0, 6); // flags
|
||||
local.writeUInt16LE(method, 8);
|
||||
local.writeUInt16LE(0, 10); // time
|
||||
local.writeUInt16LE(0, 12); // date
|
||||
local.writeUInt32LE(0, 14); // crc
|
||||
local.writeUInt32LE(stored.length, 18); // comp size
|
||||
local.writeUInt32LE(e.data.length, 22); // uncomp size
|
||||
local.writeUInt16LE(nameBuf.length, 26);
|
||||
local.writeUInt16LE(0, 28); // extra len
|
||||
nameBuf.copy(local, 30);
|
||||
const localFull = Buffer.concat([local, stored]);
|
||||
locals.push(localFull);
|
||||
|
||||
const central = Buffer.alloc(46 + nameBuf.length);
|
||||
central.writeUInt32LE(0x02014b50, 0); // central dir sig
|
||||
central.writeUInt16LE(20, 4); // version made by
|
||||
central.writeUInt16LE(20, 6); // version needed
|
||||
central.writeUInt16LE(0, 8); // flags
|
||||
central.writeUInt16LE(method, 10);
|
||||
central.writeUInt16LE(0, 12); // time
|
||||
central.writeUInt16LE(0, 14); // date
|
||||
central.writeUInt32LE(0, 16); // crc
|
||||
central.writeUInt32LE(stored.length, 20); // comp size
|
||||
central.writeUInt32LE(e.data.length, 24); // uncomp size
|
||||
central.writeUInt16LE(nameBuf.length, 28);
|
||||
central.writeUInt16LE(0, 30); // extra
|
||||
central.writeUInt16LE(0, 32); // comment
|
||||
central.writeUInt16LE(0, 34); // disk
|
||||
central.writeUInt16LE(0, 36); // internal attrs
|
||||
central.writeUInt32LE(0, 38); // external attrs
|
||||
central.writeUInt32LE(offset, 42); // local header offset
|
||||
nameBuf.copy(central, 46);
|
||||
centrals.push(central);
|
||||
|
||||
offset += localFull.length;
|
||||
}
|
||||
|
||||
const centralStart = offset;
|
||||
const centralDir = Buffer.concat(centrals);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(0, 4); // disk
|
||||
eocd.writeUInt16LE(0, 6); // cd start disk
|
||||
eocd.writeUInt16LE(entries.length, 8);
|
||||
eocd.writeUInt16LE(entries.length, 10);
|
||||
eocd.writeUInt32LE(centralDir.length, 12);
|
||||
eocd.writeUInt32LE(centralStart, 16);
|
||||
eocd.writeUInt16LE(0, 20); // comment len
|
||||
|
||||
return Buffer.concat([...locals, centralDir, eocd]);
|
||||
}
|
||||
|
||||
const ANIM_1 = { v: "5.7.4", nm: "one", layers: [{ ind: 1 }] };
|
||||
const ANIM_2 = { v: "5.7.4", nm: "two", layers: [{ ind: 2 }] };
|
||||
const MANIFEST = { version: "1.0", animations: [{ id: "animation_default" }, { id: "animation_2" }] };
|
||||
|
||||
describe("dotLottie ZIP extraction", () => {
|
||||
it("detects ZIP archives by the PK local-header magic", () => {
|
||||
assert.equal(isZipArchive(Buffer.from([0x50, 0x4b, 0x03, 0x04, 0, 0])), true);
|
||||
assert.equal(isZipArchive(Buffer.from('{"v":"5.7"}', "utf8")), false);
|
||||
assert.equal(isZipArchive(Buffer.alloc(2)), false);
|
||||
});
|
||||
|
||||
it("extracts the manifest's default animation JSON from a stored dotLottie ZIP", () => {
|
||||
const zip = buildZip([
|
||||
{ name: "manifest.json", data: Buffer.from(JSON.stringify(MANIFEST), "utf8") },
|
||||
{ name: "animations/animation_default.json", data: Buffer.from(JSON.stringify(ANIM_1), "utf8") },
|
||||
{ name: "animations/animation_2.json", data: Buffer.from(JSON.stringify(ANIM_2), "utf8") },
|
||||
]);
|
||||
const out = extractDotLottieJson(zip);
|
||||
assert.ok(out, "expected an extracted animation buffer");
|
||||
assert.deepEqual(JSON.parse(out!.toString("utf8")), ANIM_1);
|
||||
});
|
||||
|
||||
it("extracts a DEFLATE-compressed animation entry (the common real-world case)", () => {
|
||||
const zip = buildZip([
|
||||
{ name: "manifest.json", data: Buffer.from(JSON.stringify(MANIFEST), "utf8"), deflate: true },
|
||||
{ name: "animations/animation_default.json", data: Buffer.from(JSON.stringify(ANIM_1), "utf8"), deflate: true },
|
||||
]);
|
||||
const out = extractDotLottieJson(zip);
|
||||
assert.ok(out);
|
||||
assert.deepEqual(JSON.parse(out!.toString("utf8")), ANIM_1);
|
||||
});
|
||||
|
||||
it("falls back to the name-sorted first animation when the manifest is absent", () => {
|
||||
const zip = buildZip([
|
||||
{ name: "animations/b.json", data: Buffer.from(JSON.stringify(ANIM_2), "utf8") },
|
||||
{ name: "animations/a.json", data: Buffer.from(JSON.stringify(ANIM_1), "utf8") },
|
||||
]);
|
||||
const out = extractDotLottieJson(zip);
|
||||
assert.ok(out);
|
||||
assert.deepEqual(JSON.parse(out!.toString("utf8")), ANIM_1); // "a.json" sorts first
|
||||
});
|
||||
|
||||
it("returns null for non-ZIP bytes and for a ZIP with no animations", () => {
|
||||
assert.equal(extractDotLottieJson(Buffer.from('{"v":"5.7"}', "utf8")), null);
|
||||
const noAnims = buildZip([{ name: "manifest.json", data: Buffer.from("{}", "utf8") }]);
|
||||
assert.equal(extractDotLottieJson(noAnims), null);
|
||||
});
|
||||
|
||||
it("reads a named entry directly", () => {
|
||||
const zip = buildZip([{ name: "manifest.json", data: Buffer.from(JSON.stringify(MANIFEST), "utf8"), deflate: true }]);
|
||||
const m = readZipEntry(zip, "manifest.json");
|
||||
assert.ok(m);
|
||||
assert.deepEqual(JSON.parse(m!.toString("utf8")), MANIFEST);
|
||||
assert.equal(readZipEntry(zip, "missing.json"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("asset extension preservation (extFromUrl)", () => {
|
||||
it("preserves real long extensions instead of truncating to 5 chars", () => {
|
||||
assert.equal(extFromUrl("https://x/anim.lottie"), "lottie");
|
||||
assert.equal(extFromUrl("https://x/site.webmanifest"), "webmanifest");
|
||||
assert.equal(extFromUrl("https://x/pic.png?v=2"), "png");
|
||||
assert.equal(extFromUrl("https://x/font.woff2"), "woff2");
|
||||
});
|
||||
|
||||
it("rejects absurdly long or non-alphanumeric trailing segments", () => {
|
||||
assert.equal(extFromUrl("https://x/name.thisisnotanextension"), "");
|
||||
assert.equal(extFromUrl("https://x/dir.with.dots/file"), ""); // dot is in a path segment, not the file
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { DITTO_LOTTIE_TSX } from "../src/generate/lottie.js";
|
||||
import { isIdentityTransform, canonicalizeTransforms } from "../src/normalize/ir.js";
|
||||
import type { StyleMap } from "../src/normalize/ir.js";
|
||||
|
||||
/**
|
||||
* The DittoLottie runtime must NOT erase the captured placeholder frame before the animation
|
||||
* has successfully loaded — a failed load would otherwise blank the container (erasing hero /
|
||||
* logo / footer media). These are string assertions on the emitted 'use client' template.
|
||||
*/
|
||||
describe("DittoLottie placeholder retention", () => {
|
||||
it("does not clear the container's innerHTML before mounting", () => {
|
||||
assert.ok(
|
||||
!/el\.innerHTML\s*=\s*""/.test(DITTO_LOTTIE_TSX),
|
||||
"template must not eagerly clear the placeholder via el.innerHTML = \"\"",
|
||||
);
|
||||
});
|
||||
|
||||
it("mounts the live animation into a separate overlay child, not the container itself", () => {
|
||||
assert.match(DITTO_LOTTIE_TSX, /createElement\(["']div["']\)/);
|
||||
assert.match(DITTO_LOTTIE_TSX, /container:\s*mount/);
|
||||
});
|
||||
|
||||
it("only removes the placeholder after a successful load event (DOMLoaded)", () => {
|
||||
// The reveal/swap must be gated behind lottie's ready event, and the placeholder removal
|
||||
// must live inside that gated path (removing original children once mount is ready).
|
||||
assert.match(DITTO_LOTTIE_TSX, /addEventListener\(\s*["']DOMLoaded["']/);
|
||||
assert.match(DITTO_LOTTIE_TSX, /removeChild/);
|
||||
// The removal must reference the ready swap, not run unconditionally at mount time.
|
||||
const revealIdx = DITTO_LOTTIE_TSX.indexOf("DOMLoaded");
|
||||
const clearIdx = DITTO_LOTTIE_TSX.indexOf("removeChild");
|
||||
assert.ok(revealIdx >= 0 && clearIdx >= 0, "both the ready gate and the placeholder removal must be present");
|
||||
});
|
||||
|
||||
it("handles a failed load without leaving a broken mount stacked over the placeholder", () => {
|
||||
assert.match(DITTO_LOTTIE_TSX, /data_failed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity-transform canonicalization (isIdentityTransform)", () => {
|
||||
it("treats none and identity matrices as identity", () => {
|
||||
assert.equal(isIdentityTransform("none"), true);
|
||||
assert.equal(isIdentityTransform(undefined), true);
|
||||
assert.equal(isIdentityTransform("matrix(1, 0, 0, 1, 0, 0)"), true);
|
||||
assert.equal(isIdentityTransform("matrix(1,0,0,1,0,0)"), true);
|
||||
assert.equal(
|
||||
isIdentityTransform("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT treat a real (non-identity) transform as identity", () => {
|
||||
assert.equal(isIdentityTransform("matrix(1, 0, 0, 1, 0, 67.75)"), false);
|
||||
assert.equal(isIdentityTransform("translateY(67.75px)"), false);
|
||||
assert.equal(isIdentityTransform("matrix(0.5, 0, 0, 0.5, 0, 0)"), false);
|
||||
assert.equal(isIdentityTransform("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 10, 0, 1)"), false);
|
||||
});
|
||||
|
||||
it("rewrites identity values to `none` while leaving a real transform observable at other widths", () => {
|
||||
// The contamination scenario: the base viewport (1280) baked a scroll-linked translateY,
|
||||
// while the other widths report identity (none / identity matrix). After canonicalization
|
||||
// the identity widths read literal `none`, so the generator's per-band delta emits the
|
||||
// explicit reset and the base transform can't cascade across bands.
|
||||
const computedByVp: Record<number, StyleMap> = {
|
||||
375: { transform: "none" } as StyleMap,
|
||||
768: { transform: "matrix(1, 0, 0, 1, 0, 0)" } as StyleMap,
|
||||
1280: { transform: "matrix(1, 0, 0, 1, 0, 67.75)" } as StyleMap, // contaminated base
|
||||
1920: { transform: "none" } as StyleMap,
|
||||
};
|
||||
canonicalizeTransforms(computedByVp);
|
||||
assert.equal(computedByVp[375]!.transform, "none");
|
||||
assert.equal(computedByVp[768]!.transform, "none"); // identity matrix normalized to none
|
||||
assert.equal(computedByVp[1280]!.transform, "matrix(1, 0, 0, 1, 0, 67.75)"); // real transform preserved
|
||||
assert.equal(computedByVp[1920]!.transform, "none");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
medianVelocityPxPerSec,
|
||||
classifyVelocitySamples,
|
||||
hasRepeatedChildren,
|
||||
} from "../src/capture/motion.js";
|
||||
|
||||
// These cover the pure discriminators extracted from detectMarquees' in-browser sampling.
|
||||
// The regression they defend: on a scroll-LINKED-easing page, a static logo row is still
|
||||
// lerping toward its scroll-target right after scrollIntoView, reading as a constant
|
||||
// velocity over ONE short window → four phantom marquees at identical -34px/s. The layered
|
||||
// tests below make that classification fail.
|
||||
|
||||
describe("medianVelocityPxPerSec", () => {
|
||||
it("converts a steady per-120ms delta to px/s and ignores the wrap-reset outlier", () => {
|
||||
// steady -4px per 120ms ≈ -33px/s; one big +200 wrap jump is an outlier the median drops.
|
||||
const deltas = [-4, -4, 200, -4, -4];
|
||||
assert.equal(medianVelocityPxPerSec(deltas, 120), Math.round((-4 / 120) * 1000));
|
||||
});
|
||||
it("returns 0 for empty deltas or a non-positive cadence", () => {
|
||||
assert.equal(medianVelocityPxPerSec([], 120), 0);
|
||||
assert.equal(medianVelocityPxPerSec([-4, -4], 0), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyVelocitySamples — sustained constant velocity (discriminator 2)", () => {
|
||||
const MS = 120;
|
||||
it("PASSES a real marquee: velocity holds across both windows", () => {
|
||||
const w1 = [-4, -4, -4, -4, -4];
|
||||
const w2 = [-4, -4, -4, -4, -4];
|
||||
const r = classifyVelocitySamples(w1, w2, MS);
|
||||
assert.equal(r.isMarquee, true);
|
||||
assert.equal(r.pxPerSec, medianVelocityPxPerSec(w1, MS));
|
||||
});
|
||||
|
||||
it("REJECTS a scroll-settle lerp: velocity decays sharply in window 2 (the false positive)", () => {
|
||||
// window1 still lerping fast toward scroll-target; window2 nearly settled.
|
||||
const w1 = [-4, -4, -4, -4, -4]; // ≈ -33px/s (the phantom "-34px/s")
|
||||
const w2 = [-0.4, -0.4, -0.4, -0.4, -0.4]; // decayed to ~-3px/s
|
||||
assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, false);
|
||||
});
|
||||
|
||||
it("REJECTS when window 2 has fully settled (velocity ~0)", () => {
|
||||
const w1 = [-4, -4, -4, -4, -4];
|
||||
const w2 = [0, 0, 0, 0, 0];
|
||||
assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, false);
|
||||
});
|
||||
|
||||
it("REJECTS when the direction reverses between windows", () => {
|
||||
const w1 = [-4, -4, -4, -4, -4];
|
||||
const w2 = [4, 4, 4, 4, 4];
|
||||
assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, false);
|
||||
});
|
||||
|
||||
it("REJECTS a static row: no motion in either window", () => {
|
||||
assert.equal(classifyVelocitySamples([0, 0, 0], [0, 0, 0], MS).isMarquee, false);
|
||||
});
|
||||
|
||||
it("PASSES a slightly slower-but-still-moving second window (within tolerance)", () => {
|
||||
// small natural jitter (10% slower) must not disqualify a genuine ticker.
|
||||
const w1 = [-4, -4, -4, -4, -4];
|
||||
const w2 = [-3.6, -3.6, -3.6, -3.6, -3.6];
|
||||
assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasRepeatedChildren — genuine duplication (discriminator 4)", () => {
|
||||
it("PASSES two consecutive children with an identical outerHTML hash (a cloned copy)", () => {
|
||||
const hashes = [111, 111, 222]; // first two are a literal duplicate
|
||||
const widths = [80, 80, 120];
|
||||
assert.equal(hasRepeatedChildren(hashes, widths), true);
|
||||
});
|
||||
|
||||
it("PASSES a duplicated content block via the repeated width sequence [A B A B]", () => {
|
||||
// hashes differ (cloned then attribute-tweaked) but geometry repeats: [100,60,100,60].
|
||||
const hashes = [1, 2, 3, 4];
|
||||
const widths = [100, 60, 100, 60];
|
||||
assert.equal(hasRepeatedChildren(hashes, widths), true);
|
||||
});
|
||||
|
||||
it("REJECTS a row of distinct logos: no consecutive repetition (the false-positive shape)", () => {
|
||||
// four DIFFERENT logos — the exact static-logo-row case that produced phantom marquees.
|
||||
const hashes = [10, 20, 30, 40];
|
||||
const widths = [90, 110, 75, 130];
|
||||
assert.equal(hasRepeatedChildren(hashes, widths), false);
|
||||
});
|
||||
|
||||
it("REJECTS fewer than two children", () => {
|
||||
assert.equal(hasRepeatedChildren([5], [50]), false);
|
||||
assert.equal(hasRepeatedChildren([], []), false);
|
||||
});
|
||||
|
||||
it("does NOT treat a run of zero-width nodes as repetition (hash 0 / width 0 guards)", () => {
|
||||
assert.equal(hasRepeatedChildren([0, 0], [0, 0]), false);
|
||||
assert.equal(hasRepeatedChildren([1, 2, 3, 4], [0, 0, 0, 0]), false);
|
||||
});
|
||||
|
||||
it("REJECTS an odd-length width sequence that can't split into halves", () => {
|
||||
const hashes = [1, 2, 3];
|
||||
const widths = [100, 60, 100];
|
||||
assert.equal(hasRepeatedChildren(hashes, widths), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
inlineBlobBytes,
|
||||
filePayload,
|
||||
whitespaceLiterals,
|
||||
subpixelArbitraries,
|
||||
customPropTokens,
|
||||
probeArtifacts,
|
||||
uncleanedListeners,
|
||||
tagHistogram,
|
||||
ariaHiddenFocusables,
|
||||
duplicateHelpers,
|
||||
svgPathDuplication,
|
||||
nearDuplicateComponents,
|
||||
componentNames,
|
||||
toLetter,
|
||||
scoreApp,
|
||||
type SrcFile,
|
||||
} from "../src/runner/qualityScore.js";
|
||||
|
||||
const srcFile = (rel: string, text: string): SrcFile => ({
|
||||
path: rel, rel, ext: rel.slice(rel.lastIndexOf(".")), text, lines: text.split("\n").length,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metric extractors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("inlineBlobBytes", () => {
|
||||
it("counts base64 data-URI payload bytes", () => {
|
||||
const b64 = "A".repeat(500);
|
||||
const text = `const img = "data:image/png;base64,${b64}";`;
|
||||
assert.equal(inlineBlobBytes(text), 500);
|
||||
});
|
||||
|
||||
it("counts a long markup string handed in as a prop", () => {
|
||||
const html = "<div>" + "<span>x</span>".repeat(250) + "</div>";
|
||||
const text = `<Frame html={${JSON.stringify(html)}} />`;
|
||||
assert.ok(inlineBlobBytes(text) > 1000, "flags HTML-as-string blob");
|
||||
});
|
||||
|
||||
it("ignores ordinary short strings", () => {
|
||||
assert.equal(inlineBlobBytes(`const s = "hello world";`), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filePayload", () => {
|
||||
it("reports a giant single line", () => {
|
||||
const giant = "x".repeat(200_000);
|
||||
const p = filePayload(srcFile("a.tsx", `const a = 1;\nconst b = "${giant}";\n`));
|
||||
assert.ok(p.maxLine >= 200_000, "detects the giant line");
|
||||
assert.ok(p.bytes >= 200_000, "counts the bytes");
|
||||
});
|
||||
|
||||
it("a normal formatted file has a small max line", () => {
|
||||
const p = filePayload(srcFile("a.tsx", "const a = 1;\nconst b = 2;\nexport default a;\n"));
|
||||
assert.ok(p.maxLine < 40, "small max line");
|
||||
});
|
||||
});
|
||||
|
||||
describe("whitespaceLiterals", () => {
|
||||
it("counts {\" \"} capture-whitespace literals", () => {
|
||||
const text = `<p>Hi{" "}there{" "}world{' '}!</p>`;
|
||||
assert.equal(whitespaceLiterals(text), 3);
|
||||
});
|
||||
|
||||
it("does not flag meaningful expression literals", () => {
|
||||
assert.equal(whitespaceLiterals(`<p>{name}{count}</p>`), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subpixelArbitraries", () => {
|
||||
it("flags non-integer px/rem arbitraries but not whole ones", () => {
|
||||
// 713.938px (frozen) + 12.5rem (=200px, whole) → only the first counts.
|
||||
const text = `<div className="w-[713.938px] h-[12.5rem] p-[16px]" />`;
|
||||
assert.equal(subpixelArbitraries(text), 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("customPropTokens", () => {
|
||||
it("splits opaque --clr-N / hash tokens from named ones", () => {
|
||||
const css = `:root{ --clr-7:#fff; --c12:#000; --a1b2c3:#111; --brand-primary:#f00; --space-4:1rem; }`;
|
||||
const t = customPropTokens(css);
|
||||
assert.equal(t.total, 5);
|
||||
assert.equal(t.opaque, 3, "clr-7, c12, hash a1b2c3 are opaque; brand-primary/space-4 are named");
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeArtifacts", () => {
|
||||
it("flags off-screen capture-probe scaffolding", () => {
|
||||
const text = `<span data-probe="1">m</span><i style={{clip: 'rect(0 0 0 0)'}} />`;
|
||||
assert.ok(probeArtifacts(text) >= 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uncleanedListeners", () => {
|
||||
it("counts addEventListener with no cleanup", () => {
|
||||
const text = `el.addEventListener("scroll", fn);\nwin.addEventListener("resize", fn);`;
|
||||
assert.equal(uncleanedListeners(text), 2);
|
||||
});
|
||||
|
||||
it("does not flag when a matching removeEventListener / teardown exists", () => {
|
||||
const text = `useEffect(() => { el.addEventListener("scroll", fn); return () => el.removeEventListener("scroll", fn); });`;
|
||||
assert.equal(uncleanedListeners(text), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tagHistogram", () => {
|
||||
it("counts opening element tags by name", () => {
|
||||
const h = tagHistogram(`<div><div/><span>x</span><button>b</button></div>`);
|
||||
assert.equal(h["div"], 2);
|
||||
assert.equal(h["span"], 1);
|
||||
assert.equal(h["button"], 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ariaHiddenFocusables", () => {
|
||||
it("flags aria-hidden on a focusable element", () => {
|
||||
const text = `<button aria-hidden="true">x</button><a aria-hidden={true} href="#">y</a>`;
|
||||
assert.equal(ariaHiddenFocusables(text), 2);
|
||||
});
|
||||
|
||||
it("ignores aria-hidden on a decorative div", () => {
|
||||
assert.equal(ariaHiddenFocusables(`<div aria-hidden="true" />`), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicateHelpers", () => {
|
||||
it("counts copy-paste helper bodies (e.g. a repeated cn())", () => {
|
||||
const body = `{ return classes.filter(Boolean).join(" ").replace(/\\s+/g, " ").trim(); }`;
|
||||
const text = `function cn(...classes) ${body}\nfunction cx(...classes) ${body}\nfunction merge(...classes) ${body}`;
|
||||
const d = duplicateHelpers(text);
|
||||
assert.equal(d.defs, 3);
|
||||
assert.equal(d.dups, 2, "two of the three are identical duplicates");
|
||||
});
|
||||
|
||||
it("does not flag distinct helper bodies", () => {
|
||||
const text = `function a(x) { return x + 1111111111; }\nfunction b(x) { return x - 2222222222; }`;
|
||||
assert.equal(duplicateHelpers(text).dups, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("svgPathDuplication", () => {
|
||||
it("counts repeated inline <path d=...> strings", () => {
|
||||
const d = "M10 10 L20 20 L30 30 Z aaaaaaaaaaaaaaaa";
|
||||
const text = `<path d="${d}"/><path d="${d}"/><path d="M1 1 L2 2 different pathhhhhhhh"/>`;
|
||||
const r = svgPathDuplication(text);
|
||||
assert.equal(r.total, 3);
|
||||
assert.equal(r.repeats, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nearDuplicateComponents", () => {
|
||||
it("counts pairs sharing a tag signature", () => {
|
||||
assert.equal(nearDuplicateComponents(["div:2,span:1", "div:2,span:1", "nav:1"]), 1);
|
||||
assert.equal(nearDuplicateComponents(["a", "a", "a"]), 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("componentNames", () => {
|
||||
it("extracts exported + declared component symbols", () => {
|
||||
const text = `export default function HeroSection(){}\nexport function Footer(){}\nconst Navbar = () => {};`;
|
||||
const names = componentNames(text);
|
||||
assert.ok(names.includes("HeroSection"));
|
||||
assert.ok(names.includes("Footer"));
|
||||
assert.ok(names.includes("Navbar"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("toLetter", () => {
|
||||
it("maps scores to the expected grade bands", () => {
|
||||
assert.equal(toLetter(95), "A");
|
||||
assert.equal(toLetter(82), "B-");
|
||||
assert.equal(toLetter(75), "C");
|
||||
assert.equal(toLetter(67), "D+");
|
||||
assert.equal(toLetter(59), "F");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scoreApp — end-to-end on synthetic app trees (generic fixture content)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeTree(files: Record<string, string>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "qs-"));
|
||||
for (const [rel, text] of Object.entries(files)) {
|
||||
const p = join(root, rel);
|
||||
mkdirSync(p.slice(0, p.lastIndexOf("/")), { recursive: true });
|
||||
writeFileSync(p, text);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("scoreApp — hard cap on catastrophic payload", () => {
|
||||
it("caps a tree containing a multi-megabyte source file into D-range", () => {
|
||||
const giant = "x".repeat(1_200_000); // >1MB single file
|
||||
const root = makeTree({
|
||||
"src/app/page.tsx": `export default function Page(){ return <main><h1>Hi</h1><section><p>ok</p></section></main>; }`,
|
||||
"src/app/svgs/blob.tsx": `export const blob = "${giant}";`,
|
||||
});
|
||||
try {
|
||||
const rep = scoreApp(root);
|
||||
assert.ok(rep.caps.length > 0, "a catastrophe cap is recorded");
|
||||
assert.ok(rep.total <= 68, `grade is capped into D-range, got ${rep.total}`);
|
||||
assert.ok(["D+", "D", "D-", "F"].includes(rep.grade), `grade ${rep.grade} is D-range or below`);
|
||||
assert.ok(rep.categories.payload!.score < rep.categories.payload!.max * 0.5, "payload dimension collapses");
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoreApp — semantics", () => {
|
||||
it("penalizes a page with no h1 vs one with a single h1", () => {
|
||||
const withH1 = makeTree({
|
||||
"src/app/page.tsx": `export default function Page(){ return <main><h1>Title</h1><section><p>a</p></section><nav><a href="#">x</a></nav></main>; }`,
|
||||
});
|
||||
const noH1 = makeTree({
|
||||
"src/app/page.tsx": `export default function Page(){ return <div><div><div><div><span>a</span></div></div></div></div>; }`,
|
||||
});
|
||||
try {
|
||||
const a = scoreApp(withH1);
|
||||
const b = scoreApp(noH1);
|
||||
assert.ok(a.categories.semantics!.score > b.categories.semantics!.score, "h1 + semantic tags score higher");
|
||||
assert.equal(b.categories.semantics!.metrics.h1, 0);
|
||||
} finally {
|
||||
rmSync(withH1, { recursive: true, force: true });
|
||||
rmSync(noH1, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoreApp — decomposition", () => {
|
||||
it("rates a decomposed tree above a single-file monolith of the same markup", () => {
|
||||
const bodyTags = "<div><p>x</p><a href='#'>l</a></div>".repeat(40);
|
||||
const monolith = makeTree({
|
||||
"src/app/page.tsx": `export default function Page(){ return <main><h1>H</h1>${bodyTags}</main>; }`,
|
||||
});
|
||||
const decomposed = makeTree({
|
||||
"src/app/page.tsx": `import Hero from "./sections/hero";\nimport Feature from "./sections/feature";\nexport default function Page(){ return <main><h1>H</h1><Hero/><Feature/></main>; }`,
|
||||
"src/app/sections/hero.tsx": `export function Hero(){ return <section>${"<div><p>x</p></div>".repeat(20)}</section>; }`,
|
||||
"src/app/sections/feature.tsx": `export function Feature(){ return <section>${"<div><a href='#'>l</a></div>".repeat(20)}</section>; }`,
|
||||
});
|
||||
try {
|
||||
const m = scoreApp(monolith);
|
||||
const d = scoreApp(decomposed);
|
||||
assert.ok(d.categories.decomposition!.score > m.categories.decomposition!.score, "decomposed scores higher on decomposition");
|
||||
assert.ok(d.total > m.total, "and grades higher overall");
|
||||
} finally {
|
||||
rmSync(monolith, { recursive: true, force: true });
|
||||
rmSync(decomposed, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { IR, IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
|
||||
import type { RecipeReport, RecipeCandidate, RecipeResponsiveRegime } from "../src/infer/recipes.js";
|
||||
import { recipeResponsiveClassCleaner } from "../src/generate/app.js";
|
||||
|
||||
// The recipe pass infers a container's column count by grouping item bounding boxes into rows and
|
||||
// taking the widest row. When an item spans multiple tracks that under-reports the real track count,
|
||||
// so the synthesized column plan must NOT override the authored/computed grid geometry that the
|
||||
// Tailwind emitter already baked into the className. These fixtures build a 3-track grid whose main
|
||||
// card spans 2 columns (heuristic reports 2 columns) and assert the emitted classes keep 3 tracks
|
||||
// and the span.
|
||||
|
||||
const VPS = [768, 1280];
|
||||
|
||||
function computed(over: StyleMap = {}): StyleMap {
|
||||
return { display: "block", position: "static", visibility: "visible", whiteSpace: "normal", ...over };
|
||||
}
|
||||
|
||||
function node(id: string, tag: string, byVp: Record<number, StyleMap>, children: IRChild[] = []): IRNode {
|
||||
const computedByVp: Record<number, StyleMap> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
for (const vp of VPS) {
|
||||
computedByVp[vp] = { ...computed(), ...(byVp[vp] ?? {}) };
|
||||
bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 100 };
|
||||
visibleByVp[vp] = true;
|
||||
}
|
||||
return { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
|
||||
}
|
||||
|
||||
function irWith(root: IRNode): IR {
|
||||
return {
|
||||
doc: {
|
||||
sourceUrl: "https://example.test/",
|
||||
title: "Fixture",
|
||||
lang: "en",
|
||||
charset: "UTF-8",
|
||||
metaViewport: "width=device-width, initial-scale=1",
|
||||
viewports: VPS,
|
||||
sampleViewports: VPS,
|
||||
canonicalViewport: 1280,
|
||||
perViewport: Object.fromEntries(VPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255,255,255)", bodyBg: "rgb(255,255,255)", bodyColor: "rgb(0,0,0)", bodyFont: "Arial" }])),
|
||||
nodeCount: 4,
|
||||
keyframes: [],
|
||||
},
|
||||
root,
|
||||
};
|
||||
}
|
||||
|
||||
function regime(viewport: number, columns: number, visibleItems: number): RecipeResponsiveRegime {
|
||||
return { viewport, layout: "grid", rootBox: { x: 0, y: 0, width: viewport, height: 400 }, visibleItems, columns, rows: 1 };
|
||||
}
|
||||
|
||||
function candidate(over: Partial<RecipeCandidate>): RecipeCandidate {
|
||||
return {
|
||||
id: "r0",
|
||||
kind: "product-grid",
|
||||
confidence: 0.89,
|
||||
risk: "low",
|
||||
rootCid: "n0",
|
||||
rootTag: "section",
|
||||
itemParentCid: "n1",
|
||||
componentName: "ProductGridSection",
|
||||
itemCount: 2,
|
||||
repeatedItems: [],
|
||||
responsiveRegimes: [regime(768, 2, 2), regime(1280, 2, 2)],
|
||||
sourceHints: [],
|
||||
signals: [],
|
||||
emissionStatus: "report-only",
|
||||
fallbackReason: "",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function report(candidates: RecipeCandidate[]): RecipeReport {
|
||||
return {
|
||||
version: 1,
|
||||
sourceUrl: "https://example.test/",
|
||||
canonicalViewport: 1280,
|
||||
viewports: VPS,
|
||||
sampledViewports: VPS,
|
||||
summary: { totalCandidates: candidates.length, highConfidence: candidates.length, byKind: {}, templateReadyKinds: [] },
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
// A 3-track grid: the main card spans 2 tracks (`grid-column: 1 / 3`) and a photo occupies track 3.
|
||||
// The item-count heuristic groups the two items into one row → columns = 2, which disagrees with the
|
||||
// computed 3 tracks.
|
||||
function spanningGridIr(): IR {
|
||||
const threeTracks = "284px 284px 284px";
|
||||
const card = node("n2", "article", {
|
||||
768: { gridColumnStart: "1", gridColumnEnd: "3" },
|
||||
1280: { gridColumnStart: "1", gridColumnEnd: "3" },
|
||||
});
|
||||
const photo = node("n3", "img", {
|
||||
768: { gridColumnStart: "3", gridColumnEnd: "4" },
|
||||
1280: { gridColumnStart: "3", gridColumnEnd: "4" },
|
||||
});
|
||||
const parent = node("n1", "div", {
|
||||
768: { display: "grid", gridTemplateColumns: threeTracks },
|
||||
1280: { display: "grid", gridTemplateColumns: threeTracks },
|
||||
}, [card, photo]);
|
||||
return irWith(node("n0", "section", {}, [parent]));
|
||||
}
|
||||
|
||||
describe("recipe grid geometry: computed tracks/spans are ground truth", () => {
|
||||
it("does not override a 3-track grid with a heuristic 2-column plan (span-2 item present)", () => {
|
||||
const ir = spanningGridIr();
|
||||
const c = candidate({ itemParentCid: "n1", repeatedItems: [
|
||||
{ cid: "n2", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 0, y: 0, width: 568, height: 300 } },
|
||||
{ cid: "n3", tag: "img", textSample: "", mediaCount: 1, headingCount: 0, bbox: { x: 584, y: 0, width: 284, height: 300 } },
|
||||
] });
|
||||
const clean = recipeResponsiveClassCleaner(ir, report([c]), { tailwind: true });
|
||||
|
||||
// The Tailwind emitter has already put the authored 3-track grid on the container className.
|
||||
const containerIn = "grid grid-cols-3 gap-4";
|
||||
const containerOut = clean("n1", containerIn)!.split(/\s+/);
|
||||
assert.ok(containerOut.includes("grid-cols-3"), "authored 3-track grid-cols-3 survives");
|
||||
assert.ok(!containerOut.some((t) => /(?:^|:)grid-cols-2$/.test(t)), "no synthesized grid-cols-2 override");
|
||||
// No responsive column-plan tokens are appended (the plan was rejected as untrustworthy).
|
||||
assert.ok(!containerOut.some((t) => /^(?:md|lg|2xl):grid-cols-/.test(t)), "no responsive grid-cols plan appended");
|
||||
|
||||
// The span-2 item keeps its authored column span (emitter tokens pass through untouched).
|
||||
const itemOut = clean("n2", "col-start-1 col-end-3 flex flex-col")!;
|
||||
assert.equal(itemOut, "col-start-1 col-end-3 flex flex-col", "span-2 item className is unchanged");
|
||||
});
|
||||
|
||||
it("still re-flows a genuinely uniform grid whose computed tracks match the heuristic", () => {
|
||||
// 2-track grid at 768, 3-track at 1280, no spanning items → heuristic agrees with computed.
|
||||
const item = (id: string): IRNode => node(id, "article", {
|
||||
768: { gridColumnStart: "auto", gridColumnEnd: "auto" },
|
||||
1280: { gridColumnStart: "auto", gridColumnEnd: "auto" },
|
||||
});
|
||||
const parent = node("n1", "div", {
|
||||
768: { display: "grid", gridTemplateColumns: "300px 300px" },
|
||||
1280: { display: "grid", gridTemplateColumns: "284px 284px 284px" },
|
||||
}, [item("n2"), item("n3"), item("n4")]);
|
||||
const ir = irWith(node("n0", "section", {}, [parent]));
|
||||
const c = candidate({
|
||||
itemParentCid: "n1",
|
||||
itemCount: 3,
|
||||
responsiveRegimes: [regime(768, 2, 3), regime(1280, 3, 3)],
|
||||
repeatedItems: [
|
||||
{ cid: "n2", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 0, y: 0, width: 300, height: 300 } },
|
||||
{ cid: "n3", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 316, y: 0, width: 300, height: 300 } },
|
||||
{ cid: "n4", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 0, y: 316, width: 300, height: 300 } },
|
||||
],
|
||||
});
|
||||
const clean = recipeResponsiveClassCleaner(ir, report([c]), { tailwind: true });
|
||||
const out = clean("n1", "grid grid-cols-2 gap-4")!.split(/\s+/);
|
||||
// Geometry agreed → the responsive plan is applied: base 2 columns, lg:3 at the wider viewport.
|
||||
assert.ok(out.includes("grid-cols-2"), "base column count applied");
|
||||
assert.ok(out.includes("lg:grid-cols-3"), "responsive column bump applied");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { declToUtil } from "../src/generate/tailwind.js";
|
||||
import { declToUtil, snapBase } from "../src/generate/tailwind.js";
|
||||
|
||||
// Zero-value gating. A named `-0` step only exists for props on Tailwind's spacing (or numeric)
|
||||
// scales; for the rest the class compiles to NOTHING — a silent no-op that ships the wrong style
|
||||
@@ -41,3 +41,33 @@ describe("declToUtil zero values", () => {
|
||||
assert.equal(declToUtil("letter-spacing", "-0.5px"), "tracking-[-0.5px]");
|
||||
});
|
||||
});
|
||||
|
||||
// BUG B — the spacing-scale snap must only fire when a value lands ESSENTIALLY ON a step (≤0.25px).
|
||||
// The scale is 2px-granular (`p-0.5`=2px, `p-1.5`=6px), so 3.5px is BETWEEN steps — snapping it up to
|
||||
// p-1 (4px) adds +0.5px per side, which accumulates across a fixed-width flex row until items overflow
|
||||
// and wrap. On-step values (2px→p-0.5, 4px→p-1) still snap.
|
||||
describe("snapBase spacing-scale snapping", () => {
|
||||
it("keeps a between-steps value arbitrary (3.5px does NOT snap to p-1)", () => {
|
||||
assert.equal(snapBase("p-[3.5px]"), "p-[3.5px]");
|
||||
});
|
||||
|
||||
it("does not snap the same sub-step delta on other spacing props", () => {
|
||||
assert.equal(snapBase("pl-[3.5px]"), "pl-[3.5px]");
|
||||
assert.equal(snapBase("gap-[3.5px]"), "gap-[3.5px]");
|
||||
assert.equal(snapBase("mt-[13.5px]"), "mt-[13.5px]"); // between p-3 (12px) and p-3.5 (14px)
|
||||
});
|
||||
|
||||
it("still snaps a value that sits on a 0.5-step (2px → p-0.5, 6px → p-1.5)", () => {
|
||||
assert.equal(snapBase("p-[2px]"), "p-0.5");
|
||||
assert.equal(snapBase("p-[6px]"), "p-1.5");
|
||||
});
|
||||
|
||||
it("still snaps a near-exact on-step value within the tight budget (3.98px → p-1)", () => {
|
||||
assert.equal(snapBase("p-[3.98px]"), "p-1");
|
||||
});
|
||||
|
||||
it("leaves an already-clean scale utility unchanged and snaps 16px → mx-4", () => {
|
||||
assert.equal(snapBase("p-1"), "p-1");
|
||||
assert.equal(snapBase("mx-[16px]"), "mx-4");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,3 +211,109 @@ describe("walker font-metric probe tagging (fix 4)", () => {
|
||||
assert.ok(!sr.probe, "sr-only accessible text is not a probe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("walker sizing probe: circular authored-height guard", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
const capture = async (html: string) => {
|
||||
await page.setContent(html);
|
||||
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||
return page.evaluate(collectPage);
|
||||
};
|
||||
|
||||
it("keeps an explicit 100vh height (circular hero/fill-child pair)", async () => {
|
||||
// A hero authored `height:100vh` with a `height:100%` fill child: setting the hero to
|
||||
// `height:auto` still reproduces its box because the child pins it back, so the raw probe
|
||||
// would read hAuto:true and the authored 100vh would be dropped — hero collapses to 0.
|
||||
const snap = await capture(`
|
||||
<style>
|
||||
.hero { height: 100vh; display: flex; }
|
||||
.fill { height: 100%; width: 100%; }
|
||||
</style>
|
||||
<section class="hero"><div class="fill"><p>content</p></div></section>`);
|
||||
const hero = findByClass(snap.root, "hero")!;
|
||||
assert.ok(hero.sizing, "hero was probed");
|
||||
assert.equal(hero.sizing!.hAuto, false, "explicit 100vh is not content-sized");
|
||||
assert.equal(hero.sizing!.hFill, false, "explicit 100vh is authored, not a parent fill");
|
||||
// Box actually equals the viewport height (720), proving the circular reproduction.
|
||||
assert.ok(Math.abs(hero.bbox.height - 720) <= 1, "hero rendered at 100vh");
|
||||
});
|
||||
|
||||
it("keeps an explicit px height whose fill child reproduces it", async () => {
|
||||
const snap = await capture(`
|
||||
<style>
|
||||
.section { height: 400px; display: flex; }
|
||||
.fill { height: 100%; width: 100%; }
|
||||
</style>
|
||||
<div class="section"><div class="fill"><p>content</p></div></div>`);
|
||||
const section = findByClass(snap.root, "section")!;
|
||||
assert.ok(section.sizing, "section was probed");
|
||||
assert.equal(section.sizing!.hAuto, false, "explicit 400px is not content-sized");
|
||||
assert.equal(section.sizing!.hFill, false, "explicit 400px is authored, not a fill");
|
||||
assert.ok(Math.abs(section.bbox.height - 400) <= 1, "section rendered at 400px");
|
||||
});
|
||||
|
||||
it("keeps an explicit height authored via inline style", async () => {
|
||||
const snap = await capture(`
|
||||
<style>.fill { height: 100%; width: 100%; }</style>
|
||||
<div class="box" style="height: 300px; display: flex;">
|
||||
<div class="fill"><p>content</p></div>
|
||||
</div>`);
|
||||
const box = findByClass(snap.root, "box")!;
|
||||
assert.ok(box.sizing, "box was probed");
|
||||
assert.equal(box.sizing!.hAuto, false, "inline explicit height is kept");
|
||||
assert.equal(box.sizing!.hFill, false, "inline explicit height is not a fill");
|
||||
});
|
||||
|
||||
it("resolves the mutual parent/child pair without disturbing the fill child", async () => {
|
||||
// The child is a GENUINE fill (height:100%) and must keep hFill:true / hAuto:false so the
|
||||
// generator emits h-full for it; only the parent's circular verdict is corrected.
|
||||
const snap = await capture(`
|
||||
<style>
|
||||
.outer { height: 500px; display: flex; }
|
||||
.inner { height: 100%; width: 100%; }
|
||||
</style>
|
||||
<div class="outer"><div class="inner"><p>content</p></div></div>`);
|
||||
const outer = findByClass(snap.root, "outer")!;
|
||||
const inner = findByClass(snap.root, "inner")!;
|
||||
assert.equal(outer.sizing!.hAuto, false, "parent explicit height kept");
|
||||
assert.equal(outer.sizing!.hFill, false, "parent is authored, not a fill");
|
||||
// The child authors only `height:100%` (a fill), so the explicit-height guard must NOT fire on
|
||||
// it — hFill stays true so the generator can still emit h-full for the genuine fill child.
|
||||
assert.equal(inner.sizing!.hFill, true, "child still fills the definite parent");
|
||||
});
|
||||
|
||||
it("still detects a genuinely content-sized (auto) height as hAuto", async () => {
|
||||
// No authored height anywhere: the box is content-sized and must stay droppable.
|
||||
const snap = await capture(`
|
||||
<style>.wrap { display: block; }</style>
|
||||
<div class="wrap"><p>just some flowing text content</p></div>`);
|
||||
const wrap = findByClass(snap.root, "wrap")!;
|
||||
assert.ok(wrap.sizing, "wrap was probed");
|
||||
assert.equal(wrap.sizing!.hAuto, true, "content-sized height is still auto");
|
||||
});
|
||||
|
||||
it("does not treat a percentage or zero authored height as explicit", async () => {
|
||||
// height:100% is the FILL case (handled by hFill), and height:0 is not definite; neither
|
||||
// should trip the explicit-height override.
|
||||
const snap = await capture(`
|
||||
<style>
|
||||
.pct-parent { height: 300px; }
|
||||
.pct { height: 100%; }
|
||||
</style>
|
||||
<div class="pct-parent"><div class="pct"><p>x</p></div></div>`);
|
||||
const pct = findByClass(snap.root, "pct")!;
|
||||
assert.ok(pct.sizing, "pct was probed");
|
||||
// A true fill child: hFill true, hAuto false — untouched by the explicit-height guard.
|
||||
assert.equal(pct.sizing!.hFill, true, "percentage height stays a fill");
|
||||
assert.equal(pct.sizing!.hAuto, false, "percentage fill is not content-sized");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user