DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
AI agentsContributing

Responsive QA checklist

What to measure, at which widths, what counts as passing — and the six failures a green gate run still cannot see.

What to measure, where, and what counts as passing. Distilled from TESSINA_UI_QA_FLOW.md §2 with the traps that make a green run meaningless.


The widths

Viewport — all seven must pass:

xsmobilesmmdlgxl2xl
320375640768102412801536

Container — because the system is consumed in layouts we do not control: 240 (sidebar/card) · 320 (half-column) · 480 (modal/drawer) · fluid.

Measure at 320 and 375 first, every example, every skeleton, and the component inside a narrow container — not only a narrow viewport.


Rule zero: measure, never infer

The house rule (2026-09-04, TES-42). Assert numbers; do not eyeball, and do not reason from the class string.

A class in the DOM proves nothing. Three separate ways it lies:

  1. Emission order — both classes are present, the wrong one renders. Verify by computed value, per theme.
  2. An interpolated variant prefix generates no CSS at all. `${STACK}flex-col` with const STACK = "max-[22.5rem]:" reached the DOM with no rule ever created. Tailwind's scanner never saw it.
  3. A custom variant may not have parsed. The shorthand @custom-variant form compiles to invalid CSS that silently does nothing. Read the compiled stylesheet.

scrollWidth is not proof

The single most important trap on this page.

A clipping ancestor holds scrollWidth exactly at clientWidth. Every docs page was cut off mid-word from ~390px up while its own audit reported "no horizontal overflow anywhere" — scrollWidth === clientWidth throughout. scrollWidth is blind to overflow-x: clip.

The correct procedure: measure whether an element's box escapes the viewport, then ask what cuts it.

What cuts itVerdict
A scroller with real scroll extentfine
A clipper narrower than the viewportan intended crop
A clipper that spans the viewportthe layout failing

Gated by pnpm qa:reflow, which walks out past a spanning clipper.

Related: overflow: hidden clips visually but leaves the box programmatically scrollable. Assert a box cannot be scrolled; do not trust overflowX.

A worked example, measured on github.com

At a 768px viewport, scrollWidth - clientWidth returned 0 while six elements escaped the viewport by up to 75px. Walking up from each one found the clipper: nav.js-repo-nav, overflow-x: hidden, with real scroll extent — an intentionally scrollable nav strip. Per the table above that is fine, not a failure.

Both halves of the lesson matter: scrollWidth read zero while content genuinely escaped, and an escaping box was not a defect. You have to ask what cuts it.

One more trap, from getting this wrong first. An earlier pass recorded "23px of overflow at 768" — an artifact. The browser pane scales, so the viewport was actually 753px and the figure compared two different widths. Assert that the viewport you measured is the viewport you asked for (clientWidth against your intended width) before quoting any number from it.


Tap targets are verified by HIT-TEST, not getComputedStyle

A named 44px pad is only as large as what survives its ancestors and later siblings. Three invisible shrinkers, all measured:

  1. An ancestor's overflow clip, drawn at its padding box — combobox 44 → 42. Fixed with overflow-x-clip min-w-0.
  2. A later positioned sibling wins the overlap.
  3. Side-by-side pads must be spaced on the pointer. Keyed to width, an iPad Mini got 44px pads with the desktop gap; adjacent pads overlapped and the later sibling took the strip — 36, 38, 30, 36, 32 and 40px of 44.

Also: overflow-x: auto makes overflow-y clip too, at the padding box — every 44px ::before was cut to the control's own height (33px on iPad Mini).

And: disabled buttons hit-test as 0. A rect is not a target.


The floor checks

  • 44 × 44 at touch — and remember it is keyed to the pointer, so the check must run on a tablet profile, not just a phone. Every phone preset passed the run that found 8-of-8 targets at 36×36 on an iPad Mini.
  • 24 × 24 unconditionally — the AA floor (2.5.8) is not touch-conditional. A no-touch: branch below it fails for mouse users.
  • 16px type on any control a user types into (iOS zoom-on-focus).
  • 12px type floor, with recorded exceptions only.
  • Modals and drawers usable at 320 × 568.
  • Reflow at 320px with 400% zoom, no 2D scrolling (WCAG 1.4.10). Test at 320 × 256 directly — WCAG's own worked example measures a 1280×1024 laptop at 400% as 318 × 236 once chrome and scrollbars are subtracted, so the arithmetic does not reproduce cleanly.
  • Menus reposition near viewport edges (flip/shift), clearing the safe area.
  • Charts resize without axis-label collisions.

Rotation, not just resize

Mount at landscape 667×375, rotate to portrait 375×667 without reloading, rotate back. Overflow-free at every step, ≤1px tolerance.

This catches measure-once staleness that a fixed-viewport sweep structurally cannot: Textarea computed a min-width floor at 667 and left the page 27px over at 375, while every fresh load measured 0. Poll after rotation — re-measured floors arrive via ResizeObserver.


Skeletons

Placeholders shrink, never spill. Cap every bar at max-w-full and assert root.scrollWidth − root.clientWidth. FileUpload shipped 75px over at 375px behind a parity skip nobody re-read.


A skip is not a pass

registry/parity-cases.ts and the viewport/a11y baselines are ratchets. Before signing off, grep every baseline for your component.

2 skipped is not a pass. A skip entry means the gate never ran, and the component's own hardening ticket is what burns that entry down.


The gates

GateAssertsBaseline
qa:viewportsno page-level overflow at the 7 breakpointsviewport-baseline.ts — a ratchet
qa:reflow99 shipped routes at 320/375/414/430/768/1024none — hard gate
qa:rotationno overflow across a live rotationshares the viewport baseline
qa:browsers44px floor on tablets; menus by long-press—
emission:baseline (ui)no bare utility beside its own variant, in ui/srcbaseline.json — currently zero
emission:check (docs)the same rule over the docs tree, named containers includedemission-order-baseline.json — 41
ladder:baselineone type ladder per familybaseline.json
typefloor:baselineno new sub-12px sizesbaseline.json
knowledge-lintevery §n and snippet cite resolvesnone — hard zero

Harness note: qa:viewports pads #qa-root by 24px, so overflowBy reads ~24px large — subtract before quoting. The pad is dropped at 320.


What the gates cannot see

Run a green build and these still ship. Check them by hand.

  1. Shorthand vs longhand — flex-1 basis-80 races, because flex-1 writes flex-basis. Write grow shrink basis-*. Ungated.

  2. Mixed-axis pairs across two maps — touch:h-11 in one map and md:h-* in another. A coarse 768px tablet matches both, and the scan cannot see it because the halves never share a string literal. Ungated.

  3. Interpolated variant prefixes — no CSS is generated, and qa:viewports cannot see it (a row that fits its container overflows nothing). Only getComputedStyle or a CSSOM search finds it.

  4. A widthless docs example root — the harness renders at /qa-harness/[id] where <main> is a plain block and every example has a definite width, so the defect is structurally invisible there. Examples whose root is a layout wrapper carry w-full.

  5. A named container is invisible to a breakpoint regex. ui's scanner matches @?(?:sm|md|lg|xl|2xl) — which catches @2xl but not @2xl/screen. Every theme-creator screen measures against @container/screen, so that whole surface was unguarded twice over: outside the scanned tree and below the regex. Closed 2026-09-23 by docs/scripts/check-emission-order.ts (pnpm emission:check, wired into verify:push so CI runs it; baseline 41).

  6. A variant missing from the scanner's ZERO_SPECIFICITY_VARIANT set makes the gate report a clean zero by being blind to it. Adding a custom variant means adding it to that set in the same change.

  7. Never regex class strings out of a JSX file — parse it. A comment-blank + string-literal regex reads the apostrophe in prose like You're all set as opening a quoted literal that runs to the next apostrophe. On the docs tree that lost a real violation and invented ~120: 145 reported against 46 real. ts.createSourceFile and walk the literal nodes.

  8. Dev is not an oracle for emission order. fumadocs ships prebuilt CSS that duplicates utilities, so the dev cascade differs from the export's. Measure the production build.

  9. Nothing checks ARRANGEMENT. This is the largest hole on the page, and it is worth stating bluntly: a control that is merely cramped, mis-proportioned or stranded from its label passes every gate we own.

    • qa:viewports and qa:reflow assert the page does not scroll sideways. A squeezed control scrolls nothing, so it passes.
    • qa:styles is the closest thing we have and it does record geometry — entries look like div>div>div:2>input … w=330 h=20 fs=14 — so a size regression on a recorded element is caught. But it records no x/y (reference_style_baseline_traps), so a label sitting 200px from its own control, with both boxes the right size, is structurally invisible.
    • Coverage is per-example and partial: combobox, for instance, appears under three keys (combobox-demo, combobox-multiple-demo, combobox-on-color) — the playground is not among them, and the playground is what a reader sees first on the page.
    • It captures at one viewport, so a break that only appears at another width is out of scope by construction.

    What this means in practice: a green board is not a claim that the component looks right at any width. The first positional assertions now exist — the docs repo's qa:mounted suite carries anatomy seeds (left-edge alignment, vertical gap range, width floor for named slot pairs), plus the geometric mount gate the site never had. But seeds cover only the routes someone has measured and enrolled; on every unseeded route the only detector for this class is still a person opening the page. Put that on the sign-off explicitly rather than implying the gates covered it. The incident procedure, and how to enroll a seed, is 09-rendered-reality.md.


Honest reporting

  • AA is the shipping bar. Passing it in all states and both themes means the contrast gate is passed, full stop. AAA is a bonus — never block delivery or retune tokens chasing it.
  • NVDA and JAWS cannot run on this machine. List them UNTESTED; never claim them. VoiceOver is verifiable locally.
  • Name what you did not check. A sign-off that omits a skipped gate is worse than one that admits it — the §5b failure happened because a disclosure sat in a ledger where nothing read it.

Borrow the third verdict: "conditional"

Pass / fail is the wrong vocabulary for a component library, because a large class of criteria cannot be settled at component level at all — they depend on the page the component lands in.

The most rigorous public example publishes per-component results in four tiers, and 110 of its 503 items are "Conditional", glossed as: "This can only be tested in your implementation of the component. Be sure to test this in your project." (The rest: 379 pass, 13 passed-with-exceptions, 1 failed — and they name the failure.)

We have no equivalent, and we should. A component that reports "pass" for something only the consuming page can determine is making a promise it cannot keep. Reflow, target spacing against neighbours, focus order across a page, and heading hierarchy are all conditional in exactly this sense.

Until we have the tier, say it in prose: state which results are component-scoped and which the consumer must re-test. That is the same honesty the AT matrix already demands about NVDA and JAWS.

Choosing the adaptation axisRendered reality

On this page

The widthsRule zero: measure, never inferscrollWidth is not proofA worked example, measured on github.comTap targets are verified by HIT-TEST, not getComputedStyleThe floor checksRotation, not just resizeSkeletonsA skip is not a passThe gatesWhat the gates cannot seeHonest reportingBorrow the third verdict: "conditional"