From 33bd518f37c579ecb6b9d5291258954c5285ea79 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 10:32:08 +0000 Subject: [PATCH 1/3] Add anti-slop eval harness and hillclimb the skill (gates 70-84) Build a deterministic slop detector grounded in Impeccable's 37 patterns plus Hallmark's own gates, score self-contained fixtures across genres, and run a 10-cycle eval-driven hillclimb. Phase 1 (v1, cycles 1-5): close gaps the detector found, adding gates 70-77 to references/slop-test.md; fixtures climb 74.2 -> 98.3. Cycle 6: per "Your Evals Will Break", upgrade the eval to v2 -- six new detector rules (incl. hero-float/gate 54 the v1-perfect fixtures had been violating), a cross-fixture order parameter (macrostructure reuse), and two adversarial fixtures. Score honestly drops to 76.4. Phase 2 (v2, cycles 7-10): add gates 78-84 and climb back to 98.7, resisting a dark/neon/metric-hero brief. The skill gained 15 gates motivated by what the eval could measure. Full curve in evals/results/history.md. --- evals/README.md | 49 +++ evals/briefs.md | 26 ++ evals/config.json | 20 + evals/detector.mjs | 660 ++++++++++++++++++++++++++++++ evals/fixtures/fernweh.html | 117 ++++++ evals/fixtures/fernweh.judge.json | 10 + evals/fixtures/kiln.html | 130 ++++++ evals/fixtures/kiln.judge.json | 10 + evals/fixtures/ledger.html | 131 ++++++ evals/fixtures/ledger.judge.json | 10 + evals/fixtures/pulse.html | 115 ++++++ evals/fixtures/pulse.judge.json | 10 + evals/fixtures/vellum.html | 70 ++++ evals/fixtures/vellum.judge.json | 10 + evals/results/cycle-01-v1.json | 67 +++ evals/results/cycle-02-v1.json | 67 +++ evals/results/cycle-03-v1.json | 67 +++ evals/results/cycle-04-v1.json | 67 +++ evals/results/cycle-05-v1.json | 67 +++ evals/results/cycle-06-v2.json | 103 +++++ evals/results/cycle-07-v2.json | 103 +++++ evals/results/cycle-08-v2.json | 103 +++++ evals/results/cycle-09-v2.json | 103 +++++ evals/results/cycle-10-v2.json | 103 +++++ evals/results/history.md | 31 ++ evals/rubric.md | 55 +++ evals/run.mjs | 119 ++++++ references/slop-test.md | 138 +++++++ 28 files changed, 2561 insertions(+) create mode 100644 evals/README.md create mode 100644 evals/briefs.md create mode 100644 evals/config.json create mode 100644 evals/detector.mjs create mode 100644 evals/fixtures/fernweh.html create mode 100644 evals/fixtures/fernweh.judge.json create mode 100644 evals/fixtures/kiln.html create mode 100644 evals/fixtures/kiln.judge.json create mode 100644 evals/fixtures/ledger.html create mode 100644 evals/fixtures/ledger.judge.json create mode 100644 evals/fixtures/pulse.html create mode 100644 evals/fixtures/pulse.judge.json create mode 100644 evals/fixtures/vellum.html create mode 100644 evals/fixtures/vellum.judge.json create mode 100644 evals/results/cycle-01-v1.json create mode 100644 evals/results/cycle-02-v1.json create mode 100644 evals/results/cycle-03-v1.json create mode 100644 evals/results/cycle-04-v1.json create mode 100644 evals/results/cycle-05-v1.json create mode 100644 evals/results/cycle-06-v2.json create mode 100644 evals/results/cycle-07-v2.json create mode 100644 evals/results/cycle-08-v2.json create mode 100644 evals/results/cycle-09-v2.json create mode 100644 evals/results/cycle-10-v2.json create mode 100644 evals/results/history.md create mode 100644 evals/rubric.md create mode 100644 evals/run.mjs diff --git a/evals/README.md b/evals/README.md new file mode 100644 index 0000000..729f137 --- /dev/null +++ b/evals/README.md @@ -0,0 +1,49 @@ +# `evals/` — anti-slop eval harness + +An eval-driven hillclimb that improved Hallmark against two external anchors: + +- **Impeccable's slop standard** — "37 patterns that mark an interface as + AI-generated" across 8 dimensions ([impeccable.style/slop](https://impeccable.style/slop)). +- **"Your Evals Will Break and You Won't See It Coming"** — why static evals + silently miss new failure regimes, and the case for self-evolving evals + ([wanglun1996.github.io](https://wanglun1996.github.io/blog/your-evals-will-break.html)). + +## What's here + +| File | Role | +|---|---| +| `rubric.md` | The scoring rubric: 8 detector dimensions + 1 craft (judge) dimension. | +| `briefs.md` | The briefs each fixture is the skill exercised on. | +| `detector.mjs` | Deterministic slop detector — the CLI-checkable subset of the 37 patterns + Hallmark gates. v1 = 37 rules, v2 = 43. | +| `run.mjs` | Merges detector + judge sidecars, computes the cross-fixture **order parameter**, snapshots a cycle, rebuilds `results/history.md`. | +| `config.json` | Which fixtures belong to eval v1 vs v2. | +| `fixtures/*.html` | Self-contained pages (what Hallmark emits). | +| `fixtures/*.judge.json` | Per-fixture craft scores (philosophy, hierarchy, execution, specificity, restraint, variety, honesty). | +| `results/` | One JSON snapshot per cycle + the running `history.md` table. | + +## Run it + +```bash +cd evals +node detector.mjs fixtures/pulse.html --eval v2 # inspect one page +node run.mjs --cycle 10 --eval v2 --label "..." # score a cycle, update history +``` + +## The hillclimb (10 cycles) + +**Phase 1 (v1, cycles 1–5)** drove the three originals from 74.2 → 98.3 by +closing gaps the detector found — each cycle added a real gate to +`references/slop-test.md` (gates **70–77**) and brought the fixtures into line. + +**The break (cycle 6)** upgraded the eval to **v2**: six new detector rules +for failure modes v1 was blind to (notably hero-float / gate 54, which the +v1-perfect fixtures had been violating the whole time), a cross-fixture +**order parameter** (macrostructure reuse — variety is a property of the +*set*, not the page), and two adversarial fixtures (`pulse`, `vellum`). Score +fell 98.3 → 76.4, exactly as the blog predicts. + +**Phase 2 (v2, cycles 7–10)** climbed back to 98.7, adding gates **78–84** +and resisting `pulse`'s dark/neon/metric-hero brief gravity. + +The skill is the artifact that improved: 15 new gates, motivated by what the +eval could measure. See `results/history.md` for the full score table. diff --git a/evals/briefs.md b/evals/briefs.md new file mode 100644 index 0000000..991f184 --- /dev/null +++ b/evals/briefs.md @@ -0,0 +1,26 @@ +# Eval briefs + +Each fixture is the skill exercised on one brief. Briefs span genres so the +detector isn't fooled by a single safe house style. Fixtures live in +`fixtures/` as self-contained HTML (exactly what Hallmark emits). + +## v1 briefs + +- **ledger** — landing page for *Ledger*, an open-source double-entry + bookkeeping CLI for indie developers. Genre: modern-minimal. + Macrostructure target: stat-led / workbench (no rote hero→3-features→CTA). +- **fernweh** — homepage for *Fernweh*, a small-group slow-travel company + running 8-day walking trips. Genre: atmospheric / editorial. + Macrostructure target: photographic or narrative-workflow. +- **kiln** — studio page for *Kiln & Co.*, a two-person ceramics workshop + selling a seasonal run of stoneware. Genre: editorial / specimen-adjacent + but must NOT default to Specimen. + +## v2 briefs (added when v1 saturates) + +- **synthwave-trap** — adversarial: a brief for *Pulse*, a "developer + analytics dashboard," whose own copy nudges toward dark-mode + neon + + metric-hero slop. The skill must resist the brief's gravity. +- **vellum** — a long-form essay page for *Vellum*, a writing tool. Probes + reading-comfort tells v1 underweights (measure rhythm, widows, heading + cadence, real prose hierarchy). diff --git a/evals/config.json b/evals/config.json new file mode 100644 index 0000000..e075925 --- /dev/null +++ b/evals/config.json @@ -0,0 +1,20 @@ +{ + "evals": { + "v1": { + "fixtures": [ + { "name": "ledger", "file": "fixtures/ledger.html", "judge": "fixtures/ledger.judge.json" }, + { "name": "fernweh", "file": "fixtures/fernweh.html", "judge": "fixtures/fernweh.judge.json" }, + { "name": "kiln", "file": "fixtures/kiln.html", "judge": "fixtures/kiln.judge.json" } + ] + }, + "v2": { + "fixtures": [ + { "name": "ledger", "file": "fixtures/ledger.html", "judge": "fixtures/ledger.judge.json" }, + { "name": "fernweh", "file": "fixtures/fernweh.html", "judge": "fixtures/fernweh.judge.json" }, + { "name": "kiln", "file": "fixtures/kiln.html", "judge": "fixtures/kiln.judge.json" }, + { "name": "pulse", "file": "fixtures/pulse.html", "judge": "fixtures/pulse.judge.json" }, + { "name": "vellum", "file": "fixtures/vellum.html", "judge": "fixtures/vellum.judge.json" } + ] + } + } +} diff --git a/evals/detector.mjs b/evals/detector.mjs new file mode 100644 index 0000000..14a6ca7 --- /dev/null +++ b/evals/detector.mjs @@ -0,0 +1,660 @@ +// Hallmark slop detector — deterministic anti-slop checks for self-contained HTML. +// +// Grounds the eval in two external standards: +// 1. Impeccable's "37 patterns that mark an interface as AI-generated" +// across 8 dimensions (impeccable.style/slop). +// 2. Hallmark's own slop-test gates (references/slop-test.md). +// +// Only the deterministic (CLI-checkable) subset lives here. Taste dimensions +// (philosophy, hierarchy, specificity, restraint, variety, honesty) are scored +// by an LLM judge and merged by run.mjs. +// +// Usage: node detector.mjs [--json] + +import fs from 'node:fs'; + +const FONT_OVERUSED = [ + 'inter', 'roboto', 'open sans', 'poppins', 'lato', 'montserrat', + 'plus jakarta sans', 'space grotesk', 'geist', 'nunito', 'raleway', +]; +const GENERIC_FAMILIES = new Set([ + 'sans-serif', 'serif', 'monospace', 'system-ui', 'ui-monospace', + 'ui-serif', 'ui-sans-serif', 'cursive', 'fantasy', 'emoji', 'math', + '-apple-system', 'blinkmacsystemfont', 'segoe ui', 'inherit', 'initial', +]); + +// ---------------------------------------------------------------- doc loading +function loadDoc(path) { + const html = fs.readFileSync(path, 'utf8'); + const styleCss = [...html.matchAll(/]*>([\s\S]*?)<\/style>/gi)] + .map((m) => m[1]).join('\n'); + const inlineCss = [...html.matchAll(/\sstyle="([^"]*)"/gi)] + .map((m) => `__inline__{${m[1]}}`).join('\n'); + const css = `${styleCss}\n${inlineCss}`; + const stamp = (css.match(/\/\*\s*Hallmark[\s\S]*?\*\//) || [''])[0]; + const genre = + (stamp.match(/genre:\s*([a-z-]+)/i) || [])[1] || + (html.match(/data-genre="([^"]+)"/) || [])[1] || ''; + return { path, html, css, styleCss, stamp, genre }; +} + +// crude flat-rule splitter; @media wrappers drop out but inner rules survive. +function cssRules(css) { + const out = []; + const re = /([^{}]+)\{([^{}]*)\}/g; + let m; + while ((m = re.exec(css))) { + out.push({ sel: m[1].trim().toLowerCase(), body: m[2].trim() }); + } + return out; +} + +function tokenMap(css) { + const map = {}; + for (const r of cssRules(css)) { + if (!/:root|\[data-theme/.test(r.sel)) continue; + for (const m of r.body.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+)/gi)) { + map[m[1].trim()] = m[2].trim(); + } + } + return map; +} + +function resolveVar(value, map, depth = 0) { + if (depth > 8 || !value) return value; + return value.replace(/var\(\s*(--[a-z0-9-]+)\s*(?:,([^)]*))?\)/gi, (_, name, fb) => { + const v = map[name.trim()]; + if (v != null) return resolveVar(v, map, depth + 1); + return fb != null ? resolveVar(fb.trim(), map, depth + 1) : ''; + }); +} + +// oklch lightness 0..1 (handles "oklch(.3 ...)" and "oklch(32% ...)") +function oklchL(value) { + const m = String(value).match(/oklch\(\s*([0-9.]+%?)/i); + if (!m) return null; + const raw = m[1]; + return raw.endsWith('%') ? parseFloat(raw) / 100 : parseFloat(raw); +} +function oklchC(value) { + const m = String(value).match(/oklch\(\s*[0-9.]+%?\s+([0-9.]+)/i); + return m ? parseFloat(m[1]) : null; +} +function oklchH(value) { + const m = String(value).match(/oklch\(\s*[0-9.]+%?\s+[0-9.]+\s+([0-9.]+)/i); + return m ? parseFloat(m[1]) : null; +} + +const COLOR_LITERAL = /#[0-9a-fA-F]{3,8}\b|\brgba?\([^)]*\)|\bhsla?\([^)]*\)|\boklch\([^)]*\)|\blab\([^)]*\)/gi; + +function fontFamilies(css, map) { + const fams = new Set(); + for (const m of css.matchAll(/font-family\s*:\s*([^;}]+)/gi)) { + const resolved = resolveVar(m[1], map); + const first = resolved.split(',')[0].trim().replace(/['"]/g, '').toLowerCase(); + if (first && !GENERIC_FAMILIES.has(first) && !first.startsWith('var(')) fams.add(first); + } + for (const [k, v] of Object.entries(map)) { + if (!/--font/.test(k)) continue; + const first = String(v).split(',')[0].trim().replace(/['"]/g, '').toLowerCase(); + if (first && !GENERIC_FAMILIES.has(first) && !first.startsWith('var(')) fams.add(first); + } + return [...fams]; +} + +function headingLevels(html) { + return [...html.matchAll(/]/gi)].map((m) => +m[1]); +} + +// Balanced extraction of @media (...max-width...) block bodies. Regex alone +// trips over nested rule braces and indented closers, so count braces. +function maxWidthMediaBodies(css) { + const bodies = []; + const re = /@media[^{]*max-width[^{]*\{/gi; + let m; + while ((m = re.exec(css))) { + let depth = 1; + let i = m.index + m[0].length; + const start = i; + for (; i < css.length && depth > 0; i++) { + if (css[i] === '{') depth++; + else if (css[i] === '}') depth--; + } + bodies.push(css.slice(start, i - 1)); + } + return bodies; +} + +// ---------------------------------------------------------------- rule set v1 +// Each rule: { id, dim, label, fn(ctx) -> {pass:boolean, note:string} } +const RULES = [ + // ---- TYPOGRAPHY ------------------------------------------------------- + { + id: 'type-overused-font', dim: 'typography', + label: 'Display/body face is an overused AI default (Inter, Roboto, Geist…)', + fn: ({ fams }) => { + const hit = fams.filter((f) => FONT_OVERUSED.includes(f)); + return { pass: hit.length === 0, note: hit.length ? `uses ${hit.join(', ')}` : 'distinctive faces' }; + }, + }, + { + id: 'type-single-font', dim: 'typography', + label: 'Single font family across the whole page', + fn: ({ fams }) => ({ pass: fams.length !== 1, note: `${fams.length} distinct families` }), + }, + { + id: 'type-too-many-fonts', dim: 'typography', + label: 'More than three distinct font families (gate 39)', + fn: ({ fams }) => ({ pass: fams.length <= 3, note: `${fams.length} families: ${fams.join(', ') || 'none'}` }), + }, + { + id: 'type-allcaps-body', dim: 'typography', + label: 'All-caps applied to body/paragraph text', + fn: ({ rules }) => { + const bad = rules.find((r) => /(^|[\s,])(body|p|li|article)\b/.test(r.sel) && /text-transform\s*:\s*uppercase/.test(r.body)); + return { pass: !bad, note: bad ? `on ${bad.sel}` : 'body is mixed-case' }; + }, + }, + { + id: 'type-tight-leading', dim: 'typography', + label: 'Body line-height below 1.3', + fn: ({ rules, map }) => { + for (const r of rules) { + if (!/(^|[\s,])(body|p|li|article|html)\b/.test(r.sel)) continue; + const m = r.body.match(/line-height\s*:\s*([0-9.]+)\b/); + if (m && parseFloat(m[1]) < 1.3 && parseFloat(m[1]) > 0) return { pass: false, note: `line-height ${m[1]} on ${r.sel}` }; + } + return { pass: true, note: 'comfortable leading' }; + }, + }, + { + id: 'type-wide-tracking-body', dim: 'typography', + label: 'Letter-spacing above 0.05em on body text', + fn: ({ rules }) => { + for (const r of rules) { + if (!/(^|[\s,])(body|p|li)\b/.test(r.sel)) continue; + const m = r.body.match(/letter-spacing\s*:\s*([0-9.]+)em/); + if (m && parseFloat(m[1]) > 0.05) return { pass: false, note: `${m[1]}em on ${r.sel}` }; + } + return { pass: true, note: 'tracking in range' }; + }, + }, + { + id: 'type-tiny-body', dim: 'typography', + label: 'Body text below 12px', + fn: ({ rules }) => { + for (const r of rules) { + if (!/(^|[\s,])(body|p|li)\b/.test(r.sel)) continue; + const m = r.body.match(/font-size\s*:\s*([0-9.]+)px/); + if (m && parseFloat(m[1]) < 12) return { pass: false, note: `${m[1]}px on ${r.sel}` }; + } + return { pass: true, note: 'legible body size' }; + }, + }, + + // ---- COLOR & CONTRAST ------------------------------------------------- + { + id: 'color-gradient-text', dim: 'color', + label: 'Gradient clipped to text (background-clip: text)', + fn: ({ css }) => { + const bad = /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/i.test(css) && /gradient/i.test(css); + return { pass: !bad, note: bad ? 'gradient text headline' : 'solid headline fill' }; + }, + }, + { + id: 'color-ai-palette', dim: 'color', + label: 'AI purple/violet→cyan gradient', + fn: ({ css }) => { + const grads = [...css.matchAll(/(linear|radial|conic)-gradient\([^;}]*\)/gi)].map((m) => m[0]); + for (const g of grads) { + const kw = /purple|violet|indigo|fuchsia|magenta|#8b5cf6|#6366f1|#7c3aed|#a855f7/i.test(g); + const cyan = /cyan|teal|#06b6d4|#22d3ee/i.test(g); + const hues = [...g.matchAll(/oklch\([^)]*\)/gi)].map((x) => oklchH(x[0])).filter((h) => h != null); + const aiHue = hues.some((h) => h >= 270 && h <= 330); + if ((kw && cyan) || kw || aiHue) return { pass: false, note: `tell in ${g.slice(0, 40)}…` }; + } + return { pass: true, note: 'no AI-palette gradient' }; + }, + }, + { + id: 'color-pure-black-bg', dim: 'color', + label: 'Pure #000 / oklch(0) used as a base background', + fn: ({ rules, map }) => { + for (const r of rules) { + const m = r.body.match(/background(?:-color)?\s*:\s*([^;]+)/i); + if (!m) continue; + const v = resolveVar(m[1], map).toLowerCase(); + if (/#000(\b|000\b)|\boklch\(\s*0\s+0\b|\brgb\(\s*0\s*,\s*0\s*,\s*0\s*\)|\bblack\b/.test(v)) return { pass: false, note: `pure black bg on ${r.sel}` }; + } + return { pass: true, note: 'no pure-black base' }; + }, + }, + { + id: 'color-zero-chroma', dim: 'color', + label: 'Zero-chroma flat-grey neutrals (gate 24)', + fn: ({ map, genre }) => { + if (genre === 'modern-minimal') return { pass: true, note: 'modern-minimal allows zero-chroma' }; + for (const [k, v] of Object.entries(map)) { + if (!/--color|--paper|--ink|--surface|--muted|--neutral|--bg/.test(k)) continue; + const c = oklchC(resolveVar(v, map)); + if (c === 0) return { pass: false, note: `${k} has 0 chroma` }; + } + return { pass: true, note: 'neutrals tinted toward anchor' }; + }, + }, + { + id: 'color-token-discipline', dim: 'color', + label: 'Colour literal outside the token block (gate 58)', + fn: ({ rules }) => { + const offenders = []; + for (const r of rules) { + if (/:root|\[data-theme/.test(r.sel)) continue; + const lits = (r.body.match(COLOR_LITERAL) || []).filter((c) => !/transparent|currentcolor|inherit|none/i.test(c)); + if (lits.length) offenders.push(`${r.sel}: ${lits[0]}`); + } + return { pass: offenders.length === 0, note: offenders.length ? `${offenders.length} literal(s), e.g. ${offenders[0]}` : 'all colours via tokens' }; + }, + }, + { + id: 'color-ink-on-ink', dim: 'color', + label: 'Text lightness too close to its background (ink-on-ink, gates 46–50)', + fn: ({ rules, map }) => { + for (const r of rules) { + if (/:root|\[data-theme/.test(r.sel)) continue; + const cM = r.body.match(/(? { + for (const r of rules) { + // a left rule on a blockquote/figure is a typographic convention, not the card tell + if (/\b(blockquote|figure|aside|q|cite)\b/.test(r.sel)) continue; + const m = r.body.match(/border-(left|right)\s*:\s*([0-9.]+)px\s+\w+\s+([^;]+)/i); + if (!m) continue; + const w = parseFloat(m[2]); + const col = resolveVar(m[3], map).toLowerCase(); + if (w >= 4 && !/transparent/.test(col)) return { pass: false, note: `${m[2]}px ${m[1]} stripe on ${r.sel}` }; + } + return { pass: true, note: 'no side-tab stripe' }; + }, + }, + { + id: 'visual-glassmorphism', dim: 'visual', + label: 'Glassmorphism (backdrop blur on translucent panels)', + fn: ({ css }) => { + const bad = /backdrop-filter\s*:\s*[^;]*blur/i.test(css) && /rgba?\([^)]*0?\.\d+\s*\)|\/\s*0?\.\d+\s*\)/.test(css); + return { pass: !bad, note: bad ? 'translucent blur panel' : 'no glass panels' }; + }, + }, + { + id: 'visual-sparkline-decoration', dim: 'visual', + label: 'Sparkline / chart used as pure decoration', + fn: ({ html }) => { + const bad = /class="[^"]*\b(sparkline|spark-line|decor[a-z-]*chart|fake-chart)\b/i.test(html); + return { pass: !bad, note: bad ? 'decorative sparkline present' : 'no decorative charts' }; + }, + }, + + // ---- LAYOUT & SPACE --------------------------------------------------- + { + id: 'layout-center-everything', dim: 'layout', + label: 'Everything centre-aligned (≥4 text-align:center)', + fn: ({ css }) => { + const n = (css.match(/text-align\s*:\s*center/gi) || []).length; + return { pass: n < 4, note: `${n} centred blocks` }; + }, + }, + { + id: 'layout-justified', dim: 'layout', + label: 'Justified body text (word-spacing rivers)', + fn: ({ css }) => { + const bad = /text-align\s*:\s*justify/i.test(css); + return { pass: !bad, note: bad ? 'justified text present' : 'ragged-right text' }; + }, + }, + { + id: 'layout-three-col-cards', dim: 'layout', + label: 'Three equal-column card grid (icon-tile template)', + fn: ({ css }) => { + const bad = /grid-template-columns\s*:\s*repeat\(\s*3\s*,\s*(?:minmax\(0,\s*)?1fr/i.test(css) || /grid-template-columns\s*:\s*1fr\s+1fr\s+1fr\b/i.test(css); + return { pass: !bad, note: bad ? 'repeat(3, 1fr) grid' : 'no rote 3-col grid' }; + }, + }, + { + id: 'layout-long-measure', dim: 'layout', + label: 'Prose measure beyond 75ch (gate 27)', + fn: ({ css }) => { + for (const m of css.matchAll(/max-width\s*:\s*([0-9.]+)ch/gi)) { + if (parseFloat(m[1]) > 75) return { pass: false, note: `${m[1]}ch measure` }; + } + return { pass: true, note: 'measure ≤ 75ch' }; + }, + }, + { + id: 'layout-arbitrary-spacing', dim: 'layout', + label: 'Spacing off the 4px scale (gate 26)', + fn: ({ rules, map }) => { + for (const r of rules) { + if (/:root|\[data-theme/.test(r.sel)) continue; + for (const m of r.body.matchAll(/\b(?:padding|margin|gap|row-gap|column-gap)(?:-\w+)?\s*:\s*([^;]+)/gi)) { + const resolved = resolveVar(m[1], map); + for (const px of resolved.matchAll(/(-?[0-9.]+)px/g)) { + const v = Math.abs(parseFloat(px[1])); + if (v > 0 && v % 4 !== 0) return { pass: false, note: `${px[1]}px on ${r.sel}` }; + } + } + } + return { pass: true, note: 'spacing on 4px scale' }; + }, + }, + { + id: 'layout-skipped-heading', dim: 'layout', + label: 'Skipped heading level (h1→h3 with no h2)', + fn: ({ html }) => { + const lv = headingLevels(html); + for (let i = 1; i < lv.length; i++) { + if (lv[i] - lv[i - 1] > 1) return { pass: false, note: `h${lv[i - 1]}→h${lv[i]}` }; + } + return { pass: true, note: 'heading levels contiguous' }; + }, + }, + + // ---- MOTION ----------------------------------------------------------- + { + id: 'motion-transition-all', dim: 'motion', + label: 'transition: all (gate 11)', + fn: ({ css }) => { + const bad = /transition\s*:\s*all\b/i.test(css); + return { pass: !bad, note: bad ? 'transition: all present' : 'transitions are scoped' }; + }, + }, + { + id: 'motion-hover-scale', dim: 'motion', + label: 'Uniform hover-scale (gate 12)', + fn: ({ css }) => { + const bad = /:hover[^{}]*\{[^{}]*transform\s*:\s*scale\(\s*1\.0[1-9]/i.test(css) || /hover:scale-10[0-9]/i.test(css); + return { pass: !bad, note: bad ? 'hover scale present' : 'no rote hover-scale' }; + }, + }, + { + id: 'motion-bouncy-easing', dim: 'motion', + label: 'Bouncy/overshoot easing on UI state (gate 13)', + fn: ({ css }) => { + for (const m of css.matchAll(/cubic-bezier\(\s*([0-9.-]+)\s*,\s*([0-9.-]+)\s*,\s*([0-9.-]+)\s*,\s*([0-9.-]+)\s*\)/gi)) { + const y1 = parseFloat(m[2]); const y2 = parseFloat(m[4]); + if (y1 > 1 || y2 > 1 || y1 < 0 || y2 < 0) return { pass: false, note: `overshoot ${m[0]}` }; + } + return { pass: true, note: 'no overshoot easing' }; + }, + }, + { + id: 'motion-layout-animation', dim: 'motion', + label: 'Animating layout properties (gate 15)', + fn: ({ css }) => { + const bad = /transition\s*:[^;}]*\b(width|height|top|left|right|bottom|margin|padding)\b/i.test(css); + return { pass: !bad, note: bad ? 'layout prop in transition' : 'animates transform/opacity only' }; + }, + }, + { + id: 'motion-no-reduced-motion', dim: 'motion', + label: 'Animation without prefers-reduced-motion fallback (gate 29)', + fn: ({ css }) => { + const hasMotion = /@keyframes|animation\s*:|transition\s*:/i.test(css); + const hasGuard = /prefers-reduced-motion/i.test(css); + return { pass: !hasMotion || hasGuard, note: hasMotion ? (hasGuard ? 'guarded' : 'no reduced-motion guard') : 'no motion' }; + }, + }, + + // ---- INTERACTION ------------------------------------------------------ + { + id: 'interaction-emoji-icon', dim: 'interaction', + label: 'Emoji used as a feature/step icon (gate 60)', + fn: ({ html }) => { + const body = html.replace(//gi, '').replace(//gi, ''); + const bad = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}]/u.test(body); + return { pass: !bad, note: bad ? 'emoji glyph in markup' : 'no emoji icons' }; + }, + }, + { + id: 'interaction-all-primary', dim: 'interaction', + label: 'Every button styled as primary (no secondary register)', + fn: ({ html, css }) => { + const btns = (html.match(/<(?:button|a)[^>]*class="[^"]*\b(?:btn|button|cta)\b/gi) || []).length; + const hasVariant = /\b(btn|button)[-_]{1,2}(secondary|ghost|outline|tertiary|quiet|text)\b|data-variant|\bbtn--/i.test(html + css); + return { pass: btns < 3 || hasVariant, note: btns >= 3 && !hasVariant ? `${btns} buttons, one register` : 'button hierarchy present' }; + }, + }, + { + id: 'interaction-placeholder-names', dim: 'interaction', + label: 'Placeholder names / startup clichés (gate 20)', + fn: ({ html }) => { + const bad = /jane doe|john smith|john doe|lorem ipsum|\bacme\b|\bnexus\b|seamless|unleash|\bwidget(?:co|inc)\b/i.test(html); + return { pass: !bad, note: bad ? 'placeholder/cliché copy' : 'specific copy' }; + }, + }, + { + id: 'interaction-modal-reflex', dim: 'interaction', + label: 'Reaching for a modal/dialog reflexively', + fn: ({ html }) => { + const bad = / { + const bad = !/(html|body)[^{}]*\{[^{}]*overflow-x\s*:\s*clip/i.test(css) && !/(html|body)\s*,\s*(html|body)[^{}]*\{[^{}]*overflow-x\s*:\s*clip/i.test(css); + return { pass: !bad, note: bad ? 'no overflow-x: clip' : 'overflow-x clipped' }; + }, + }, + { + id: 'responsive-img-grid-minmax', dim: 'responsive', + label: 'Image-bearing 1fr grid track without minmax(0,1fr) (gate 61)', + fn: ({ css, html }) => { + const hasImg = /