Nine prompts · a build sequence
Turn your design system
into something CI
can fail on.
These prompts build a real design-QA gate for your codebase — one that boots a browser, scrolls the page the way a reader would, lets the animations play, and then fails the build naming the element that moved. Not a score. The element. All nine are on this page in full; nothing is behind a click you have to take on trust.
Fig. 01 — the failure prompt 03 exists to catch, and what it prints when it does
Every prompt is three things
- The prompt
- The full text, not a summary of one. Portable to any Next, React, Vite or plain-HTML site — where a rule is specific to one codebase, the prompt makes the model derive it from yours instead of shipping mine.
- The artifact
- What you get back, named file by file. A prompt that ends in advice is worth nothing; every one of these ends in code you can run, and says exactly which files appear.
- The proof
- A deliberately broken fixture the check must catch, and a control it must not flag. Every check here reports by not finding something — so an unproven one reports a clean page forever, which is the worst way for a check to fail.
The sequence
Read in order. The checks depend on each other, and each card says which step it is blocked on: you cannot compare rendered pixels to a design system nobody wrote down, you cannot measure anything without a harness that boots the app, and every measuring check after that is worthless until its probe is proven to see.
- starts here766 words
Design contract first
You are going to extract this project's design rules into one machine-readable contract that automated checks can import. **Do not write any checks in this task.** The contract is the deliverable. **Step 1 — find where the rules are written today.** Read, in this order: README / CONTRIBUTING / AGENTS.md / CLAUDE.md / any docs/ design or style guide; the Tailwind or theme config; the global stylesheet's custom properties; and the ten most-imported layout components (find them by grepping imports, not by guessing). Collect every rule stated as a number, an exact string, or an absolute — "the content column is 1200px", "radius is 12px everywhere", "body copy never exceeds 72ch", "only transform and opacity animate", "one h1 per page". **Step 2 — sort what you found into three buckets and print the table before writing any file.** - MEASURABLE — has a number or an exact string, and a rendered page either matches it or does not. Goes in the contract. - JUDGEMENT — "feels balanced", "reads premium", "generous whitespace". Does NOT go in the contract. Write these to `design/not-checkable.md` with one line each so nobody re-litigates them into the audit later. - AMBIGUOUS — stated, but with unlisted exceptions: "cards are square" when the sidebar cards obviously are not. **Stop and ask me one question per item.** Do not guess the exception list; a guessed exception becomes a rule that fires on correct code, and a check that fires on correct code gets muted within a week. **Step 3 — write `design/contract.mjs`.** Plain data. No logic, no branching, no imports of application code. Every entry carries the value, a one-line `why`, and a `source` pointing at the file and line the rule was written in: ```js export const CONTRACT = { readingColumns: { value: 1, why: "all running text starts at one left edge; media may be wider", source: "docs/design.md:41" }, contentWidth: { value: 1200, why: "the column every text block resolves to", source: "docs/design.md:44" }, cardRadius: { value: 12, why: "one radius across the product", source: "tailwind.config.ts:31" }, clsBudget: { value: 0.01, why: "entrances are transform/opacity only, so anything above noise is a real reflow", source: "docs/design.md:70" }, retired: { value: ["1040px", "830x470"], why: "pre-2024 column widths; grep prose for these", source: "git log" }, }; ``` **Step 4 — bind the contract to where the app actually holds the number.** For each entry whose value also exists in the app (a CSS custom property, a theme key, a constant), add `binding: { file, declaration }`. This is what later lets one check assert the app and the contract still agree — and it is the only honest answer to "which one is the source of truth", because there are always two. **Step 5 — rewrite mechanism rules as the invariant they exist to produce.** This is the most important instruction here. "Use the `<Container>` component" is a mechanism; the invariant is "running text starts at one left edge." Check the invariant. A check that asserts the right class name passes a page where two nested containers compounded their padding and shoved a paragraph 16px right — it looks correct in source and wrong on screen. Every entry in the contract must be phrased as something a browser can measure about the rendered page, or as an exact string a parser can find in a file. If you cannot phrase it that way, it belongs in `not-checkable.md`. **Step 6 — name the exemption channels now, in the contract, before any check exists.** Declare the attribute a component uses to say "I overflow on purpose", the attribute that marks a component which is already its own frame, and the path prefixes for vendored or generated code that checks must downgrade rather than fail. Exemptions invented later, at the call site, under pressure to make a red build green, are how audits die. **Step 7 — write `design/contract.test.mjs`.** One test: grep the checker directory for every top-level key in CONTRACT and fail if a key is referenced by zero checkers. An entry nobody imports is a rule nobody enforces, and it will read as enforced to the next person who opens the file. **Hand back:** the three-bucket table, the questions from the AMBIGUOUS bucket (do not proceed past them on your own), the contract file, and the output of `node --test design/contract.test.mjs`. If fewer than five rules landed in MEASURABLE, say so plainly — this project may not have a written design system yet, and the honest next step is to write one, not to invent one from the code and present it as discovered.
design/contract.mjs - starts here614 words
Measurement harness
Build `qa/harness.mjs`: the part of a rendered design audit that boots the app, opens each route, reads it the way a person does, and isolates failures. It contains no checks — it hands measured data to checkers. Use `puppeteer-core` (not `puppeteer`; do not download a second Chrome). **Find Chrome, and fail loudly if it is missing.** Try `process.env.CHROME_PATH` first, then a platform candidate list (`/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`, `/Applications/Chromium.app/Contents/MacOS/Chromium`, `/usr/bin/google-chrome`, `/usr/bin/chromium`, and the Windows Program Files paths). If none exists, **throw with the full list of paths tried and the CHROME_PATH hint**. Never `process.exit(0)` on a missing browser — a check that silently skips itself in CI is worse than no check, because the green tick is a lie. **Boot the app on a port nobody is using.** Get one with `net.createServer().listen(0)`, read `address().port`, close it, then spawn the dev server on that port. Detect the package manager from the lockfile (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `bun.lockb` → bun, else npm) and read the dev command out of `package.json` scripts — do not hardcode either. Capture stdout and stderr into a string; stream it to stderr only under `--verbose`. **Wait for it properly.** Poll `GET base` every 400ms with `AbortSignal.timeout(4000)` per attempt against a 90s deadline. Return on the first `res.ok`. **Reuse a server that is already running.** Many dev servers refuse to start a second instance and print the URL of the existing one. If the boot times out, search the captured log for that message, extract the `http://localhost:NNNN` (strip ANSI escapes), verify it responds within 10s, and measure that one instead — recording `reused: true` and returning a **no-op `stop()`**, because it was not yours to stop. This single behaviour is the difference between an audit people run and an audit that demands they close their editor first. Also support `--base <url>` to skip spawning entirely and measure a preview deployment. **Read each route the way a reader does.** Not `scrollTo(bottom)`. Measure `document.documentElement.scrollHeight`, divide the scrollable distance into 14 steps (minimum 200px each), and at each step `window.scrollTo({top, behavior:'instant'})` then wait 320ms. Components gated on entering the viewport need to actually enter it, mount, and start. **Time is an explicit input to this audit** — put the step count and dwell in named constants with a comment saying what they are for, so the next person tunes them instead of deleting them. **Wait for real content, generously.** `waitForSelector` on the app's main content root (`main`, `article`, `[data-testid=…]` — read the app and pick one) with a **120 second** timeout, and write the reason in a comment: a first-request dev compile reported as a failed audit is a false alarm, and nobody trusts a flaky gate twice. Then a short settle wait (~1200ms) before measuring. **Isolate failures per route.** Wrap each route's measurement in try/catch; on error, record a FAIL for that route naming the error message and continue to the next. One broken route must never suppress nine routes' findings. **Flags:** `--route <path>` (measure one), `--base <url>`, `--json`, `--strict`, `--quiet`, `--verbose`. Always close the browser and stop the server in a `finally`. **Route discovery:** enumerate routes from the framework's own file conventions (app/ or pages/ directory walk, a routes manifest, or the sitemap) — and if the project keeps a list of published pages somewhere, read that list rather than copying the slugs into the harness. Print the routes it found before measuring, so a route that silently stopped being audited is visible. **Hand back:** the file, the printed route list, and one full run against this project showing per-route timings. State the real wall-clock time — if the first run takes four minutes because of a cold compile, say four minutes.
qa/harness.mjs - after 02614 words
Layout shift, named
Add a layout-shift gate to the browser harness that never reports a bare score. Every failure must name the element that moved and print its geometry before and after. **Install the observer before the page's own code runs.** Via `page.evaluateOnNewDocument` (not `page.evaluate` after navigation — you will miss every shift during hydration): ```js window.__shifts = []; const describe = (node) => { if (!node || node.nodeType !== 1) return "(unknown)"; const parts = []; for (let el = node; el && el.nodeType === 1 && parts.length < 4; el = el.parentElement) { let s = el.tagName.toLowerCase(); if (el.id) s += `#${el.id}`; else if (typeof el.className === "string" && el.className.trim()) s += "." + el.className.trim().split(/\s+/).slice(0, 2).join("."); parts.unshift(s); } return parts.join(" > "); }; new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.hadRecentInput) continue; window.__shifts.push({ value: entry.value, sources: (entry.sources ?? []).map((s) => ({ node: describe(s.node), from: s.previousRect ? { y: s.previousRect.y, h: s.previousRect.height } : null, to: s.currentRect ? { y: s.currentRect.y, h: s.currentRect.height } : null, })), }); } }).observe({ type: "layout-shift", buffered: true }); ``` `buffered: true` is load-bearing — without it you lose everything that shifted before the observer attached. The four-ancestor walk is what turns a score into a bug report. If your app sets a stable test attribute on components, prefer it over the class name inside `describe` — minified class names name nobody. **Measure three times, with three different instruments, because each one fails differently.** 1. *Proof of life.* After the stepped read-through, sum `window.__shifts` and record it as a **fact, not a check** — `route.clsWhileScrolling`. It exists so that a zero from the real check below cannot silently be a dead observer. 2. *The check.* Scroll back to top, reset `window.__shifts = []`, record `document.documentElement.scrollHeight`, wait `PLAY_MS`, then read the shifts. Default `PLAY_MS = 6000` — long enough for one loop of the longest looping animation in this app. **Make it a flag and write the reason in a comment**: a 12-second hero under-measures at 6s, and a fully static page wastes 6s per route. Fail when total CLS exceeds `CONTRACT.clsBudget` (default 0.01 — ten times stricter than Google's 0.1, and the failure message must say why: if entrances are transform/opacity only, anything above measurement noise is a real reflow, not a budget being spent). 3. *The cross-check.* Compare `scrollHeight` before and after the wait. If it changed by more than 4px, fail independently — a page that grew with no interaction has a component animating its own box. This instrument does not depend on the observer at all, so it survives the observer being wrong. **The failure message is the product.** Sort the shifts descending, take the worst three, and print for each: ``` FAIL layout-shift /pricing · CLS 0.184 the page moves while its content settles 0.1120 — div#root > main > section.pricing > div.card height 100 → 400, y 0 → 0 ``` A failure that prints only `CLS 0.184` has not done the job — send it back. **Do not** compute your own shift score from rect diffs. Use Chrome's `layout-shift` entries: they are the same numbers that decide the site's real CLS, so the gate and the field metric cannot disagree. **Also record, per route, into the JSON facts bag:** `cls`, `clsWhileScrolling`, and the document height delta. These are the numbers you will want to diff between two runs when someone asks whether last week's refactor helped. **Hand back:** the probe, the check, and a run against this project printing the per-route CLS facts. If every route reports 0, say explicitly that this number is only believable once the probe self-test exists — and point at that as the next task.
INSTALL_SHIFT_OBSERVER - after 02534 words
Overflow sweep
Add a horizontal-overflow sweep to the browser harness that runs at every breakpoint, sees through the global clip, and does not blame elements that are legitimately clipped. **The probe** — a single exported function serialized into the page: ```js export const OVERFLOW_CULPRITS = () => { const de = document.documentElement; const prev = document.body.style.overflowX; // save document.body.style.overflowX = "visible"; // lift the mask void de.scrollWidth; // force reflow before measuring const vw = de.clientWidth; const overflow = de.scrollWidth - vw; const culprits = []; if (overflow > 1) { for (const el of document.querySelectorAll("body *")) { const r = el.getBoundingClientRect(); if (!r.width || !r.height) continue; // invisible if (r.right <= vw + 1 && r.left >= -1) continue; // inside the viewport let clipped = false; for (let p = el.parentElement; p; p = p.parentElement) { if (/hidden|clip|auto|scroll/.test(getComputedStyle(p).overflowX)) { clipped = true; break; } } if (clipped) continue; // a carousel doing its job, not the page being too wide culprits.push({ tag: el.tagName.toLowerCase(), cls: typeof el.className === "string" ? el.className.slice(0, 70) : "", right: Math.round(r.right), width: Math.round(r.width), }); } } document.body.style.overflowX = prev; // restore — the page must be as you found it return { overflow, culprits: culprits.slice(0, 5) }; }; ``` Four things in there are not optional and each one is a bug you would otherwise ship: - **Lifting `overflow-x` on body**, or the global clip masks every offender and the audit is decorative. - **`void de.scrollWidth`** to force a reflow after the style change, or you measure the pre-clip layout. - **The clipped-ancestor walk**, or the check cries wolf on every marquee, carousel and ticker, which is how checks get deleted. - **The restore**, because later checks run on the same page and must see the app's real styles. Assert the restoration in the probe's self-test — a probe that mutates the page and forgets to undo it poisons every measurement after it. **Sweep the viewports.** Default `[1440×900, 768×1024, 390×844]`, and **read the project's own breakpoints** out of the Tailwind config or the stylesheet's media queries and add one viewport just inside each. After `setViewport`, wait ~600ms before measuring: media queries, ResizeObservers and any JS-driven layout need a frame or several. Run the sweep on every route, not just the homepage — the marketing hero is rarely the thing that overflows. **Fail per viewport**, naming the offenders: ``` FAIL overflow /features @ 390px document is 214px wider than the viewport div.grid.gap-8 — right edge 604px, width 604px img.hero — right edge 512px, width 512px ``` The overflow amount alone is not actionable at 390px, where a dozen things could be responsible. Sort culprits by `right` descending so the worst offender is the first line. **Do not** add the check by asserting `document.body.scrollWidth === window.innerWidth` and calling it done — that is the same number without the culprit list, and it will also read clean through the clip. **Do not** try to fix overflow by adding more `overflow-x: hidden`; if the check fires and someone's remedy is another clip, the check has made the codebase worse. Say this in the failure text. **Hand back:** the probe, the sweep, and a run against this project at all viewports with the real numbers.
OVERFLOW_CULPRITS - after 03 · 04792 words
Self-testing probes
You are going to make this project's audit prove, on every run, that its probes can still see. This is not a documentation task and it is not "add some tests" — it is a specific structure, and if you produce anything less than fixtures plus imports of the real probe functions, you have produced nothing. **Step 1 — extract the probes.** Every function that gets serialized into the browser (anything passed to `page.evaluate` or `page.evaluateOnNewDocument`) moves out of the audit script into `qa/probes.mjs` as a **named export**. They may use browser globals only: no imports, no closures over module scope, no TypeScript that needs compiling — they cross a process boundary as source text. Put a file-header comment saying exactly that, because the first person to add a helper import will otherwise get a baffling `ReferenceError` inside a headless Chrome. **Step 2 — write `qa/probes.test.mjs` with `node:test`, importing those exact exports.** Not copies. Not re-implementations. If the test file contains its own version of the probe logic, delete it and import — a copy proves the copy works. ```js import { INSTALL_SHIFT_OBSERVER, OVERFLOW_CULPRITS, MEASURE_TEXT_EDGES } from "./probes.mjs"; ``` **Step 3 — serve fixtures from an in-process HTTP server.** `node:http` `createServer`, `listen(0)` for a free port, a plain object mapping path → tiny HTML string. No dev server, no framework, no build. The whole suite must run in under fifteen seconds or it will be excluded from the fast gate, which defeats the point. **Fixtures must render under the same reset the app renders under.** If the app ships a CSS reset that sets `box-sizing: border-box` globally, put that in the fixture — otherwise padding lands outside the box you are asserting on and the fixture quietly stops describing the thing being tested. Write that as a comment in the fixture, not just in your head. **Step 4 — every probe gets three tests. All three, no exceptions.** - **True positive** — a page that breaks the rule. The probe must report it AND name the element. Assert both; a probe that finds a violation but names nobody produces a finding no one can act on. - **True negative** — a page that obeys the rule. The probe must report exactly zero. A noisy probe gets muted by the team, and a muted probe is a deleted probe. - **Hard negative** — a page that *looks* like a violation and is legitimate. A marquee wider than the viewport but clipped by its own parent. A grid cell that starts at a different x than the body copy. The probe must stay silent. This is the test that keeps the audit trusted, and it is the one people skip. **Plus, for any probe that mutates the page** (anything that lifts a clip, toggles a style, scrolls): a restoration test that reads the mutated property back afterwards and asserts the original value. **Assertion messages carry the consequence, not the expectation.** Not `assert.ok(cls > 0)`. Write: `"the observer reported no shift on a page that deliberately grows a box — every CLS 0 in the audit is meaningless until this passes"`. The person reading that message in six months is debugging at 11pm and needs to know what the failure means, not what the number was. **Step 5 — wire it into the fast gate**, alongside typecheck and lint, and **specifically not** behind the same flag as the slow browser audit. The self-test must run on every commit even when the full rendered pass is skipped, because a broken probe reports a clean page whether or not you ran the browser audit that day. **Step 6 — the rot drill. Do this, and paste the real output; it is the only evidence the suite is load-bearing.** 1. Copy `qa/` to a scratch directory outside the repo and symlink `node_modules` into it. **Never run this drill in the real tree.** 2. In the *copy*, rename one selector or attribute the probe depends on to something that matches nothing — exactly what a component rename does silently. 3. Run the test suite in the copy. 4. It must fail, with a message that names what was expected and what was measured (`expected 2 elements, measured 0`). 5. Delete the scratch directory. Paste the failing output into your hand-back. If the suite still passes after the drill, the tests are decorative: they are asserting on the probe's *shape* rather than its *findings*. Go back and add the assertion that would have caught it, then run the drill again. **Hand back:** `qa/probes.mjs`, `qa/probes.test.mjs`, the passing run (`node --test qa/probes.test.mjs`), the failing rot-drill output, and one sentence per probe stating what its hard negative is. A probe with no hard negative is a probe you have not thought about yet.
qa/probes.test.mjs - after 01 · 02666 words
Column & outline
Add two rendered checks to the browser harness. Both measure the page a reader actually gets, not the classes the source asked for. **Check 1 — one reading column.** The invariant: every block of running text that flows down the page starts at the same left edge. Media may be wider and overhang it — text may not. Do **not** check that the right container class was used; a container nested inside another container compounds both paddings and lands 16px off at tablet widths while reading as perfectly correct in source. Measure the edges. The probe collects, from inside the app's main content root, every `h1, h2, h3, p, li` and returns `Math.round(getBoundingClientRect().left)` with the tag and the first 48 characters of text. Skip: - anything inside a declared media/widget/device wrapper, a `figure`, or a `figcaption` — that is the widget's own composition, not the page's text column; - `width < 80` or `height < 8` (icons, empty nodes, screen-reader-only text); - `visibility: hidden` / `display: none`; - text shorter than 12 characters (a badge is not a paragraph). **The exemption that does the real work: text inside a composed layout.** Walk from the element up to `document.body` — **not** to the nearest section or article wrapper, because apps render list rows as `article` and stopping there makes every grid row look like a stray paragraph. If any ancestor is a grid with more than one column track, or a row-direction flex with more than one visible child, the element is exempt: its cell starts where the grid puts it, and holding a table cell to the page's reading column is asking a table to be a paragraph. Cluster the remaining lefts with a 1px tolerance (sub-pixel rounding is not a misalignment). The largest cluster is the column. Fail when the number of clusters exceeds `CONTRACT.readingColumns`, printing each stray as `x=316 (+24) h2 "How it works"` — the signed delta from the dominant edge is what makes it a five-minute fix instead of an afternoon. **Portability, stated honestly:** "exactly one left edge" is true for a single-column editorial or marketing page and false for a dashboard, a docs site with a sidebar, or alternating full-bleed sections. So the count comes from the contract, per route if necessary — `readingColumns: 1` for content routes, `2` for the docs shell. **Declare the columns, then measure; never assert one column globally.** Record the clusters in the facts bag as `[{left, count}]` so the shape of the page is visible in a diff even when the check passes. **Check 2 — the document outline.** From the same content root, read every `h1, h2, h3, h4` in document order with its level and its first 60 characters. Then: - **Warn** on any skipped level (`h2 → h4`), naming the heading text. Say in the message that heading level is document structure, not type scale — if it needs to be smaller, style it. - **Fail** on more than one `h1`, listing all of them. Explain the consequence in the finding: a second top-level heading is usually a mounted component contributing its own outline, and both a screen reader and a crawler read it as a new document starting mid-page. - Record the whole outline as a fact — `"h1 h2 h2 h3 h3 h2"` — so restructuring a page shows up as a one-line diff between runs. Use `aria-label` in preference to `textContent` when present, and trim/collapse whitespace, so a heading wrapped in per-letter animation spans still reports readable text rather than `H\nO\nW`. **Do not** implement either check by reading JSX or class names. Both of these are facts about rendered geometry and rendered DOM order, and the whole reason they are in the browser pass is that source cannot answer them. **Hand back:** the two probes, the two checks, the facts they record, and a run against every route in this project — including the routes that pass, with their measured edge clusters and heading strings printed as NOTE inventory.
MEASURE_TEXT_EDGES - after 01754 words
Source scanner
Build `qa/source-audit.mjs`: the fast, deterministic half of the design gate. It reads files, never a browser, runs in seconds, and imports `design/contract.mjs` for every number it checks. **Bug one, fixed before anything else: strip comments first.** ```js function stripComments(src, path) { // preserve newlines so line numbers still point at the right line const noBlock = src.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")); return path.endsWith(".css") ? noBlock // `//` is valid inside a CSS url() : noBlock.replace(/(^|[^:])\/\/[^\n]*/g, (m, p) => p + " ".repeat(m.length - p.length)); } ``` A prose note *about* a rule is not a violation of it. Replacing comment characters with spaces rather than deleting them keeps every `file.tsx:42` location honest. **Bug two, fixed second: follow one level of `var()`.** To read a declaration out of a stylesheet and compare it to the contract: match the rule block by selector, match the property with a `(?<![-\w])` guard so `padding:` does not also match inside `--card-padding:`, then substitute any `var(--name)` from the custom properties declared in that same block. Without this, a design system that correctly tokenized its own geometry produces NaN, and **a NaN failure is worse than a miss** — a miss is silent, a NaN teaches the team the check is broken and they stop reading its output entirely. **Then the checks. Each one imports its numbers from the contract; no literal appears twice in this repo.** 1. **Contract binding.** For every contract entry with a `binding`, read the declaration out of the app and fail when they disagree, printing both values and both file:line locations. This is the check that makes the contract real rather than aspirational. 2. **Motion contract.** Match real declarations only — `/\btransition(?:-property)?\s*:\s*([^;{}]+);/g` — then: fail on `all` ("it animates every property, including ones added later"); split on commas and fail when a part's first token is in a BOX_METRICS set (`height width top left right bottom margin* padding inset block-size inline-size min/max-width/height`), because animating a layout property moves the page under the reader. In component files, flag the framework's catch-all utility class (`transition-all` and its responsive prefixes) from `className` string literals, and flag animation-library props — `animate={{…}}`, `initial`, `exit`, `whileHover`, `whileInView` — whose keys are box metrics. Print the offending value truncated to 80 chars on the following line. 3. **Token re-declaration.** For each contract token that has a canonical owner module (an easing tuple, a shadow, a duration), grep the exact literal across the tree and report every occurrence outside the owner with file:line. A re-typed tuple drifts the moment one copy is tuned, and nothing will tell you. 4. **Stale-number drift.** Read `CONTRACT.retired` — the values that used to be right — and grep prose, comments and markdown for them. A comment still teaching last year's column width will be read by the next person, and by the next agent, as the spec. **Be honest in the output that this check has a shelf life**: it encodes a migration, and entries should be deleted from `retired` once the tree is clean, not left to accumulate. 5. **Placeholder copy.** After comment-stripping, fail on `Lorem ipsum` / `LOREM` in anything that renders. Comment-stripping is what makes this safe: a parked section kept in a comment block is a decision with a note attached, not shipped placeholder text. **The generation step you must not skip.** Checks 2–4 need regexes that describe *this* codebase's dialects, and those cannot be authored in advance. Before writing them: scan the tree for the N competing ways this project does one thing — how a card/panel wrapper is expressed, how a section header is expressed, how vertical spacing is applied — and print a frequency-ranked inventory (`"rounded-2xl border" ×31, <Card> ×12, function Panel() ×4`). Show me that inventory, name which one is canonical, and only then write the patterns into the audit with the canonical one marked as allowed. **A dialect list invented without reading the codebase produces an audit that fails on correct code**, and that audit will be muted before it catches anything. **Thresholds you invent ("more than 8 inline headers", "more than 400 words of copy in a component") must be printed with the measured value next to them and marked as tuned-by-feel in a comment.** They are calibrated against this codebase and nowhere else. **Hand back:** the dialect inventory, the audit file, and a full run. State which findings you believe are true positives and which are the audit meeting an intentional exception you did not know about — then ask me before suppressing any of them.
qa/source-audit.mjs - after 01 · 04619 words
Declared exemptions
Make every suppression in this project's design gate an explicit declaration in the artifact, and then add the inversion almost nobody builds: report the suppressions that have stopped suppressing anything. **Three channels, all declarations, no heuristics.** A check must never guess that something is intentional by inspecting a border radius or a class name — guessing produces both false negatives and unexplained silence. 1. **In-markup, on the element.** `data-qa-allow-overflow="the mock bleeds off the bottom edge by design"` — the *value is the reason and it is required*. An attribute with an empty value is itself a FAIL ("an exemption with no reason is an exemption nobody can review"). 2. **Component-level role marker.** Some components *are* the thing a check is looking for — a device-chrome mock is already a frame, so a "is this wrapped in the standard frame" check must leave it alone. Mark it once on the component (`data-qa-is-frame`) and have **both the source audit and the rendered audit read the same marker**, so the two halves of the gate cannot drift into disagreeing about what is exempt. 3. **Path allowlist for code you do not own.** Vendored, generated, or verbatim-ported directories: list the path prefixes in the contract with a reason per prefix. Findings inside them are **downgraded from FAIL to WARN, never dropped**, and the reason is baked into the finding text: `"vendored — fix upstream, do not patch here."` A rule broken inside vendored code is still on the live page; it just is not this repo's bug to fix, and hiding it entirely means nobody ever takes it back upstream. **Every exemption is printed.** Emit each active one as a NOTE with its reason, so a reviewer reading the audit output sees the full list of things not being checked without opening a single file. Exemptions that are invisible in the report accumulate. **Now the inversion — the part that makes this rare.** After each check runs, assert that each exemption is still doing work: - An element declares `data-qa-allow-overflow` but nothing overflows → **report it**: "declares an overflow exemption but nothing runs past the box — remove the attribute so the check is live again." - An allowlisted path produced zero findings this run → **report it**: the prefix may point at a directory that was deleted or renamed, in which case it is silently allowlisting nothing while looking like coverage. - Any lint-disable comment your gate owns that suppresses a rule with no violations at that location → **report it** (most linters can be asked this directly; if yours cannot, run the rule twice, once with suppressions honoured and once without, and diff). Severity for all three: WARN, not FAIL — a stale exemption is not broken output, it is a disarmed check. But it must be visible, because the cost is paid later and by someone else. **Two rules for how exemptions get added.** Write both into the audit's own README. (a) An exemption is added in the artifact, by the person who knows why, in the same commit as the thing being exempted — never bolted onto the audit as a special case to turn a red build green. (b) A check that has grown more than a handful of exemptions is telling you the rule is wrong, not that the code is. Print the exemption count per check in the summary so that signal is visible. **Hand back:** the three channels wired into the existing checks, the staleness pass, the NOTE inventory of every active exemption with its reason, and the current count per check. If any existing suppression in this repo has no reason attached, list them and ask me for the reasons rather than inventing them.
data-qa-allow-overflow - after 05 · 07 · 08640 words
One gate, one verdict
Unify this project's design checks behind one severity model, one facts bag and one runner. Assume the individual checks already exist. **Part 1 — three severities, written down where the checks live.** - **FAIL** — a contract rule is broken, or the codebase has diverged in a way that costs real work to unwind. Breaks the run. - **WARN** — a real divergence that might be a deliberate call. Printed; breaks the run only under `--strict`. - **NOTE** — inventory. What the code does today, so the *next* run's diff shows what moved. Never breaks anything. Build a small `Report` class with `fail/warn/note(check, where, note, detail?)` and `fact(key, value)`. Findings group by check id when printed. `where` is a location a person can open — `file.tsx:42`, or `route @ 390px`. `detail` is an array of lines printed indented under the finding: the culprit list, the three worst shifts, the stray text blocks. The constructor takes the audit's **intent** in one sentence, printed under the title, so somebody staring at a red run knows what the audit is defending before they start arguing with it. **Part 2 — the facts bag turns the audit into a time series.** Alongside findings, every check records its measurements: `route.cls`, `route.headings` as a level string, `route.textEdges` as `[{left, count}]`, viewport widths, element counts. Serialize them under `--json`. This is the difference between a red light and an instrument. Commit `qa/baseline.json` and diff it between runs: "CLS went 0 → 0.04 on three routes" is a conversation; "the audit is red" is not. Add a `--json` diff note in the README showing the two-command version (`node qa/gate.mjs --json > new.json && diff baseline.json new.json`). Exit code from `print()`: 1 if any FAIL, or if `--strict` and any WARN. Every check script takes the same four flags — `--json`, `--strict`, `--quiet` (hide the NOTE inventory), and the audit's own scope flag. **Part 3 — one runner, `qa/gate.mjs`.** Declare the checks as data: `{ name, cmd: [bin, args], blocking }`. Spawn each as a child process, capture stdout+stderr and elapsed ms, and print one table: ``` pass typecheck 4.1s warn lint 6.8s non-blocking — waiting for the pre-existing no-unused-vars backlog pass source audit 0.9s pass probe self-test 9.8s FAIL rendered audit 214.3s ``` Then print the **full output of blocking failures only** — the summary tells you which check, the detail tells you what actually broke, and nobody has to re-run anything to find out. **The rule that keeps the backlog honest: every non-blocking check must carry a `waitingFor` string, printed under it on every run.** A check that is `blocking: false` with no `waitingFor` is a config error and the gate must refuse to run — otherwise "temporarily non-blocking" becomes permanent and invisible. `waitingFor` turns a disabled rule into a visible IOU, and the list should only ever get shorter. **Two entry points, and one specific thing that must appear in both.** A fast gate (source audit + typecheck + lint + the probe self-test — seconds) and a rendered gate (fast, plus the browser pass — minutes). **The probe self-test belongs in the fast set**, always, even though it launches a browser: a probe that has stopped matching reports a clean page, so skipping it is skipping the reason to believe every other green result. If it makes the fast gate too slow, cut the fixture count, never the suite. Wire both into `package.json` using whichever package manager the lockfile indicates, and into CI: the fast one on every push, the rendered one on pull requests and on the default branch. **Hand back:** the report module, the runner, both entry points, a full run of each with real timings, and the first `baseline.json`. If any check ships non-blocking, list it with its `waitingFor` and tell me what would have to be true to make it blocking.
qa/gate.mjs