From 7394bb41a19ee0c6eb04643b2a0c4cf1087fb8df Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 31 Aug 2026 15:44:16 -0700 Subject: [PATCH] Rebase reconciliation: fold main's post-freeze work into the swapped tree The rebase onto origin/main brought changes whose JS engine halves left the tree with the swap. This commit reconciles what survives: - Suite map: register main's comp-fidelity unit tests (build-phase, comp-diff, font-match, hero-checks) in the core suite and live-browser-ignores in the live suite. - Payload guard: the skill scripts payload now allowlists the comp-fidelity build pipeline (comp-spec/comp-diff/build-phase/font-match and their libs), the one Node toolchain that has not moved into the engine. - Drop skill/scripts/live/project-ignores.mjs, lib/live-path-globs.mjs, and their test: they import hook-lib/live-inject/impeccable-paths, which the swap deleted, and their consumer (the JS live server) is the engine now. - skill text: the comp pipeline's calls to engine verbs (generate-image, embed-prompt) use the launcher spelling. - Oracle: re-record 17 detect goldens over the fixture set main changed (oklch #592, color-mix #578, 1D grid #615, the two comp-fidelity rules) and record the gap in DELTAS.md; those JS rule changes are not yet ported to the engine, and the goldens pin its current behavior. bun run test (oracle included) and bun run build are green on this tree. AI-assisted change: implemented with Claude Code. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WaJv2c4oN8wS7Ttq4XRqyx --- skill/reference/new-work.md | 2 +- skill/scripts/lib/live-path-globs.mjs | 37 ----- skill/scripts/live/project-ignores.mjs | 139 ----------------- tests/build.test.js | 21 ++- tests/live-project-ignores.test.mjs | 145 ------------------ tests/oracle/DELTAS.md | 15 ++ .../golden/detect-dir-json-all-fixtures.json | 2 +- .../golden/detect-dir-quiet-all-fixtures.json | 2 +- .../golden/detect-dir-text-all-fixtures.json | 2 +- ...etect-fixture-json-buried-raster-html.json | 7 + ...-fixture-json-codex-grid-1d-pass-html.json | 7 + .../detect-fixture-json-color-html.json | 2 +- ...ect-fixture-json-oklch-neon-text-html.json | 7 + ...t-fixture-json-organic-clip-path-html.json | 7 + ...etect-fixture-text-buried-raster-html.json | 7 + ...-fixture-text-codex-grid-1d-pass-html.json | 7 + .../detect-fixture-text-color-html.json | 2 +- ...ect-fixture-text-oklch-neon-text-html.json | 7 + ...t-fixture-text-organic-clip-path-html.json | 7 + .../golden/detect-no-advisory-json.json | 2 +- .../golden/detect-no-advisory-text.json | 2 +- tests/oracle/golden/detect-scope-both.json | 2 +- tests/oracle/golden/detect-scope-type.json | 2 +- 23 files changed, 100 insertions(+), 333 deletions(-) delete mode 100644 skill/scripts/lib/live-path-globs.mjs delete mode 100644 skill/scripts/live/project-ignores.mjs delete mode 100644 tests/live-project-ignores.test.mjs create mode 100644 tests/oracle/golden/detect-fixture-json-buried-raster-html.json create mode 100644 tests/oracle/golden/detect-fixture-json-codex-grid-1d-pass-html.json create mode 100644 tests/oracle/golden/detect-fixture-json-oklch-neon-text-html.json create mode 100644 tests/oracle/golden/detect-fixture-json-organic-clip-path-html.json create mode 100644 tests/oracle/golden/detect-fixture-text-buried-raster-html.json create mode 100644 tests/oracle/golden/detect-fixture-text-codex-grid-1d-pass-html.json create mode 100644 tests/oracle/golden/detect-fixture-text-oklch-neon-text-html.json create mode 100644 tests/oracle/golden/detect-fixture-text-organic-clip-path-html.json diff --git a/skill/reference/new-work.md b/skill/reference/new-work.md index 9cb7c3607..0d2f7f343 100644 --- a/skill/reference/new-work.md +++ b/skill/reference/new-work.md @@ -108,7 +108,7 @@ Then, in order, each closed by `node {{scripts_path}}/build-phase.mjs advance` ( The comp-led path is a frontier-tier job: it asks the builder to hold a measured layout, place plates at their boxes, and act on numeric readings across a dozen attempts. Smaller or faster models produce a recognisable page and stall under the hero gate; if the model in hand is one of those, say so before the direction round and take the code-led path, or expect the run to end at the hero with its readings unmet. 1. **spec.** Measure the comp: `comp-spec.mjs --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `comp-spec.mjs --comp --regions `. The spec carries each region's box, sampled palette, and medium; `comp-spec.mjs --print` is the build's reference from here on. Type is measured, not guessed: `font-match.mjs --measure ` reads the comp's cap height, width class, and weight off the pixels, and `font-match.mjs --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. -2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (ink on flat ground is generated on a chroma key and keyed to alpha, so it sits on the page's own ground rather than a second paper); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `generate-image.mjs --plate ` does one region end to end and scores it against the crop; a harness-native image tool takes the crop (`comp-spec.mjs --crop `) as its input image and `comp-spec.mjs --plate-prompt ` as its prompt, then `embed-prompt.mjs`. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. +2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (ink on flat ground is generated on a chroma key and keyed to alpha, so it sits on the page's own ground rather than a second paper); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable generate-image --plate ` does one region end to end and scores it against the crop; a harness-native image tool takes the crop (`comp-spec.mjs --crop `) as its input image and `comp-spec.mjs --plate-prompt ` as its prompt, then `impeccable embed-prompt`. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. 3. **hero.** `build-phase.mjs scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `build-phase.mjs record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `comp-diff.mjs`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. diff --git a/skill/scripts/lib/live-path-globs.mjs b/skill/scripts/lib/live-path-globs.mjs deleted file mode 100644 index 4a3eebda0..000000000 --- a/skill/scripts/lib/live-path-globs.mjs +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Convert a live-config glob pattern to a RegExp. - * - * Supports `**` across path segments, `*` within one segment, and `?` for one - * character. Callers normalize project-relative paths to forward slashes. - */ -export function livePathGlobToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += `\\${c}`; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp(`^${re}$`); -} diff --git a/skill/scripts/live/project-ignores.mjs b/skill/scripts/live/project-ignores.mjs deleted file mode 100644 index e99acaed1..000000000 --- a/skill/scripts/live/project-ignores.mjs +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Project detector waivers for the live overlay (issue #639, hardened in the - * PR #645 follow-up). One place decides what the /live.js prelude serializes - * as window.__IMPECCABLE_PROJECT_IGNORES__: - * - * ignoreRules detector.ignoreRules, unioned across every live root. - * ignoreValues detector.ignoreValues entries ({rule, value, files?}), - * deduped across roots; createdAt/reason stay local. - * ignoreFiles detector.ignoreFiles globs, unioned across roots, so a - * wholly waived page scans to zero findings in the overlay - * just as it reports nothing through the CLI and the hook. - * roots served-root prefixes derived from the inject config's own - * `files` globs. Never derived from the ignore globs: one - * entry scoped to prototype/library/** would lend - * prototype/library/ as a candidate prefix to every page, - * and that rule would suppress site-wide (issue #639). - * pageFiles the inject config's `files` expanded to real project - * files, so the browser can resolve a URL to the one file it - * actually serves instead of trying every root (PR #645 - * review: with src/ and public/ both served, /foo.html must - * not borrow src/foo.html's waivers while actually serving - * public/foo.html). - * - * Config is read from every root the live session spans: the appRoot the - * server chdir'd onto, plus contextRoot and repoRoot when they differ. The - * edit hook keys the same config at the session cwd (the repo root in a - * monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its - * invocation cwd, so reading only the appRoot silently dropped every waiver - * in exactly the monorepo layouts the roots manifest exists for. Reading is - * additive across roots, matching readConfig's own union of config.json and - * config.local.json. - * - * In a monorepo, roots and pageFiles are serialized repo-relative (the - * appRoot's path inside the repo is prefixed), so waivers spelled from - * either root match through the resolver's suffix expansion. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { readConfig } from '../hook-lib.mjs'; -import { resolveFiles } from '../live-inject.mjs'; -import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs'; - -// Serializing thousands of page identities into every /live.js response -// helps nobody; past this cap pageFiles is omitted and the resolver falls -// back to the served-root common ancestor, which is correct, just less -// precise about cross-root duplicates. -const PAGE_FILES_CAP = 500; - -export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) { - const configRoots = []; - for (const dir of [appRoot, contextRoot, repoRoot]) { - if (typeof dir !== 'string' || !dir) continue; - const resolved = path.resolve(dir); - if (!configRoots.includes(resolved)) configRoots.push(resolved); - } - if (configRoots.length === 0) configRoots.push(process.cwd()); - - const ignoreRules = new Set(); - const ignoreFiles = new Set(); - const valueEntries = new Map(); - for (const dir of configRoots) { - // readConfig merges config.json with the gitignored config.local.json - // and type-checks both, exactly as the edit hook reads the same pair. - const config = readConfig(dir); - for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) { - if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule); - } - for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) { - if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob); - } - for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) { - if (!entry || typeof entry !== 'object') continue; - // readConfig already normalized rule/value and folded `file` into - // `files`; serve only what the browser matches on. - const serialized = { - rule: entry.rule, - value: entry.value, - ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), - }; - const key = JSON.stringify([serialized.rule, serialized.value, - Array.isArray(serialized.files) ? [...serialized.files].sort() : []]); - if (!valueEntries.has(key)) valueEntries.set(key, serialized); - } - } - - const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir }); - return { - ignoreRules: [...ignoreRules], - ignoreValues: [...valueEntries.values()], - ignoreFiles: [...ignoreFiles], - roots: served.roots, - pageFiles: served.pageFiles, - }; -} - -function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) { - let live = null; - try { - const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir }); - live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - } catch { - // No readable inject config: the browser matches URL paths as-is. - return { roots: [], pageFiles: [] }; - } - const files = Array.isArray(live?.files) - ? live.files.filter((glob) => typeof glob === 'string' && glob) - : []; - - // A monorepo appRoot serializes identities repo-relative, so waivers - // spelled from either root match through the resolver's suffix expansion. - let prefix = ''; - if (typeof repoRoot === 'string' && repoRoot) { - const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/'); - if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`; - } - - const roots = [...new Set(files.map((glob) => { - const wildcardAt = glob.search(/[*?{]/); - const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); - const cut = head.lastIndexOf('/'); - return prefix + (cut > -1 ? head.slice(0, cut + 1) : ''); - }))]; - - let pageFiles = []; - try { - pageFiles = resolveFiles(appRoot, { ...live, files }) - .filter((rel) => { - // resolveFiles passes literal entries through even when they do not - // exist; a missing file is nobody's identity. - try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; } - }) - .map((rel) => prefix + rel); - } catch { - pageFiles = []; - } - if (pageFiles.length > PAGE_FILES_CAP) pageFiles = []; - - return { roots, pageFiles }; -} diff --git a/tests/build.test.js b/tests/build.test.js index 073fc21a6..fe36c931f 100644 --- a/tests/build.test.js +++ b/tests/build.test.js @@ -393,8 +393,25 @@ describe('skill scripts payload', () => { } }); - test('ships no Node entry points and no bundled detector', () => { - expect([...names].filter((n) => n.endsWith('.mjs') || n.startsWith('detector/') || n.startsWith('lib/'))).toEqual([]); + test('ships no engine entry points and no bundled detector', () => { + // The engine verbs live in the binary; the only Node scripts allowed in + // the payload are the comp-fidelity build pipeline and its libs, which + // have not moved into the engine yet. + const allowedNodeScripts = new Set([ + 'build-phase.mjs', + 'comp-diff.mjs', + 'comp-spec.mjs', + 'font-match.mjs', + 'lib/font-fingerprint.mjs', + 'lib/font-index.mjs', + 'lib/hero-checks.mjs', + 'lib/image-metrics.mjs', + 'lib/png.mjs', + 'lib/raster.mjs', + ]); + const stray = [...names].filter((n) => + (n.endsWith('.mjs') || n.startsWith('detector/') || n.startsWith('lib/')) && !allowedNodeScripts.has(n)); + expect(stray).toEqual([]); }); test('never reads platform binaries as source', () => { diff --git a/tests/live-project-ignores.test.mjs b/tests/live-project-ignores.test.mjs deleted file mode 100644 index 3669d63f2..000000000 --- a/tests/live-project-ignores.test.mjs +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, it, after } from 'node:test'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { collectProjectDetectorIgnores } from '../skill/scripts/live/project-ignores.mjs'; - -const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); -const SCRIPTS_DIR = path.join(REPO_ROOT, 'skill', 'scripts'); - -const tempDirs = []; -function makeTemp() { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-project-ignores-')); - tempDirs.push(dir); - return dir; -} -after(() => { - for (const dir of tempDirs) { - try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } - } -}); - -function write(root, rel, content) { - const filePath = path.join(root, rel); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, content); -} - -function writeDetectorConfig(root, detector) { - write(root, '.impeccable/config.json', JSON.stringify({ detector }, null, 2)); -} - -function writeLiveConfig(root, files) { - write(root, '.impeccable/live/config.json', JSON.stringify({ - files, - insertBefore: '', - commentSyntax: 'html', - }, null, 2)); -} - -describe('collectProjectDetectorIgnores', () => { - it('collects waivers, roots, and pageFiles from a single-root project', () => { - const app = makeTemp(); - write(app, 'package.json', '{"name":"single","private":true}\n'); - writeDetectorConfig(app, { - ignoreRules: ['ai-color-palette'], - ignoreFiles: ['prototype/legacy/**'], - ignoreValues: [ - { rule: 'gradient-text', value: '*', files: ['prototype/library/**'], reason: 'stays local' }, - ], - }); - writeLiveConfig(app, ['prototype/index.html', 'prototype/library/buttons.html']); - write(app, 'prototype/index.html', ''); - write(app, 'prototype/library/buttons.html', ''); - - const out = collectProjectDetectorIgnores({ appRoot: app, scriptsDir: SCRIPTS_DIR }); - assert.deepEqual(out.ignoreRules, ['ai-color-palette']); - assert.deepEqual(out.ignoreFiles, ['prototype/legacy/**']); - // createdAt/reason stay local; only rule/value/files ride to the browser. - assert.deepEqual(out.ignoreValues, [ - { rule: 'gradient-text', value: '*', files: ['prototype/library/**'] }, - ]); - assert.deepEqual(out.roots.sort(), ['prototype/', 'prototype/library/']); - assert.deepEqual(out.pageFiles.sort(), ['prototype/index.html', 'prototype/library/buttons.html']); - }); - - it('reads waivers keyed at the repo root, where the hook and the CLI put them', () => { - // The monorepo shape from the PR #645 review: the live server chdirs - // onto the child appRoot, while resolveCacheCwd keys the hook's config - // at the session cwd, which is the repo root. - const repo = makeTemp(); - const app = path.join(repo, 'site'); - fs.mkdirSync(path.join(repo, '.git'), { recursive: true }); - write(app, 'package.json', '{"name":"site","private":true}\n'); - writeDetectorConfig(repo, { - ignoreRules: ['ai-color-palette'], - ignoreValues: [{ rule: 'overused-font', value: 'space grotesk' }], - }); - writeLiveConfig(app, ['prototype/index.html']); - write(app, 'prototype/index.html', ''); - - const out = collectProjectDetectorIgnores({ appRoot: app, repoRoot: repo, scriptsDir: SCRIPTS_DIR }); - assert.deepEqual(out.ignoreRules, ['ai-color-palette']); - assert.deepEqual(out.ignoreValues, [{ rule: 'overused-font', value: 'space grotesk' }]); - // Identities serialize repo-relative so waivers spelled from either root - // match through the resolver's suffix expansion. - assert.deepEqual(out.roots, ['site/prototype/']); - assert.deepEqual(out.pageFiles, ['site/prototype/index.html']); - }); - - it('unions configs across roots and dedupes identical value entries', () => { - const repo = makeTemp(); - const app = path.join(repo, 'site'); - fs.mkdirSync(path.join(repo, '.git'), { recursive: true }); - write(app, 'package.json', '{"name":"site","private":true}\n'); - writeDetectorConfig(repo, { - ignoreRules: ['ai-color-palette'], - ignoreValues: [{ rule: 'overused-font', value: 'space grotesk' }], - }); - writeDetectorConfig(app, { - ignoreRules: ['gradient-text', 'ai-color-palette'], - ignoreValues: [{ rule: 'overused-font', value: 'space grotesk' }], - }); - writeLiveConfig(app, ['prototype/index.html']); - write(app, 'prototype/index.html', ''); - - const out = collectProjectDetectorIgnores({ appRoot: app, repoRoot: repo, scriptsDir: SCRIPTS_DIR }); - assert.deepEqual(out.ignoreRules.sort(), ['ai-color-palette', 'gradient-text']); - assert.deepEqual(out.ignoreValues, [{ rule: 'overused-font', value: 'space grotesk' }]); - }); - - it('expands glob file entries to existing files and drops missing literals', () => { - const app = makeTemp(); - write(app, 'package.json', '{"name":"globs","private":true}\n'); - writeLiveConfig(app, ['prototype/**/*.html', 'prototype/not-created-yet.html']); - write(app, 'prototype/index.html', ''); - write(app, 'prototype/library/buttons.html', ''); - - const out = collectProjectDetectorIgnores({ appRoot: app, scriptsDir: SCRIPTS_DIR }); - assert.deepEqual(out.pageFiles.sort(), ['prototype/index.html', 'prototype/library/buttons.html']); - assert.deepEqual(out.roots.sort(), ['prototype/']); - }); - - it('degrades to empty arrays when nothing is configured', () => { - const app = makeTemp(); - write(app, 'package.json', '{"name":"bare","private":true}\n'); - const out = collectProjectDetectorIgnores({ appRoot: app, scriptsDir: SCRIPTS_DIR }); - assert.deepEqual(out, { ignoreRules: [], ignoreValues: [], ignoreFiles: [], roots: [], pageFiles: [] }); - }); - - it('survives a malformed detector config without throwing', () => { - const app = makeTemp(); - write(app, 'package.json', '{"name":"broken","private":true}\n'); - write(app, '.impeccable/config.json', '{"detector":{"ignoreRules":"foo","ignoreValues":[null,7],"ignoreFiles":{}}}'); - writeLiveConfig(app, ['prototype/index.html']); - write(app, 'prototype/index.html', ''); - - const out = collectProjectDetectorIgnores({ appRoot: app, scriptsDir: SCRIPTS_DIR }); - assert.deepEqual(out.ignoreRules, []); - assert.deepEqual(out.ignoreValues, []); - assert.deepEqual(out.ignoreFiles, []); - assert.deepEqual(out.pageFiles, ['prototype/index.html']); - }); -}); diff --git a/tests/oracle/DELTAS.md b/tests/oracle/DELTAS.md index 53b29040f..22de13e94 100644 --- a/tests/oracle/DELTAS.md +++ b/tests/oracle/DELTAS.md @@ -33,3 +33,18 @@ origin but not `'wasm-unsafe-eval'` still refuses to compile it. The JS `patchCspMeta` predates the wasm bundle and appended only the origin. - `live-inject-csp-meta-no-connect-src`: the patched `` reads `script-src 'self' http://localhost:8412 'wasm-unsafe-eval'` (was `script-src 'self' http://localhost:8412`). The `data-impeccable-csp-original` marker, the `connect-src` and `img-src` additions, idempotence, and the revert on unpatch are unchanged. `live-inject-vite-csp-meta` and `live-inject-next-jsx` carry meta tags the patch does not touch, so their goldens did not move. + +## Recorded 2026-08-31: main's post-freeze fixture changes, goldens re-recorded from the engine + +The rebase onto main brought fixture updates whose paired JS rule changes have +not been ported to the engine yet. The detect goldens below are re-recorded +from the binary, so they pin the engine's current behavior on the new fixture +content; the entries name the upstream JS change each one still owes. Until a +rule ships in the engine and its golden is re-recorded, the golden is the pin +of the gap, not an endorsement of it. + +- `detect-fixture-json-color-html`, `detect-fixture-text-color-html`: the fixture gained the color-mix nested-hex column (upstream 54440319, #578, with explicit sizes from 7426af44); the engine still reads hex codes inside `color-mix(...)` when measuring gradient contrast, so its readings on the reshaped fixture differ from the JS engine's. +- `detect-fixture-json-oklch-neon-text-html`, `detect-fixture-text-oklch-neon-text-html`: new fixture for oklch parsing in visual-contrast and neon-text (upstream 1b7da15b, #592, columns from 8347d77f); the engine does not parse oklch there yet, so the flag column's neon-text goes unflagged and a mis-read low-contrast is recorded. +- `detect-fixture-json-codex-grid-1d-pass-html`, `detect-fixture-text-codex-grid-1d-pass-html`: new pass-case fixture for 1D dashed rules (upstream a236137b/7ddcd533, #615); the engine still flags the 1D line-field as `codex-grid-background`, which is the pre-fix behavior the fixture exists to retire. +- `detect-fixture-json-organic-clip-path-html`, `detect-fixture-text-organic-clip-path-html`, `detect-fixture-json-buried-raster-html`, `detect-fixture-text-buried-raster-html`: fixtures for the two comp-fidelity rules (upstream 58561610: organic-clip-path, buried-raster); neither rule exists in the engine, so only incidental findings (or none) are recorded. +- `detect-dir-json-all-fixtures`, `detect-dir-text-all-fixtures`, `detect-dir-quiet-all-fixtures`, `detect-scope-type`, `detect-scope-both`, `detect-no-advisory-json`, `detect-no-advisory-text`: directory-wide sweeps over `tests/fixtures/antipatterns/`; re-recorded because the fixture set above grew and changed, shifting counts and orderings. diff --git a/tests/oracle/golden/detect-dir-json-all-fixtures.json b/tests/oracle/golden/detect-dir-json-all-fixtures.json index cc692d65c..09985b007 100644 --- a/tests/oracle/golden/detect-dir-json-all-fixtures.json +++ b/tests/oracle/golden/detect-dir-json-all-fixtures.json @@ -1,5 +1,5 @@ { - "stdout": "[\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 52,\n \"snippet\": \"[data-case=\\\"Kinpaku Edge\\\"] — inset box-shadow 3px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 53,\n \"snippet\": \"[data-case=\\\"Patina Edge\\\"] — inset box-shadow 3px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 54,\n \"snippet\": \"[data-case=\\\"Accent Edge\\\"] — inset box-shadow 4px stripe (right)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 55,\n \"snippet\": \"[data-case=\\\"Signal Blue Edge\\\"] — inset box-shadow 5px stripe (top)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 66,\n \"snippet\": \"[data-case=\\\"Chromatic Hex Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 67,\n \"snippet\": \"[data-case=\\\"Named Red Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 68,\n \"snippet\": \"[data-case=\\\"Chromatic Rgb Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 69,\n \"snippet\": \"[data-case=\\\"Chromatic Oklch Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 78,\n \"snippet\": \"[data-case=\\\"Trailing Inset Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 79,\n \"snippet\": \"[data-case=\\\"Trailing Inset Token Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 81,\n \"snippet\": \"[data-case=\\\"Inset Named Token Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 86,\n \"snippet\": \"[data-case=\\\"Two Length Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 87,\n \"snippet\": \"[data-case=\\\"Two Length Trailing Inset Edge\\\"] — inset box-shadow 5px stripe (top)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 88,\n \"snippet\": \"[data-case=\\\"Important Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 89,\n \"snippet\": \"[data-case=\\\"Cascade Override Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 91,\n \"snippet\": \"[data-case=\\\"Color First Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 92,\n \"snippet\": \"[data-case=\\\"Color First Var Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"pulsing-dot\",\n \"name\": \"Pulsing status dot\",\n \"description\": \"Small pulsing status dots simulate liveness decoratively. Reserve pulse animation for indicators tied to genuinely live, changing data; a static indicator with clear labeling is honest and calmer.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/blinking-cursor.html\",\n \"line\": 0,\n \"snippet\": \".pass-round-dot — 8x8px dot with infinite \\\"blink-anim\\\" animation\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-left: 4px + border-radius: 10px\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-right: 5px + border-radius: 10px\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-left: 4px\"\n },\n {\n \"antipattern\": \"border-accent-on-rounded\",\n \"name\": \"Border accent on rounded element\",\n \"description\": \"Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-top: 4px + border-radius: 10px\"\n },\n {\n \"antipattern\": \"border-accent-on-rounded\",\n \"name\": \"Border accent on rounded element\",\n \"description\": \"Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-bottom: 3px + border-radius: 10px\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-left: 4px + border-radius: 10px\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-top: 4px\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-bottom: 3px\"\n },\n {\n \"antipattern\": \"cramped-padding\",\n \"name\": \"Cramped padding\",\n \"description\": \"Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"
\\\"pass-split-container\\\": children flush against border on all sides (no inset)\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-hidden clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-clip clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-negative clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-right clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-shadow-utility clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overlay-surface clips a positioned child\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text #969696 on bg #3b82f6\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.2:1 (need 4.5:1) — text #969696 on #3b82f6\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text #b4b4b4 on bg #10b981\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.2:1 (need 4.5:1) — text #b4b4b4 on #10b981\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.7:1 (need 4.5:1) — text #c8c8c8 on #ffffff\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"2.1:1 (need 4.5:1) — text #505050 on #1e1e1e\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text #808080 on bg gradient(#3b82f6, #8b5cf6)\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.1:1 (need 3:1) — text #808080 on #8b5cf6\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text #666666 on bg gradient(#3b82f6, #8b5cf6)\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.4:1 (need 4.5:1) — text #666666 on #8b5cf6\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"background-clip: text + gradient\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"2.2:1 (need 4.5:1) — text #5b4f44 on #1f1a15\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"2.1:1 (need 4.5:1) — text #6c7280 on #374151\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text #5c5449 on bg #b6322d\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.2:1 (need 4.5:1) — text #5c5449 on #b6322d\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text-gray-400 on bg-blue-500\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text #9ca3af on bg #3b82f6\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.4:1 (need 4.5:1) — text #9ca3af on #3b82f6\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet text (#a855f7) on heading\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text-purple-500 on heading\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet gradient (Tailwind)\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"4.0:1 (need 4.5:1) — text #ffffff on #a855f7\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.3:1 (need 4.5:1) — text #cfc9bd on #e8e2d6\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"4.1:1 (need 4.5:1) — text #ffffff on #7d7d7d\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"background-clip: text + gradient\"\n },\n {\n \"antipattern\": \"undersized-ui-text\",\n \"name\": \"Undersized functional text\",\n \"description\": \"Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"8px functional text \\\"tick\\\" (below 11px floor)\"\n },\n {\n \"antipattern\": \"skipped-heading\",\n \"name\": \"Skipped heading level\",\n \"description\": \"Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"

\\\"Welcome to Our Platform\\\" followed by

\\\"Gradient text\\\" (missing h2)\"\n },\n {\n \"antipattern\": \"skipped-heading\",\n \"name\": \"Skipped heading level\",\n \"description\": \"Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"

\\\"Purple heading text\\\" followed by

\\\"Styled and