Initial commit

This commit is contained in:
Samraaj Bath
2026-06-29 15:11:48 -07:00
commit 66dfdcc58d
404 changed files with 45970 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Classify width-varying nodes in a DENSE capture by their sizing law, using the full
sample curve. Run after a dense clone: python3 scripts/analyze-dense.py <run/.clone> """
import json, sys, statistics, glob, os
run = sys.argv[1] if len(sys.argv) > 1 else "output-dense/sample/.clone"
caps = {}
for f in sorted(glob.glob(os.path.join(run, "source/capture/dom-*.json"))):
vp = int(os.path.basename(f)[4:-5])
caps[vp] = json.load(open(f))["root"]
VPS = sorted(caps)
print("sample widths:", VPS)
def flat(n, a):
if isinstance(n, dict):
a.append(n)
for c in (n.get("children") or []): flat(c, a)
F = {vp: [] for vp in VPS}
for vp in VPS: flat(caps[vp], F[vp])
def wp(n, p, m):
m[id(n)] = p
for c in (n.get("children") or []): wp(c, n, m)
PM = {vp: {} for vp in VPS}
for vp in VPS: wp(caps[vp], None, PM[vp])
def pf(v):
try: return float(str(v).replace("px", ""))
except: return 0.0
n = min(len(F[vp]) for vp in VPS)
cats = {"fills_container": 0, "prop_container": 0, "prop_viewport": 0, "clamped_maxw": 0,
"shrink_recoverable": 0, "real_breakpoint": 0, "fixed": 0, "unknown": 0}
for i in range(n):
nd = {vp: F[vp][i] for vp in VPS}
if not all(nd[vp].get("visible") for vp in VPS): continue
w = {vp: (nd[vp].get("bbox") or {}).get("width", 0) for vp in VPS}
if max(w.values()) - min(w.values()) <= 2: continue # constant → no band
# container content width per vp
cw = {}
for vp in VPS:
p = PM[vp].get(id(nd[vp])); pb = (p or {}).get("bbox"); pcs = (p or {}).get("computed") or {}
base = pb["width"] if pb else vp
cw[vp] = base - pf(pcs.get("paddingLeft")) - pf(pcs.get("paddingRight"))
rc = [w[vp] / cw[vp] for vp in VPS if cw[vp] > 0]
rv = [w[vp] / vp for vp in VPS]
def cv(xs):
m = statistics.mean(xs); return statistics.pstdev(xs) / m if m else 9
wl = [w[vp] for vp in VPS]
if rc and cv(rc) < 0.02 and abs(statistics.mean(rc) - 1) < 0.02: cats["fills_container"] += 1
elif rc and cv(rc) < 0.03: cats["prop_container"] += 1
elif cv(rv) < 0.03: cats["prop_viewport"] += 1
elif wl[-1] == wl[-2] and wl[0] < wl[-1]: # plateau at the widest → clamp or recoverable shrink
# constant above a knee, smaller below
cats["clamped_maxw"] += 1
elif wl[-1] < wl[-2]: # still shrinking even at the widest → natural beyond range
cats["shrink_recoverable"] += 1
else:
# piecewise? count distinct plateaus
cats["unknown"] += 1
print("\nwidth-varying nodes by inferred sizing law (dense):")
for k, v in cats.items(): print(f" {k:20} {v}")
print(f" TOTAL varying: {sum(cats.values())}")
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""For every node whose width VARIES across viewports (=> would emit a width band),
classify the underlying law: hairline / parent-fill / fraction-of-parent / step.
Tells us how many bands each recovery law would eliminate."""
import json, glob, os, sys, collections
run = sys.argv[1] if len(sys.argv) > 1 else "compiler/output/sample/.clone"
caps = {}
for f in sorted(glob.glob(os.path.join(run, "source/capture/dom-*.json"))):
caps[int(os.path.basename(f)[4:-5])] = json.load(open(f))["root"]
VPS = sorted(caps)
# flatten each viewport in pre-order, aligned by index
F = {vp: [] for vp in VPS}
PARENT = []
def flat(n, a, parent_idx, store_parent):
idx = len(a); a.append(n)
if store_parent: PARENT.append(parent_idx)
for c in (n.get("children") or []):
flat(c, a, idx, store_parent)
for vp in VPS:
flat(caps[vp], F[vp], -1, vp == VPS[0])
N = len(F[VPS[0]])
def pf(v):
try: return float(str(v).replace("px",""))
except: return 0.0
def cs(i, vp): return F[vp][i].get("computed") or {}
def bb(i, vp): return F[vp][i].get("bbox") or {}
def vis(i, vp):
if not F[vp][i].get("visible"): return False
if (cs(i,vp).get("display")=="none"): return False
return True
def width(i, vp): return bb(i, vp).get("width", 0)
def parent_content_w(i, vp):
p = PARENT[i]
if p < 0: return bb(i,vp).get("width",0)
pc = cs(p, vp)
return bb(p,vp).get("width",0) - pf(pc.get("paddingLeft")) - pf(pc.get("paddingRight")) - pf(pc.get("borderLeftWidth")) - pf(pc.get("borderRightWidth"))
FRACTIONS = {1/2:"1/2",1/3:"1/3",2/3:"2/3",1/4:"1/4",3/4:"3/4",1/5:"1/5",2/5:"2/5",3/5:"3/5",4/5:"4/5",1/6:"1/6",5/6:"5/6"}
cat = collections.Counter()
band_cat = collections.Counter()
examples = collections.defaultdict(list)
for i in range(N):
ws = {vp: width(i,vp) for vp in VPS if vis(i,vp)}
if len(ws) < 2: continue
wv = list(ws.values())
spread = max(wv) - min(wv)
if spread <= 1.0: continue # constant => no band
# how many band variants would this node emit? (# distinct rounded widths across the 4 band vps) - 1
distinct = len(set(round(w) for w in wv))
bands = distinct - 1
if bands < 1: continue
# --- classify ---
if max(wv) < 2.0:
c = "hairline(<2px)"
else:
ratios = []
ok_parent = True
for vp, w in ws.items():
pcw = parent_content_w(i, vp)
if pcw <= 0: ok_parent = False; break
ratios.append(w / pcw)
if not ok_parent:
c = "no-parent"
elif max(ratios) - min(ratios) < 0.03 and abs(sum(ratios)/len(ratios) - 1.0) < 0.03:
c = "parent-fill(100%)"
else:
avg = sum(ratios)/len(ratios)
# nearest simple fraction
best = min(FRACTIONS, key=lambda f: abs(f-avg))
if max(ratios)-min(ratios) < 0.03 and abs(best-avg) < 0.015:
c = f"fraction~{FRACTIONS[best]}"
elif max(ratios)-min(ratios) < 0.04:
c = "const-%(other)"
else:
c = "step/other"
cat[c] += 1
band_cat[c] += bands
if len(examples[c]) < 4:
examples[c].append((round(min(wv),1), round(max(wv),1), [round(width(i,vp)) for vp in VPS]))
print(f"nodes with width-bands: {sum(cat.values())} total band-variants: {sum(band_cat.values())}")
print(f"{'category':22} {'nodes':>6} {'bands':>6}")
for c,_ in band_cat.most_common():
print(f"{c:22} {cat[c]:>6} {band_cat[c]:>6} eg {examples[c][:3]}")
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Replicate the (updated) reconstructFlexRow and report success + bail reasons + band impact."""
import json, glob, os, sys, collections
run = sys.argv[1] if len(sys.argv) > 1 else "compiler/output-dense/sample/.clone"
caps = {}
for f in sorted(glob.glob(os.path.join(run, "source/capture/dom-*.json"))):
caps[int(os.path.basename(f)[4:-5])] = json.load(open(f))["root"]
VPS = sorted(caps); BAND_VPS = [v for v in (375, 768, 1280, 1920) if v in VPS]
acc = []
def walkidx(n):
n["_i"] = len(acc); acc.append(n); n["_kids"] = []
for c in (n.get("children") or []):
n["_kids"].append(len(acc)); walkidx(c)
walkidx(caps[VPS[0]])
N = len(acc)
# per-vp flat lists aligned by index
F = {vp: [] for vp in VPS}
def flat(n, a):
a.append(n)
for c in (n.get("children") or []): flat(c, a)
for vp in VPS: flat(caps[vp], F[vp])
def pf(v):
try: return float(str(v).replace("px", ""))
except: return 0.0
def CS(i, vp): return F[vp][i].get("computed") or {}
def BB(i, vp): return F[vp][i].get("bbox") or {}
def VIS(i, vp): return F[vp][i].get("visible")
reasons = collections.Counter(); succ = 0; succ_items = 0; bands_removed = 0
for i in range(N):
c0 = CS(i, VPS[0]); disp = c0.get("display", "")
if "flex" not in disp: continue
if c0.get("flexDirection", "row") not in ("row", "row-reverse"): reasons["not_row"] += 1; continue
if (c0.get("flexWrap") or "nowrap") != "nowrap": reasons["wrap"] += 1; continue
info = []; bail = None
for k in acc[i]["_kids"]:
if not acc[k].get("tag"): continue
ck = CS(k, VPS[0]); pos = ck.get("position", "static")
if pos in ("absolute", "fixed"): continue
if (ck.get("float") or "none") != "none": continue
if ck.get("marginLeft") == "auto" or ck.get("marginRight") == "auto": bail = "auto_margin"; break
if (ck.get("boxSizing") or "border-box") == "content-box": bail = "content_box"; break
if pf(ck.get("flexGrow")) > 0: bail = "grow"; break
basis = ck.get("flexBasis") or "auto"
if basis != "auto" and not basis.endswith("px"): bail = "basis_pct"; break
info.append((k, pf(ck.get("flexShrink")), pf(basis) if basis.endswith("px") else None,
pf(ck.get("minWidth")) if (ck.get("minWidth") or "").endswith("px") else 0,
pf(ck.get("maxWidth")) if (ck.get("maxWidth") or "").endswith("px") else float("inf")))
if bail: reasons[bail] += 1; continue
if not info: continue
used = {}; margin = {}; cw = {}; gap = {}
for vp in VPS:
pcs = CS(i, vp); pb = BB(i, vp)
cw[vp] = pb.get("width", 0) - pf(pcs.get("paddingLeft")) - pf(pcs.get("paddingRight"))
gap[vp] = pf(pcs.get("columnGap") if pcs.get("columnGap") not in (None, "normal") else pcs.get("gap"))
u = []; m = []
for (k, sh, bp, mn, mx) in info:
ck = CS(k, vp); hidden = (not VIS(k, vp)) or ck.get("display") == "none"
u.append(None if hidden else BB(k, vp).get("width", 0)); m.append(0 if hidden else pf(ck.get("marginLeft")) + pf(ck.get("marginRight")))
used[vp] = u; margin[vp] = m
def slack(vp):
vis = [j for j in range(len(info)) if used[vp][j] is not None]
return cw[vp] - sum(used[vp][j] for j in vis) - (gap[vp] * max(0, len(vis) - 1) + sum(margin[vp][j] for j in vis))
bases = []; ok = True
for j, (k, sh, bp, mn, mx) in enumerate(info):
if bp is not None: bases.append(bp); continue
base = None
for v in range(len(VPS) - 1, -1, -1):
vp = VPS[v]
if used[vp][j] is None: continue
if slack(vp) >= -0.5: base = used[vp][j]; break
if base is None: ok = False; break
bases.append(base)
if not ok: reasons["no_base"] += 1; continue
bad = False
for vp in VPS:
vis = [j for j in range(len(info)) if used[vp][j] is not None]
if not vis: continue
hyp = [min(max(bases[j], info[j][3]), info[j][4]) for j in vis]
gaps = gap[vp] * max(0, len(vis) - 1) + sum(margin[vp][j] for j in vis)
free = cw[vp] - sum(hyp) - gaps
sim = list(hyp)
if free < -0.5:
ts = sum(info[vis[k]][1] * hyp[k] for k in range(len(vis)))
if ts > 0:
for k in range(len(vis)): sim[k] = max(hyp[k] - abs(free) * (info[vis[k]][1] * hyp[k]) / ts, info[vis[k]][3])
for k in range(len(vis)):
if abs(sim[k] - used[vp][vis[k]]) > 1: bad = True
if bad: reasons["sim_mismatch"] += 1; continue
varies = [j for j in range(len(info)) if len([used[vp][j] for vp in VPS if used[vp][j] is not None]) >= 2 and max(x for vp in VPS if (x:=used[vp][j]) is not None) - min(x for vp in VPS if (x:=used[vp][j]) is not None) > 8]
if not varies: reasons["no_variation"] += 1; continue
succ += 1; succ_items += len(info)
# estimate band variants removed: for each varying item, count distinct band-vp widths != base-vp
for j in varies:
bandw = set(round(used[vp][j]) for vp in BAND_VPS if used[vp][j] is not None)
bands_removed += max(0, len(bandw) - 1)
print(f"SUCCESS lines: {succ} items: {succ_items} est. width-band variants removed: ~{bands_removed}")
for k, v in reasons.most_common(): print(f" bail {k:16} {v}")
+43
View File
@@ -0,0 +1,43 @@
// Run a generated app in Next DEV mode (unminified React) and print hydration errors.
import { spawn } from "node:child_process";
import { cpSync, rmSync, existsSync } from "node:fs";
import { chromium } from "playwright";
const appDir = process.argv[2];
const harness = new URL("../.harness", import.meta.url).pathname;
const PORT = 3987;
for (const s of ["src", "public", "next.config.mjs", "tsconfig.json", "next-env.d.ts", ".next", "out"]) {
const p = `${harness}/${s}`;
if (existsSync(p)) rmSync(p, { recursive: true, force: true });
}
cpSync(`${appDir}/src`, `${harness}/src`, { recursive: true });
if (existsSync(`${appDir}/public`)) cpSync(`${appDir}/public`, `${harness}/public`, { recursive: true });
for (const f of ["next.config.mjs", "tsconfig.json", "next-env.d.ts"]) {
if (existsSync(`${appDir}/${f}`)) cpSync(`${appDir}/${f}`, `${harness}/${f}`);
}
const dev = spawn("./node_modules/.bin/next", ["dev", "-p", String(PORT)], {
cwd: harness, env: { ...process.env, NEXT_TELEMETRY_DISABLED: "1" },
});
let ready = false;
dev.stdout.on("data", (d) => { if (/Ready in|Local:|started server/i.test(d.toString())) ready = true; });
dev.stderr.on("data", () => {});
const t0 = Date.now();
while (!ready && Date.now() - t0 < 90000) await new Promise((r) => setTimeout(r, 500));
await new Promise((r) => setTimeout(r, 4000));
const browser = await chromium.launch();
const page = await browser.newPage();
const msgs = [];
page.on("console", (m) => { msgs.push(m.text()); });
page.on("pageerror", (e) => msgs.push("PAGEERROR: " + e.message));
try { await page.goto(`http://localhost:${PORT}/`, { waitUntil: "networkidle", timeout: 60000 }); } catch (e) { msgs.push("goto: " + e.message); }
await new Promise((r) => setTimeout(r, 3000));
console.log("=== ALL CONSOLE MESSAGES (" + msgs.length + ") ===");
for (const e of msgs.slice(0, 12)) console.log("\n---\n" + e.slice(0, 1500));
await browser.close();
dev.kill("SIGKILL");
process.exit(0);
+50
View File
@@ -0,0 +1,50 @@
// Generate examples/<tier>/RESULTS.md from the latest run per site.
import { readFileSync, readdirSync, existsSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const ROOT = new URL("../..", import.meta.url).pathname;
const RUNS = join(ROOT, "runs");
const tier = process.argv[2] || "stage2";
const sites = JSON.parse(readFileSync(join(ROOT, "compiler", "benchmarks", `${tier}.json`), "utf8"));
function siteId(url) {
const u = new URL(url);
const host = u.hostname.replace(/^www\./, "");
const path = u.pathname.replace(/\/+$/, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "");
return (host + (path ? "-" + path : "")).replace(/[^a-zA-Z0-9.-]/g, "-").slice(0, 80);
}
function latest(host) {
const dir = join(RUNS, host);
if (!existsSync(dir)) return null;
const runs = readdirSync(dir).filter((d) => /^\d/.test(d)).sort();
for (let i = runs.length - 1; i >= 0; i--) {
if (existsSync(join(dir, runs[i], "validation", "report.json"))) return join(dir, runs[i]);
}
return null;
}
let g06 = 0, s2 = 0, sum = 0, n = 0;
const rows = [];
for (const s of sites) {
const run = latest(siteId(s.url));
if (!run) { rows.push(`| ${s.id} | ${s.url} | — | — | no run |`); continue; }
const r = JSON.parse(readFileSync(join(run, "validation", "report.json"), "utf8"));
n++; sum += r.scorecard.total; if (r.gates0to6Pass) g06++; if (r.stage2Pass) s2++;
const fails = Object.entries(r.gates).filter(([, gg]) => !gg.pass).map(([k]) => k).join(",");
const host = new URL(s.url).hostname.replace(/^www\./, "") + new URL(s.url).pathname.replace(/\/$/, "");
rows.push(`| ${s.id} | ${host} | ${r.scorecard.total} | ${r.gates0to6Pass ? "PASS" : "FAIL"} | ${r.stage2Pass ? "PASS" : "FAIL"} | ${fails || ""} |`);
}
const avg = Math.round((sum / n) * 10) / 10;
const md = `# Stage 2 — results (capture-state correctness)
**${g06}/${n} pass gates 0-6; ${s2}/${n} pass the stricter stage-2 bar** (gates 0-6 + non-degenerate capture + perceptually-close render), average ${avg}.
Stage 2 = popup/video/animation pages where the captured frame must be the settled, unobstructed state. Stage-2 gates: **pollution** (degenerate/wall/blocking-modal) and **perceptual** (tier-thresholded screenshot diff).
| id | site | score | gates0-6 | stage2 | failing |
|----|------|------:|:--------:|:------:|---------|
${rows.join("\n")}
Documented residuals (limitations, not defects): wix (heavy-JS site-builder — custom-element carousel positioned by script we don't run); warbyparker/squarespace (dynamic/autoplay hero — perceptual-only, frame-to-frame non-deterministic).
`;
writeFileSync(join(ROOT, "examples", tier, "RESULTS.md"), md);
console.log(`wrote examples/${tier}/RESULTS.md — gates0-6 ${g06}/${n}, stage2 ${s2}/${n}, avg ${avg}`);
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Human-tell diagnostic: count patterns a human would (almost) never hand-write.
Separate from codeAudit.ts — this is the "would a human write this?" lens, focused on
specific robotic constructs, scanned over the .tsx/.css of each tree. Prints a column per tree.
Usage: human-tells.py <label=dir> ...
"""
import re, sys, os
def read_tree(d):
tsx, css = [], []
for root, dirs, files in os.walk(d):
dirs[:] = [x for x in dirs if x not in ("node_modules", ".next", "out") and not x.startswith(".")]
for f in files:
p = os.path.join(root, f)
try:
s = open(p, encoding="utf8").read()
except Exception:
continue
if f.endswith((".tsx", ".jsx", ".ts")):
tsx.append(s)
elif f.endswith(".css"):
css.append(s)
return "\n".join(tsx), "\n".join(css)
def count(pat, s):
return len(re.findall(pat, s))
TELLS = {
# noise / redundancy — almost never hand-written
"noop relative inset (top-0 right-0 bottom-0 left-0)": (lambda tsx, css: count(r'\btop-0 right-0 bottom-0 left-0\b', tsx), True),
"redundant gap+gap-x+gap-y (same n)": (lambda tsx, css: len([m for m in re.finditer(r'\bgap-(\d+(?:\.\d+)?) gap-y-(\d+(?:\.\d+)?) gap-x-(\d+(?:\.\d+)?)', tsx) if m.group(1)==m.group(2)==m.group(3)]), True),
"4-side longhand pad (pt-x pr-y pb-x pl-y)": (lambda tsx, css: count(r'\bpt-\S+ pr-\S+ pb-\S+ pl-\S+', tsx), True),
"per-side border arbitrary [border-*]": (lambda tsx, css: count(r'\[border-(?:top|right|bottom|left)-(?:style|color|width):', tsx+css), True),
"baked runtime id (radix/_R_/:r)": (lambda tsx, css: count(r'id=\"(?:radix-|[^\"]*_R_|:r)', tsx), True),
"[text-align:inherit] override": (lambda tsx, css: count(r'\[text-align:inherit\]', tsx), True),
"data-cid shipped (jsx)": (lambda tsx, css: count(r'data-cid', tsx), True),
"data-cid selectors in ditto.css": (lambda tsx, css: count(r'data-cid', css), True),
# font-size arbitrary that maps to a named tailwind size (should be text-xs..text-9xl)
"font-size arbitrary mappable to named": (lambda tsx, css: len([m for m in re.finditer(r'text-\[(\d+(?:\.\d+)?)(px|rem)\]', tsx) if round((float(m.group(1))*(16 if m.group(2)=='rem' else 1)))/16 in NAMED_REM]), True),
# decimal arbitrary in JSX classes (sub-pixel frozen measurement)
"decimal arbitrary [N.NNpx/rem] (jsx)": (lambda tsx, css: len([m for m in re.finditer(r'\[(-?\d+\.?\d*)(px|rem)\]', tsx) if abs((v:=float(m.group(1))*(16 if m.group(2)=='rem' else 1))-round(v))>0.02]), True),
# decimal px in ditto.css (uncounted by codeAudit)
"decimal px in ditto.css (Npx)": (lambda tsx, css: len([m for m in re.finditer(r'(-?\d+\.\d+)px', css) if abs((v:=float(m.group(1)))-round(v))>0.02]), True),
"pseudo-element rules in ditto.css": (lambda tsx, css: count(r'::(?:before|after)\b', css), True),
}
# named tailwind text sizes in rem
NAMED_REM = {0.75, 0.875, 1.0, 1.125, 1.25, 1.5, 1.875, 2.25, 3.0, 3.75, 4.5, 6.0, 8.0}
def main():
trees = []
for a in sys.argv[1:]:
label, d = a.split("=", 1)
trees.append((label, read_tree(d)))
w0 = max(len(k) for k in TELLS)
cw = max(11, *(len(l) for l, _ in trees))
print("\n" + "metric".ljust(w0) + " " + "".join(l.ljust(cw) for l, _ in trees))
print("-" * (w0 + 2 + cw * len(trees)))
for k, (fn, _) in TELLS.items():
cells = "".join(str(fn(tsx, css)).ljust(cw) for _, (tsx, css) in trees)
print(k.ljust(w0) + " " + cells)
print()
main()
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Build before/after (source vs clone) screenshot composites for a tier.
For each site: a single PNG with a desktop row (1280px) and a mobile row (375px),
each showing SOURCE on the left and CLONE on the right, with labels.
"""
import json, os, sys, glob, re
from PIL import Image, ImageDraw, ImageFont
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
RUNS = os.path.join(ROOT, "runs")
TIER = sys.argv[1] if len(sys.argv) > 1 else "easy"
OUT = os.path.join(RUNS, f"compare-{TIER}")
os.makedirs(OUT, exist_ok=True)
def font(sz):
for p in ["/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]:
if os.path.exists(p):
return ImageFont.truetype(p, sz)
return ImageFont.load_default()
F_TITLE = font(30)
F_LABEL = font(22)
def host_of(url):
# Mirror siteIdFromUrl() in cli.ts: host (minus www) + slugified path.
from urllib.parse import urlparse
u = urlparse(url)
host = re.sub(r"^www\.", "", u.hostname or "")
path = re.sub(r"^-|-$", "", re.sub(r"[^a-zA-Z0-9]+", "-", u.path.rstrip("/")))
sid = host + (("-" + path) if path else "")
return re.sub(r"[^a-zA-Z0-9.-]", "-", sid)[:80]
def latest_run(host):
dirs = sorted(glob.glob(os.path.join(RUNS, host, "*")), reverse=True)
for d in dirs:
if (os.path.exists(os.path.join(d, "source", "screenshots")) and
os.path.exists(os.path.join(d, "rendered", "screenshots"))):
return d
return None
def load(path, scale, max_h):
if not os.path.exists(path):
return None
im = Image.open(path).convert("RGB")
w = int(im.width * scale)
h = int(im.height * scale)
im = im.resize((w, h), Image.LANCZOS)
if im.height > max_h: # cap very tall pages
im = im.crop((0, 0, im.width, max_h))
return im
PAD = 16
HEADER = 46 # per-row label strip
TITLE = 56 # site title strip
GAP = 10 # gap between source and clone
BG = (250, 250, 250)
INK = (20, 20, 20)
RULE = (210, 210, 210)
def row(src_path, clone_path, scale, max_h, label):
"""One labeled row: [SOURCE | CLONE] side by side, top-aligned."""
s = load(src_path, scale, max_h)
c = load(clone_path, scale, max_h)
if s is None and c is None:
return None
sw = s.width if s else (c.width if c else 0)
cw = c.width if c else sw
sh = s.height if s else 0
ch = c.height if c else 0
body_h = max(sh, ch)
W = sw + GAP + cw
H = HEADER + body_h
img = Image.new("RGB", (W, H), BG)
d = ImageDraw.Draw(img)
d.text((4, 10), label, font=F_LABEL, fill=INK)
d.text((4, HEADER - 4), "SOURCE", font=F_LABEL, fill=(90, 90, 90))
d.text((sw + GAP + 4, HEADER - 4), "CLONE", font=F_LABEL, fill=(90, 90, 90))
if s: img.paste(s, (0, HEADER))
if c: img.paste(c, (sw + GAP, HEADER))
# divider line between the pair
d.line([(sw + GAP // 2, HEADER), (sw + GAP // 2, H)], fill=RULE, width=2)
return img
def stack(title, rows):
rows = [r for r in rows if r is not None]
if not rows:
return None
W = max(r.width for r in rows) + PAD * 2
H = TITLE + sum(r.height for r in rows) + PAD * (len(rows) + 1)
img = Image.new("RGB", (W, H), BG)
d = ImageDraw.Draw(img)
d.text((PAD, 14), title, font=F_TITLE, fill=INK)
d.line([(0, TITLE - 4), (W, TITLE - 4)], fill=RULE, width=2)
y = TITLE + PAD
for r in rows:
img.paste(r, (PAD, y))
y += r.height + PAD
return img
def main():
sites = json.load(open(os.path.join(ROOT, "compiler", "benchmarks", f"{TIER}.json")))
made = []
for site in sites:
host = host_of(site["url"])
run = latest_run(host)
if not run:
print(f"SKIP {site['id']} {host}: no run with screenshots")
continue
ss = os.path.join(run, "source", "screenshots")
rs = os.path.join(run, "rendered", "screenshots")
title = f"{site['id']}{host}"
desktop = row(os.path.join(ss, "1280.png"), os.path.join(rs, "1280.png"),
0.5, 1600, "Desktop · 1280px viewport (shown at 50%)")
mobile = row(os.path.join(ss, "375.png"), os.path.join(rs, "375.png"),
1.0, 1800, "Mobile · 375px viewport (100%)")
img = stack(title, [desktop, mobile])
if img is None:
print(f"SKIP {site['id']}: no images")
continue
out = os.path.join(OUT, f"{site['id']}-{host}.png")
img.save(out, optimize=True)
made.append(out)
print(f"OK {out} ({img.width}x{img.height})")
print(f"\n{len(made)} composites -> {OUT}")
if __name__ == "__main__":
main()
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Build before/after (source vs clone) composites for the multi-page `sites` benchmark.
For each site: a stacked PNG of a few key routes (entry + listings/representatives),
each a labeled SOURCE|CLONE row at the desktop (1280px) viewport, titled with the
site's per-route pass summary. Mirrors make_compare.py but reads the site run layout
(runs/site-<host>/<ts>/routes/<key>/{source,rendered}/screenshots/<vp>.png).
"""
import json, os, sys, glob, re
from PIL import Image, ImageDraw, ImageFont
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
RUNS = os.path.join(ROOT, "runs")
OUT = os.path.join(RUNS, "compare-sites")
os.makedirs(OUT, exist_ok=True)
MAX_ROUTES = int(os.environ.get("MAX_ROUTES", "4"))
def font(sz):
for p in ["/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]:
if os.path.exists(p):
return ImageFont.truetype(p, sz)
return ImageFont.load_default()
F_TITLE, F_LABEL = font(30), font(20)
PAD, HEADER, TITLE, GAP = 16, 42, 60, 10
BG, INK, RULE = (250, 250, 250), (20, 20, 20), (210, 210, 210)
def host_of(url):
from urllib.parse import urlparse
u = urlparse(url)
host = re.sub(r"^www\.", "", u.hostname or "")
path = re.sub(r"^-|-$", "", re.sub(r"[^a-zA-Z0-9]+", "-", u.path.rstrip("/")))
sid = host + (("-" + path) if path else "")
return "site-" + re.sub(r"[^a-zA-Z0-9.-]", "-", sid)[:80]
def sanitize_seg(s):
out = re.sub(r"-+", "-", re.sub(r"[^A-Za-z0-9._-]", "-", s)).strip("-")
if out == "" or re.match(r"^[_(@.]", out):
out = "r-" + re.sub(r"^[_(@.]+", "", out)
return out or "r"
def route_key(path):
if path == "/":
return "home"
segs = [sanitize_seg(s) for s in path.split("/") if s]
return "__".join(segs) or "home"
def latest_run(host):
for d in sorted(glob.glob(os.path.join(RUNS, host, "*")), reverse=True):
if os.path.exists(os.path.join(d, "site-manifest.json")):
return d
return None
def load(path, scale, max_h):
if not os.path.exists(path):
return None
im = Image.open(path).convert("RGB")
im = im.resize((int(im.width * scale), int(im.height * scale)), Image.LANCZOS)
if im.height > max_h:
im = im.crop((0, 0, im.width, max_h))
return im
def row(src_path, clone_path, scale, max_h, label):
s, c = load(src_path, scale, max_h), load(clone_path, scale, max_h)
if s is None and c is None:
return None
sw = s.width if s else (c.width if c else 0)
cw = c.width if c else sw
body_h = max(s.height if s else 0, c.height if c else 0)
img = Image.new("RGB", (sw + GAP + cw, HEADER + body_h), BG)
d = ImageDraw.Draw(img)
d.text((4, 8), label, font=F_LABEL, fill=INK)
d.text((4, HEADER - 6), "SOURCE", font=F_LABEL, fill=(90, 90, 90))
d.text((sw + GAP + 4, HEADER - 6), "CLONE", font=F_LABEL, fill=(90, 90, 90))
if s: img.paste(s, (0, HEADER))
if c: img.paste(c, (sw + GAP, HEADER))
d.line([(sw + GAP // 2, HEADER), (sw + GAP // 2, img.height)], fill=RULE, width=2)
return img
def stack(title, rows):
rows = [r for r in rows if r is not None]
if not rows:
return None
W = max(r.width for r in rows) + PAD * 2
H = TITLE + sum(r.height for r in rows) + PAD * (len(rows) + 1)
img = Image.new("RGB", (W, H), BG)
d = ImageDraw.Draw(img)
d.text((PAD, 16), title, font=F_TITLE, fill=INK)
d.line([(0, TITLE - 4), (W, TITLE - 4)], fill=RULE, width=2)
y = TITLE + PAD
for r in rows:
img.paste(r, (PAD, y))
y += r.height + PAD
return img
def main():
sites = json.load(open(os.path.join(ROOT, "compiler", "benchmarks", "sites.json")))
made = []
for site in sites:
host = host_of(site["url"])
run = latest_run(host)
if not run:
print(f"SKIP {site['id']}: no run"); continue
man = json.load(open(os.path.join(run, "site-manifest.json")))
report = None
rp = os.path.join(run, "validation", "site-report.json")
if os.path.exists(rp):
report = json.load(open(rp))
summ = ""
if report:
summ = f"{report['routesGates0to6']}/{report['routesTotal']} routes pass gates 06"
routes = man.get("routes", [])[:MAX_ROUTES]
rows = []
for r in routes:
key = route_key(r["routePath"])
ss = os.path.join(run, "routes", key, "source", "screenshots", "1280.png")
rs = os.path.join(run, "routes", key, "rendered", "screenshots", "1280.png")
rows.append(row(ss, rs, 0.5, 1400, f"{r.get('role','page')} · {r['href']}"))
img = stack(f"{site['id']} ({host}){summ}", rows)
if img is None:
print(f"SKIP {site['id']}: no screenshots"); continue
out = os.path.join(OUT, f"{site['id']}.png")
img.save(out, optimize=True)
made.append(out)
print(f"OK {out} ({img.width}x{img.height})")
print(f"\n{len(made)} composites -> {OUT}")
if __name__ == "__main__":
main()
+35
View File
@@ -0,0 +1,35 @@
// Probe the recent-highlights container width vs its parent across desktop widths — confirm it
// FILLS (no gutter/snap) instead of freezing at a baked w-310.
import { chromium } from "playwright";
const url = process.argv[2] || "http://127.0.0.1:8139";
const widths = [1280, 1400, 1536, 1700, 1920];
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: "networkidle", timeout: 60000 });
// the grid that holds the highlight cards: a grid with grid-cols and >=3 <a> children
const ok = await page.evaluate(() => {
const g = [...document.querySelectorAll("div")].find(
(d) => /grid/.test(getComputedStyle(d).display) && d.querySelectorAll(":scope > div > article, :scope > div > a, :scope > a").length >= 3
&& d.closest("section")?.textContent?.includes("Recent highlights")
);
if (!g) return false;
g.setAttribute("data-hl", "1");
return true;
});
if (!ok) { console.log("recent-highlights grid not found"); await browser.close(); process.exit(0); }
console.log(`\n=== ${url} (recent-highlights) ===`);
for (const w of widths) {
await page.setViewportSize({ width: w, height: 1000 });
await page.waitForTimeout(200);
const d = await page.evaluate(() => {
const g = document.querySelector("[data-hl]");
const p = g.parentElement;
const gw = g.getBoundingClientRect().width;
const pw = p.getBoundingClientRect().width;
return { gw: Math.round(gw), pw: Math.round(pw), gutter: Math.round(pw - gw) };
});
console.log(`w=${String(w).padEnd(5)} grid=${d.gw} parent=${d.pw} gutter=${d.gutter}px ${d.gutter > 4 ? "**GUTTER**" : "fills"}`);
}
await browser.close();
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Reproduce the compiler environment from a clean checkout.
# - installs compiler deps
# - installs the Chromium build Playwright needs
# - installs the shared build harness deps (Next/Vite/React) used to build & render
# every generated app during validation
# Safe to re-run; each step is idempotent.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "[setup] installing compiler deps"
npm install
echo "[setup] installing Playwright chromium"
npx playwright install chromium
echo "[setup] installing build harness deps"
( cd .harness && npm install )
echo "[setup] done. Try: npm run clone -- https://example.com/"
+51
View File
@@ -0,0 +1,51 @@
// Hypothesis test: does adding -webkit-line-clamp:5 to quote cards make their
// heights uniform? Inject the clamp at runtime and re-measure heights.
import { chromium } from "playwright";
const url = process.argv[2] || "http://127.0.0.1:8139";
const widths = [1024, 1100, 1200, 1280, 1440];
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: "networkidle", timeout: 60000 });
const gridSel = await page.evaluate(() => {
const grids = [...document.querySelectorAll("div")].filter(
(d) => getComputedStyle(d).display === "grid" && d.querySelector("blockquote")
);
if (!grids[0]) return null;
grids[0].setAttribute("data-probe-grid", "1");
return true;
});
if (!gridSel) { console.log("no grid"); await browser.close(); process.exit(0); }
const measure = async (label) => {
console.log(`\n--- ${label} ---`);
for (const w of widths) {
await page.setViewportSize({ width: w, height: 1000 });
await page.waitForTimeout(200);
const d = await page.evaluate(() => {
const grid = document.querySelector('[data-probe-grid]');
const vis = [...grid.children].filter((c) => getComputedStyle(c).display !== "none");
const H = vis.map((c) => Math.round((c.querySelector(":scope > div") || c).getBoundingClientRect().height * 10) / 10);
return { cols: getComputedStyle(grid).gridTemplateColumns.split(" ").length, H, uniq: [...new Set(H)].length };
});
console.log(`w=${String(w).padEnd(5)} cols=${d.cols} H=${JSON.stringify(d.H)} ${d.uniq === 1 ? "UNIFORM" : "**NONUNIFORM " + d.uniq + "**"}`);
}
};
await measure("BEFORE (current clone)");
// (0) h-full ONLY (no line-clamp) — does filling the definite grid cell alone equalize within rows?
await page.addStyleTag({
content: `[data-probe-grid] > div > div { height:100% !important; }
[data-probe-grid] figure { height:100% !important; }`,
});
await measure("AFTER-0 (card/figure h-full ONLY, no line-clamp)");
// (1) add line-clamp:5 on top.
await page.addStyleTag({
content: `[data-probe-grid] blockquote p { display:-webkit-box !important; -webkit-box-orient:vertical !important; -webkit-line-clamp:5 !important; overflow:hidden !important; }`,
});
await measure("AFTER-1 (h-full + line-clamp:5)");
await browser.close();
+19
View File
@@ -0,0 +1,19 @@
// Dev-only: replay selectRoutes on a site's discovered crawl paths (from crawl.json) to
// inspect/verify the route plan WITHOUT re-capturing. Usage: tsx scripts/test-select.ts [maxRoutes]
import { selectRoutes } from "../src/crawl/routeTemplates.js";
import { readFileSync } from "node:fs";
const sites: [string, string][] = [
["cropin", "output/cropin"],
];
const maxRoutes = Number(process.argv[2] ?? 25);
for (const [site, dir] of sites) {
let c: { entryPath?: string; depthByPath?: Record<string, number> };
try { c = JSON.parse(readFileSync(`${dir}/.clone/crawl.json`, "utf8")); } catch { console.log(`\n### ${site} (no crawl.json)`); continue; }
const paths = Object.keys(c.depthByPath ?? {});
const plan = selectRoutes({ entryPath: c.entryPath ?? "/", paths, maxRoutes });
console.log(`\n### ${site} (discovered ${paths.length}, maxRoutes ${maxRoutes}) ###`);
console.log(" collections:", plan.collections.map((x) => `${x.template}(${x.instanceCount})`).join(" ") || "none");
console.log(" selected:");
for (const r of plan.selected) console.log(` ${r.role.padEnd(14)} ${r.path}`);
}
+45
View File
@@ -0,0 +1,45 @@
// Triage the latest run per stage-2 site: pass state + the key metric per failure.
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
const ROOT = new URL("../..", import.meta.url).pathname;
const RUNS = join(ROOT, "runs");
const tier = process.argv[2] || "stage2";
const sites = JSON.parse(readFileSync(join(ROOT, "compiler", "benchmarks", `${tier}.json`), "utf8"));
function siteId(url) {
const u = new URL(url);
const host = u.hostname.replace(/^www\./, "");
const path = u.pathname.replace(/\/+$/, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "");
return (host + (path ? "-" + path : "")).replace(/[^a-zA-Z0-9.-]/g, "-").slice(0, 80);
}
function latest(host) {
const dir = join(RUNS, host);
if (!existsSync(dir)) return null;
const runs = readdirSync(dir).filter((d) => /^\d/.test(d)).sort();
for (let i = runs.length - 1; i >= 0; i--) {
const r = join(dir, runs[i], "validation", "report.json");
if (existsSync(r)) return join(dir, runs[i]);
}
return null;
}
let pass = 0, g06 = 0;
const lines = [];
for (const s of sites) {
const run = latest(siteId(s.url));
if (!run) { lines.push(`${s.id} NO RUN`); continue; }
const r = JSON.parse(readFileSync(join(run, "validation", "report.json"), "utf8"));
if (r.gates0to6Pass) g06++;
if (r.stage2Pass) pass++;
const fails = Object.entries(r.gates).filter(([, g]) => !g.pass).map(([k]) => k);
let detail = "";
if (fails.includes("perceptual")) detail += ` perc=${JSON.stringify(r.gates.perceptual.metrics.perViewport)}`;
if (fails.includes("layout")) { const pv = r.gates.layout.metrics.perViewport; detail += " layout=" + Object.entries(pv).filter(([, m]) => (m.heightDeltaPct > 0.05 || m.leafMedianDelta > 8 || m.sectionsBboxOkPct < 0.9)).map(([vp, m]) => `${vp}:h${m.heightDeltaPct},leaf${m.leafMedianDelta}`).join(","); }
if (fails.includes("pollution")) detail += " poll=" + JSON.stringify(r.gates.pollution.issues);
if (fails.includes("style")) detail += " style=" + JSON.stringify(r.gates.style.metrics.topFailingProps);
if (fails.includes("build")) detail += " build=" + (r.gates.build.metrics.runtimeErrorSample || []).length + "errs";
lines.push(`${r.stage2Pass ? "✅" : (r.gates0to6Pass ? "🟡" : "❌")} ${s.id} ${r.scorecard.total} [${fails.join(",") || "PASS"}]${detail}`);
}
console.log(lines.join("\n"));
console.log(`\n${tier}: gates0-6 ${g06}/${sites.length}, stage2 ${pass}/${sites.length}`);
+283
View File
@@ -0,0 +1,283 @@
/**
* Source-vs-clone Tailwind diff for the cloner (Stage 4/5 instrument).
*
* For a Tailwind-built source, the live `class` attribute is the AUTHORED fluid intent —
* the exact utility our generator should have inferred. This tool rebuilds the IR (which now carries
* the source `class` as `srcClass`) and our emitted classes (`buildTailwind().classOf`) IN-PROCESS,
* both keyed by node id, so the per-node diff is perfectly aligned with zero live-probe flakiness.
*
* It then NORMALIZES away faithful, unavoidable differences (colors, design-system spacing tokens,
* data-*, no-preflight arbitrary transforms) and focuses the diff on the LAYOUT/SIZING utility
* families — where the class name itself is the signal — to surface the real misses: places we baked
* per-viewport px where the source expressed ONE fluid rule. Misses are clustered by axis + source
* utility and ranked by frequency, so one law fixes many nodes.
*
* npx tsx scripts/tw-diff.ts [runDir=output/sample/.clone] [--axis=width] [--samples=N] [--json=path]
*
* Run from compiler/.
*/
import { join, resolve } from "node:path";
import { writeFileSync } from "node:fs";
import { buildIR, isTextChild, type IR, type IRNode } from "../src/normalize/ir.js";
import { buildTailwind } from "../src/generate/tailwind.js";
import { buildColorPalette } from "../src/infer/semanticTokens.js";
import { buildAssetGraph, type AssetGraph } from "../src/infer/assets.js";
import { readJSON } from "../src/util/fsx.js";
import type { CaptureResult } from "../src/capture/capture.js";
function buildAssetMap(assetGraph: AssetGraph): Map<string, string> {
const m = new Map<string, string>();
for (const e of assetGraph.entries) {
if (e.classification === "downloaded" && e.localPath && e.type !== "css") m.set(e.sourceUrl, e.localPath);
}
return m;
}
// ---- token parsing -------------------------------------------------------
type Tok = { raw: string; variant: string; core: string };
/** Split a className string into tokens, separating the responsive/state variant prefix
* (`md:`, `lg:`, `max-md:`, `hover:`, `2xl:` …) from the core utility. Arbitrary values can
* themselves contain `:` inside `[...]`, so only split on `:` that are OUTSIDE brackets. */
function parseClass(cls: string): Tok[] {
const out: Tok[] = [];
for (const raw of cls.split(/\s+/).filter(Boolean)) {
// find the last variant-colon that's outside brackets/parens
let depth = 0, lastColon = -1;
for (let i = 0; i < raw.length; i++) {
const ch = raw[i]!;
if (ch === "[" || ch === "(") depth++;
else if (ch === "]" || ch === ")") depth--;
else if (ch === ":" && depth === 0) lastColon = i;
}
const variant = lastColon >= 0 ? raw.slice(0, lastColon) : "";
const core = lastColon >= 0 ? raw.slice(lastColon + 1) : raw;
out.push({ raw, variant, core });
}
return out;
}
// ---- sizing-axis classification -----------------------------------------
type Axis = "width" | "height" | "grid-cols" | "grid-rows" | "flex" | "aspect" | "line-clamp";
/** Does an arbitrary value `[…]` (already unwrapped) denote a BAKED fixed length (px/rem)? */
function isBakedLen(v: string): boolean {
return /^-?[\d.]+(px|rem|em)$/.test(v);
}
/** Does an arbitrary value denote a FLUID length (%, vw/vh, fr, auto/min/max-content, calc with %/vw)? */
function isFluidLen(v: string): boolean {
if (/^-?[\d.]+(%|vw|vh|svh|lvh|dvh|svw|lvw|dvw|fr)$/.test(v)) return true;
if (/^(auto|min-content|max-content|fit-content|stretch)$/.test(v)) return true;
if (/(%|vw|vh|fr|min-content|max-content|fit-content)/.test(v) && /^(calc|min|max|clamp|repeat|fit-content)\(/.test(v)) return true;
return false;
}
/** Spacing-scale numbers (`w-24`, `h-2.5`, `w-93`) are FIXED px. Fractions (`w-1/2`) are fluid. */
function spacingIsFixed(rest: string): boolean {
return /^-?[\d.]+$/.test(rest); // pure number → fixed scale step; a fraction has a '/'
}
type AxisVerdict = { fluid?: string; baked?: string };
/** Classify a single core utility onto an axis with a fluid|baked verdict.
* Returns null for utilities that are not on a sizing axis. */
function classify(core: string): { axis: Axis; v: AxisVerdict } | null {
// grid templates
let m = core.match(/^grid-(cols|rows)-(.+)$/);
if (m) {
const axis: Axis = m[1] === "cols" ? "grid-cols" : "grid-rows";
const val = m[2]!;
if (/^\d+$/.test(val)) return { axis, v: { fluid: `grid-${m[1]}-N` } }; // fixed-count fluid grid
if (val === "none" || val === "subgrid") return { axis, v: { fluid: val } };
if (val.startsWith("[")) {
const inner = val.slice(1, -1);
// baked iff it contains an explicit px/rem track and NO fr/%/min/max/auto
const hasFixed = /[\d.]+(px|rem)/.test(inner);
const hasFluid = /(fr|%|min-content|max-content|auto|minmax|repeat)/.test(inner);
if (hasFixed && !hasFluid) return { axis, v: { baked: `grid-${m[1]}-[px]` } };
if (hasFixed && hasFluid) return { axis, v: { baked: `grid-${m[1]}-[mixed-px]` } }; // partly baked
return { axis, v: { fluid: `grid-${m[1]}-[fr]` } };
}
return { axis, v: { fluid: `grid-${m[1]}-${val}` } };
}
// aspect-ratio
m = core.match(/^aspect-(.+)$/);
if (m) {
const val = m[1]!;
if (val === "square" || val === "video" || val === "auto") return { axis: "aspect", v: { fluid: `aspect-${val}` } };
if (val.startsWith("[")) {
const inner = val.slice(1, -1);
// clean ratio like [16/9] is fluid intent; [auto_42_/_42] baked-from-px
if (/^\d+\s*\/\s*\d+$/.test(inner.replace(/_/g, " "))) return { axis: "aspect", v: { fluid: "aspect-[ratio]" } };
return { axis: "aspect", v: { baked: "aspect-[baked]" } };
}
return { axis: "aspect", v: { fluid: `aspect-${val}` } };
}
// line-clamp
m = core.match(/^line-clamp-(.+)$/);
if (m) return { axis: "line-clamp", v: { fluid: /^\d+$/.test(m[1]!) ? "line-clamp-N" : `line-clamp-${m[1]}` } };
// flex shorthand / grow / shrink / basis (all width-flow on the main axis)
if (/^(flex-1|flex-auto|flex-initial|grow|grow-0|shrink|shrink-0)$/.test(core)) return { axis: "flex", v: { fluid: core } };
m = core.match(/^basis-(.+)$/);
if (m) {
const val = m[1]!;
if (val === "full" || val === "auto" || /\d+\/\d+/.test(val)) return { axis: "flex", v: { fluid: `basis-${val.includes("/") ? "frac" : val}` } };
if (val.startsWith("[")) { const inner = val.slice(1, -1); return isBakedLen(inner) ? { axis: "flex", v: { baked: "basis-[px]" } } : { axis: "flex", v: { fluid: "basis-[fluid]" } }; }
return { axis: "flex", v: { baked: "basis-px" } }; // basis-24 etc
}
// width / height (incl. min-/max-)
m = core.match(/^(min-|max-)?([wh])-(.+)$/);
if (m) {
const axis: Axis = m[2] === "w" ? "width" : "height";
const val = m[3]!;
const fam = `${m[1] ?? ""}${m[2]}`; // w, min-w, max-w, h, …
if (/^(full|screen|auto|fit|min|max|svh|lvh|dvh|svw|lvw|dvw|prose)$/.test(val)) return { axis, v: { fluid: `${fam}-${val}` } };
if (/^\d+\/\d+$/.test(val)) return { axis, v: { fluid: `${fam}-frac` } }; // w-1/2
if (/^(screen-|3xl|2xl|xl|lg|md|sm|xs|7xl|6xl|5xl|4xl)/.test(val) && fam === "max-w") return { axis, v: { fluid: `max-w-${val}` } }; // named cap
if (val.startsWith("[")) {
const inner = val.slice(1, -1);
if (inner.startsWith("var(")) return { axis, v: { fluid: `${fam}-[var]` } }; // token-driven → fluid-ish
if (isFluidLen(inner)) return { axis, v: { fluid: `${fam}-[fluid]` } };
if (isBakedLen(inner)) return { axis, v: { baked: `${fam}-[px]` } };
return { axis, v: {} };
}
if (spacingIsFixed(val)) return { axis, v: { baked: `${fam}-num` } }; // w-24, h-93 → fixed scale
return { axis, v: {} };
}
return null;
}
type NodeAxisInfo = Record<Axis, { srcFluid: Set<string>; srcBaked: Set<string>; cloFluid: Set<string>; cloBaked: Set<string> }>;
function emptyAxisInfo(): NodeAxisInfo {
const mk = () => ({ srcFluid: new Set<string>(), srcBaked: new Set<string>(), cloFluid: new Set<string>(), cloBaked: new Set<string>() });
return { width: mk(), height: mk(), "grid-cols": mk(), "grid-rows": mk(), flex: mk(), aspect: mk(), "line-clamp": mk() };
}
function collectAxis(cls: string, info: NodeAxisInfo, side: "src" | "clo"): void {
for (const t of parseClass(cls)) {
const c = classify(t.core);
if (!c) continue;
const bucket = info[c.axis];
if (c.v.fluid) (side === "src" ? bucket.srcFluid : bucket.cloFluid).add(c.v.fluid);
if (c.v.baked) (side === "src" ? bucket.srcBaked : bucket.cloBaked).add(c.v.baked);
}
}
// ---- main ----------------------------------------------------------------
const argv = process.argv.slice(2);
const runDir = resolve(argv.find((a) => !a.startsWith("--")) ?? "output/sample/.clone");
const axisFilter = (argv.find((a) => a.startsWith("--axis="))?.split("=")[1] ?? "") as Axis | "";
const sampleN = parseInt(argv.find((a) => a.startsWith("--samples="))?.split("=")[1] ?? "20", 10);
const jsonOut = argv.find((a) => a.startsWith("--json="))?.split("=")[1];
const inspectIds = (argv.find((a) => a.startsWith("--ids="))?.split("=")[1] ?? "").split(",").filter(Boolean);
const sourceDir = join(runDir, "source");
const input = readJSON<{ url: string; viewports: number[] }>(join(runDir, "input.json"));
const capture = readJSON<CaptureResult>(join(sourceDir, "capture", "capture-result.json"));
const cloneOpts = readJSON<{ reflow?: boolean }>(join(sourceDir, "clone-options.json"));
const ir: IR = buildIR(sourceDir, capture.viewports, { motion: !!capture.motion, bandViewports: input.viewports });
const assetGraph = buildAssetGraph(capture);
const palette = buildColorPalette(ir);
const tw = buildTailwind(ir, buildAssetMap(assetGraph), palette.varForColor, { interaction: capture.interaction, reflow: !!cloneOpts.reflow });
const classOf = tw.classOf;
// --ids inspect mode: dump src/clone class + per-vp height geometry for specific nodes, then exit.
if (inspectIds.length) {
const idx = new Map<string, IRNode>();
const idxWalk = (n: IRNode): void => { idx.set(n.id, n); for (const c of n.children) if (!isTextChild(c)) idxWalk(c); };
idxWalk(ir.root);
for (const id of inspectIds) {
const n = idx.get(id);
if (!n) { console.log(`${id}: NOT FOUND`); continue; }
const vps = ir.doc.sampleViewports;
console.log(`\n${id} <${n.tag}>`);
console.log(` src: ${n.srcClass ?? "(none)"}`);
console.log(` clone: ${classOf.get(id) ?? "(none)"}`);
console.log(` bboxH: ${vps.map((vp) => n.bboxByVp[vp]?.height ?? "·").join("/")}`);
}
process.exit(0);
}
// Per-axis miss tally + per (axis, srcUtil) cluster + sample rows.
type MissRow = { id: string; tag: string; text: string; axis: Axis; srcUtil: string; cloBaked: string; source: string; clone: string };
const misses: MissRow[] = [];
const clusterCount = new Map<string, number>(); // "axis · srcUtil" → count
const axisMiss = new Map<Axis, number>();
let nNodes = 0, nSrc = 0, nClone = 0, nBoth = 0;
function textOf(n: IRNode): string {
for (const c of n.children) if (isTextChild(c) && c.text.trim()) return c.text.trim().slice(0, 40);
return "";
}
function walk(n: IRNode): void {
nNodes++;
const src = n.srcClass ?? "";
const clo = classOf.get(n.id) ?? "";
if (src) nSrc++;
if (clo) nClone++;
if (src && clo) {
nBoth++;
const info = emptyAxisInfo();
collectAxis(src, info, "src");
collectAxis(clo, info, "clo");
for (const axis of Object.keys(info) as Axis[]) {
const b = info[axis];
// MISS = source expressed a fluid rule on this axis, clone baked px (or token absent) and
// produced NO fluid utility of its own on that axis.
const cloneHasFluid = b.cloFluid.size > 0;
if (b.srcFluid.size > 0 && !cloneHasFluid && (b.cloBaked.size > 0 || (b.srcBaked.size === 0 && axisExpectedInClone(axis, clo)))) {
const srcUtil = [...b.srcFluid].sort().join("+");
const cloBaked = [...b.cloBaked].sort().join("+") || "(absent)";
misses.push({ id: n.id, tag: n.tag, text: textOf(n), axis, srcUtil, cloBaked, source: src, clone: clo });
const key = `${axis} · ${srcUtil}`;
clusterCount.set(key, (clusterCount.get(key) ?? 0) + 1);
axisMiss.set(axis, (axisMiss.get(axis) ?? 0) + 1);
}
}
}
for (const c of n.children) if (!isTextChild(c)) walk(c);
}
/** For line-clamp/aspect a missing clone utility is itself the miss; for width/height/grid we only
* count a miss when the clone actually baked something (handled by cloBaked.size>0). */
function axisExpectedInClone(axis: Axis, _clone: string): boolean {
return axis === "line-clamp" || axis === "aspect";
}
walk(ir.root);
// ---- report --------------------------------------------------------------
console.log(`\n=== tw-diff: ${runDir} ===`);
console.log(`nodes=${nNodes} withSrcClass=${nSrc} withCloneClass=${nClone} both=${nBoth}`);
console.log(`\n--- real misses by axis (source fluid, clone baked/absent) ---`);
const totalMiss = misses.length;
for (const [axis, n] of [...axisMiss.entries()].sort((a, b) => b[1] - a[1])) {
console.log(` ${axis.padEnd(11)} ${n}`);
}
console.log(` ${"TOTAL".padEnd(11)} ${totalMiss}`);
console.log(`\n--- top miss clusters (axis · source-util → count) ---`);
for (const [key, n] of [...clusterCount.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20)) {
console.log(` ${String(n).padStart(4)} ${key}`);
}
const shown = axisFilter ? misses.filter((m) => m.axis === axisFilter) : misses;
console.log(`\n--- sample miss rows${axisFilter ? ` [axis=${axisFilter}]` : ""} (${Math.min(sampleN, shown.length)} of ${shown.length}) ---`);
for (const m of shown.slice(0, sampleN)) {
console.log(`\n ${m.id} <${m.tag}> ${m.text ? `${m.text}` : ""} [${m.axis}: src ${m.srcUtil} → clone ${m.cloBaked}]`);
console.log(` src: ${m.source}`);
console.log(` clone: ${m.clone}`);
}
if (jsonOut) {
writeFileSync(resolve(jsonOut), JSON.stringify({ stats: { nNodes, nSrc, nClone, nBoth, totalMiss }, axisMiss: Object.fromEntries(axisMiss), clusters: Object.fromEntries([...clusterCount.entries()].sort((a, b) => b[1] - a[1])), misses }, null, 2));
console.log(`\nwrote ${jsonOut}`);
}