Skip to content

Validating with r$ — the design guide

This is the guide to @responsivejs/design: what the validation side of r$ is for, how measuring works, and worked examples that grow from five lines to contracts in CI. The API reference has every exact signature. Authoring is the other half: the runtime guide.

bash
npm i -D @responsivejs/design @playwright/test   # Playwright is the default driver

The mental model

The runtime writes value = f(width); the design package reads it back and judges it. Three stages, always the same:

  1. Measure — open the page at several widths and snapshot every element you care about: rectangles, computed styles, parent/child relations. This produces a SnapshotStore — the sampled version of the parametric plane.
  2. Model — the store makes f(width) tangible: for any element and property you can ask for its curve, the measured function across widths.
  3. Judge — constraints are equations that must hold at every width (child.right ≤ parent.right, dFontSize/dWidth ≥ 0); the score grades composition; a11y checks run on the same measurements. The verdict is machine-readable — humans read the console form, CI reads the exit code, agents read the JSON.

Nothing downstream of measurement touches the browser again: judging a cached store is pure math, deterministic, and free to re-run.

Your first validation

typescript
import { test, expect } from '@playwright/test';
import { r$ } from '@responsivejs/design';

test('the layout holds', async ({ page }) => {
    const r = r$(page);

    await r.sweep({
        url: 'http://localhost:3000',
        widths: [320, 768, 1280],
        selectors: ['main', 'h1', '.card', '.btn'],
    });

    r.assert.noOverflow().minSize('.btn', { height: 44 });

    expect(r.report().pass).toBe(true);
});

What each line really does:

  • r$(page) wraps a Playwright page in a validator. Nothing happens yet.
  • sweep() is the measurement pass: for each width it resizes the viewport, waits for layout to settle, and runs r$'s in-page collector over your selectors. Choose widths like test cases — the extremes plus wherever your layout changes regime. Choose selectors for the elements with responsibilities (the nav that must not overflow, the CTA that must be tappable) rather than sweeping *.
  • r.assert is a chainable Asserter. Each call checks one equation at every measured width and records violations instead of throwing — you always get the full picture, not the first failure.
  • r.report() returns { pass, total, passed, failed, violations }. failed counts failed checks — one check can carry several violations (minSize failing width AND height), so failed ≤ violations.length and passed is never negative. Each violation says what broke, where (.btn[0]), at which width, and by how much (expected/actual).

Picking constraints

There are 27 (all in the agents reference, each a method on r.assert). They fall into families — pick by what the element is responsible for:

typescript
r.assert
    .noOverflow()                                  // geometry: nothing bleeds out
    .contains('.card', '.card img')                // geometry: children stay inside
    .monotonic('h1', 'fontSize', 'up')             // continuity: text never shrinks as width grows
    .continuous('.sidebar', 'width', 200)          // continuity: no sudden jumps between widths
    .touchTarget('.toolbar button')                // a11y: tappable (24px WCAG floor; pass 44 for platform)
    .contrastRatio('p', 'AA')                      // a11y: measured effective backgrounds
    .proportion('.sidebar', '.main', { min: 0.2, max: 0.33 }); // relations: the ratio holds

The continuity family is the distinctive one: it checks the function, not a screenshot. A font that drops from 32px to 18px between 767 and 768px passes every per-width check and fails monotonic — which is exactly the class of bug that slips through visual review.

The full oracle: analyze()

Assertions check what you thought of. analyze() runs everything at once — the default constraint set, the aesthetic score, and axe (when installed):

typescript
const report = await r.analyze();   // reuses the sweep you already did

The UnifiedReport adds structure worth knowing:

jsonc
{
    "pass": false,       // no ERROR-level violations — your CI gate
    "clean": false,      // no violations at all — your polish gate
    "summary": { "errors": 2, "warnings": 3, "info": 0, "byRule": {}, "byWidth": {} },
    "violations": [ /* every one, with severity */ ],
    "fixes": [ { "selector": ".cta", "property": "min-height", "value": "44px", "reason": "…", "kind": "exact" } ],
    "scores": { "average": { "overall": 0.62, /* +17 metrics */ } },
    "sources": { "measurement": "playwright", "a11y": "axe" }
}

Two gates on purpose: pass ignores warnings (loop on it — fix errors first), clean is zero-anything (chase it when polishing). fixes is the apply-verbatim list: only kind: "exact" fixes (concrete CSS that mechanically resolves the measured violation), deduped across widths. Heuristic fixes — a direction, like increase contrast — stay on their violations, alongside detail + expected/actual to reason from.

sources.a11y tells you whether axe actually ran ('axe'), was skipped, or was unavailable — the report never silently pretends.

The aesthetic score (experimental)

Seventeen measured metrics with academic lineage (Ngo/Birkhoff — balance, equilibrium, symmetry, proportion, density, rhythm…), each 0..1, averaged into overall. It is deterministic but experimental: elements matched by several selectors are currently counted once per selector, nested areas sum into density/weight, and the weights are not yet calibrated against human judgment — treat it as a relative signal subordinate to the verifiable constraints, never as a gate on its own. How to use it without over-trusting it:

  • Read metrics, not the total. Each low metric maps to a concrete adjustment: low balance → visual weight is lopsided; low proportion → sizes don't relate harmonically; low density → cramped or sparse.
  • Compare, don't idolize. The score shines as a relative signal: this variant vs that one, this week vs last week (score: [{ min: 0.6 }] in a contract pins the floor).
  • Scope it. On long document pages visual mass concentrates at the top and equilibrium tanks — score a section (r.score('.hero')), not the page.

Curves: the model made queryable

typescript
r.at(768).rect('.sidebar');            // one width, one element
r.curve('h1', 'fontSize');             // Map<width, px> — f(width), measured
r.rectCurve('.sidebar', 'width');

Curves are what continuity constraints and baselines are built on — and your debugging X-ray: print one and you see the function your CSS actually produced.

Contracts: the spec that travels

Assertions live in test code. A contract is the same expectations as reviewable JSON — versionable, diffable, executable by anyone (CI, the CLI, an agent):

typescript
import { contract } from '@responsivejs/contract';
import { verifyContract, recordBaseline } from '@responsivejs/design';

const home = contract('home')
    .viewport({ widths: [320, 768, 1280] })
    .select('sidebar', '.app-sidebar')
    .assert('noOverflow', undefined, { id: 'no-bleed', description: 'nothing bleeds out' })
    .below(768).assert('hidden', { selector: '$sidebar' })
    .from(768).assert('visible', { selector: '$sidebar' })
    .baseline('h1', 'fontSize')
    .build();                            // → plain JSON (schema-validated round-trip)

const report = await verifyContract(home, page);   // sweep derived FROM the contract

Three things contracts add over inline asserts:

  • Intent. Every rule carries a description — when it fails, the report tells you why the rule exists, not just that it broke. (This is also what makes contracts executable instructions for agents.)
  • Baselines. recordBaseline measures today's curves and pins them into the contract; later runs fail if the geometry drifts beyond tolerance. Record → commit → verify: visual regression without screenshots.
  • Design-system presets. "designSystem": { "profile": "material-design-3" } expands into that system's measurable rules — touch minimums, contrast, spacing tokens, radius sanity, control heights (also apple-hig, fluent-ui-2, or your own config). These are validation presets, not full conformance: they don't yet check type scales, color palettes, elevation or per-component specs.

When you outgrow the builder, write the JSON directly — the published schema validates it, and unknown names fail at load with did-you-mean suggestions.

And if the page runs the r$ runtime, you don't have to start from a blank file: rjs init <url> -o app.contract.json generates the contract from your constructs — each numeric fluid becomes monotonic + continuous + a baseline, each ratio a proportion, your r$.breakpoints the sweep widths. What isn't expressible yet is listed on stderr. rjs record then pins today's curves: the page is held to what it declares.

Drivers: the seam under everything

sweep needs three capabilities: set a viewport, measure, (optionally) evaluate JS — plus an optional screenshot seam that powers rjs audit's visual reports. That's the whole MeasurementSource interface — and everything above it is driver-neutral:

  • PlaywrightSource — the default, what r$(page) uses. For CI.
  • CdpSource — any structural { send(method, params) } client (CDPSession, chrome-remote-interface, agent-browser's CDP bridge). No dependency.
  • EvalSource — the lowest bar: if your environment can eval a JS string in the page, it can drive r$. Without a viewport-setter it verifies the live width rather than lying (measurements are never fiction); wrap chunkedEval when the transport caps argument length.

You care about this when validating where Playwright can't go: a logged-in session in a real browser, an embedded webview, an agent's browser tool. Same collector, same math — identical measurements everywhere.

agent-browser: audits with nothing installed in the project

Vercel's agent-browser deserves its own note, because it changes when you can reach for r$. It's a standalone browser CLI (npm i -g agent-browser) — so the audit needs no Playwright, no npm install, no config in the target project. That unlocks workflows Playwright-in-the-repo can't do:

  • Audit a page you don't have the repo for — production, a legacy property, a CMS page marketing just shipped: rjs analyze https://example.com -d agent-browser.
  • Check the competition — measure any live site's responsive behavior the same way you measure yours (mind their terms of service).
  • Try r$ before adopting it — one global install, one command, a full report; nothing to undo if you walk away.
  • A shared browser session — agent-browser keeps a persistent session (--session), so repeated audits reuse the same browser instead of cold-starting one per run.

Playwright stays the CI driver (deterministic, versioned with the repo); agent-browser is the zero-footprint field tool. -d auto picks whichever is available.

For no driver at all, the /browser subpath runs inside any live DOM: analyzeDOM() is the one-call oracle at the current viewport, scoreDOM(['main', '.card']) scores it — usable from a devtools console or injected by tooling. Two packaged ways in: mountOverlay() drops an <rjs-overlay> badge on the page (live counts, grouped findings, hover-to-highlight, re-measures on resize), and rjs snippet --bookmarklet turns the whole bundle into a bookmark you can click on any site — the overlay appears, zero install.

CI and the CLI

The rjs CLI is this same engine with exit codes: rjs analyze <url> (0 pass / 1 violations), rjs verify <contract> <url>, rjs record. Use the library in Playwright tests where you already have fixtures; use the CLI where you want zero project setup or a quick audit of any URL. -f sarif feeds code-scanning UIs; -f json feeds anything else.

And when the audience is a person, not a pipeline: rjs audit <url> produces one self-contained HTML file — screenshots per width with the violation rectangles drawn on them (from the measured rects), grouped findings, fixes, scores. --crawl covers the same-origin pages, --vs competitor.com opens with a side-by-side table. It's the "here's what's wrong with our site" artifact, generated in one command.

The flags that matter day to day: -s "main,.card" (scope selectors — see below), -w 320,768,1280, --strict (fail on warnings too), --touch-min 44 (platform touch rule instead of the WCAG 24px floor), --scroll (below-the-fold content), -d agent-browser (audit any live URL with nothing installed in the project).

Cutting false positives

A report that is mostly noise teaches people to ignore it. Four levers, in order of impact:

  • Scope your selectors. Audit main, nav, .content, .cta — the elements with responsibilities — not the whole DOM. This is the single biggest signal/noise lever on real-world pages.
  • Contrast is computed from measured effective backgrounds (transparent ancestors resolved to the color actually painted), so what it flags is what users see.
  • noOverflow distinguishes naked overflow (would bleed the page — error) from overflow inside a scroll region (an overflow-x: auto carousel or table wrapper — warning): a data table that scrolls by design stops failing your build.
  • Touch targets default to WCAG 2.5.8's 24px floor and exempt inline prose links; raise to platform guidance (44/48) per rule, per run, or via a design-system profile.
  • Gate on pass (errors only) in loops and CI; chase clean (zero anything) when polishing.

Determinism, cost, trust

Same store in → same report out: scores and contrast are computed from measured values (axe's color sampler is deliberately disabled in favor of the deterministic constraint; contrast uses effective backgrounds — transparent ancestors resolved to what users actually see). Sweeping costs one page-load plus one relayout per width; everything after is pure math, so re-judging a cached store — or a store shipped as JSON via storeToJSON — is free.

Where next