Skip to main content

The SDK

Recipes

Copyable answers to the questions people ask first.

1. Sign in once and scan behind the login

Nothing Setsquare-specific: this is Playwright's storageState, and Setsquare reads it. The recipe is here because "how do I scan the logged-in application" is the most common first question, and the answer is that you already know how.

ts
// auth.setup.ts
import { test as setup } from '@playwright/test';

setup('sign in as a recruiter', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.RECRUITER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.RECRUITER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('/dashboard');
  await page.context().storageState({ path: '.auth/recruiter.json' });
});
ts
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import { withSetsquare } from '@setsquare/review-sdk';

// Wrap `defineConfig(...)`, not a bare object: `withSetsquare` needs a value typed
// as a Playwright config, and a plain literal does not typecheck. Setsquare's own
// options are a SECOND argument — see recipe 2. None are needed for auth.
export default withSetsquare(
  defineConfig({
    projects: [
      { name: 'setup', testMatch: /auth\.setup\.ts/ },
      {
        name: 'recruiter',
        use: { storageState: '.auth/recruiter.json' },
        dependencies: ['setup'],
      },
    ],
  }),
);

Two things follow from this that are easy to miss.

setsquare doctor reads those storageState paths to work out how many roles your suite has. It deliberately does not count project names — a project called chromium or apply-discovery is a browser or a zone, not a person, and counting those would manufacture a denominator out of a naming convention. If your config names no storageState anywhere, role coverage prints nothing rather than "0 of 0".

Put .auth/ in .gitignore. A committed storageState is a committed session.

2. Themes: dark, high contrast, whatever you call yours

Setsquare has no built-in list of theme names. Yours are yours.

If a Playwright project is a theme, map project name to theme name:

ts
// withSetsquare(yourConfig, setsquareOptions) — two arguments, config first.
// Setsquare's options never go inside your config object.
export default withSetsquare(
  defineConfig({
    projects: [
      { name: 'dark-desktop', use: { colorScheme: 'dark' } },
      // `forcedColors` and `reducedMotion` are NOT accepted directly under `use:`
      // — Playwright does not declare them there, and `use: { forcedColors: … }`
      // does not compile. They go through `contextOptions`.
      { name: 'hc-desktop', use: { contextOptions: { forcedColors: 'active' } } },
    ],
  }),
  { themes: { 'dark-desktop': 'dark', 'hc-desktop': 'forced-colors' } },
);

If your themes are toggled inside tests — a data-theme attribute, a class on <html> — you do not have to configure anything. Setsquare reads the theme off the page, which is the only source that works for that pattern.

And if neither fits, the callback is the general escape hatch:

ts
export default withSetsquare(defineConfig(yourConfig), {
  theme: ({ project, urlPattern, observedTheme }) =>
    project.endsWith('-hc') ? 'high-contrast' : observedTheme,
});

Config outranks observation — you telling us beats us sniffing — and the callback outranks the map.

Worth knowing: with one theme in the run, Setsquare says nothing about theme-specific failures. Not "no theme-specific failures found", which would be a claim about your dark mode from a run that never looked at it.

3. Viewports: laptop, tablet, phone

A rule only ever sees the viewport it ran in. A navigation that collapses into a menu below a breakpoint is a different document, not a narrower one — so a scan at 1280 has no opinion whatsoever about the same page at 390, and a grid built entirely from desktop scans reads as far more coverage than it is.

Setsquare cannot widen that for you. It scans the pages your suite drives, at the sizes your suite drives them, and it takes no screenshots at all — so it can neither resize a page nor produce a capture at a width your tests never used. A project per width is what does, and it needs no test changes:

ts
// withSetsquare(yourConfig, setsquareOptions) — two arguments, config first.
export default withSetsquare(
  defineConfig({
    projects: [
      { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
      {
        name: 'tablet',
        testMatch: /a11y\.spec\.ts/,
        use: { ...devices['Desktop Chrome'], viewport: { width: 768, height: 1024 } },
      },
      {
        name: 'mobile',
        testMatch: /a11y\.spec\.ts/,
        use: { ...devices['Desktop Chrome'], viewport: { width: 390, height: 844 } },
      },
    ],
  }),
);

Three things in that snippet are decisions rather than boilerplate.

The engine is held constant. Playwright's devices['iPad (gen 7)'] and devices['iPhone 15'] are WebKit, so adopting them changes the browser and the width together and every difference in the results has two candidate causes. Spreading Desktop Chrome and overriding only viewport isolates the variable you are actually testing. Use the real device descriptors when you want to test WebKit — just know that is a different experiment.

testMatch keeps the narrow projects to the specs that benefit. Running your whole suite three times is the obvious reading and usually the wrong one: a spec that sets its own viewport will overwrite the project's, and specs that check request origins, resolve links or read a built artefact return an identical answer at every width. Point the extra projects at the specs whose layout is the subject.

320 needs no project. setsquare-reflow measures WCAG 1.4.10 there on every surface already, by resizing at teardown and putting the viewport back. A project at 320 re-measures what a rule covers.

Widths are yours to pick. 768 and 1024 are the conventional breakpoints and are where Setsquare's own reporting groups, so a project either side of one is the cheapest useful pair.

What you get back

The viewport is part of a scan's identity, so evidence from several widths survives rather than collapsing into one row. The report, the contact sheet and the per-surface tables group by band — laptop 1024px and wider, tablet 768px to 1023px, mobile narrower than 768px — with the exact size shown beside every band name.

Those names describe a width, never a device. Nothing detects what a page was opened on and nothing could: a 640px viewport lands in the mobile band whether it is a phone or a desktop browser with text at 200%. Every artifact that prints a band name prints that caveat beside it.

Worth knowing, and the same shape as themes: with one viewport in the run, Setsquare says nothing about narrow widths. Not "no narrow-viewport failures found", which would be a claim about your phone layout from a run that never looked at it.

4. Personas: who was looking at the page

Same shape as themes, and the same reason: a finding on a page an admin sees and a finding on a page everyone sees are different findings.

ts
export default withSetsquare(
  defineConfig({
    projects: [
      { name: 'recruiter', use: { storageState: '.auth/recruiter.json' } },
      { name: 'candidate', use: { storageState: '.auth/candidate.json' } },
      { name: 'admin', use: { storageState: '.auth/admin.json' } },
    ],
  }),
  { personas: { recruiter: 'recruiter', candidate: 'candidate', admin: 'admin' } },
);

Then setsquare audit will tell you something like "3 roles configured, 1 with evidence" — which is usually the most uncomfortable line in the report, and the most useful.

5. Sharding in CI

yaml
jobs:
  a11y:
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 26
      - run: npm ci
      - run: npx playwright install --with-deps

      - uses: ./.github/actions/setsquare
        with:
          command: npx playwright test --shard=${{ matrix.shard }}/4
          lane: shard-${{ matrix.shard }}
          run-parts: ${{ strategy.job-total }}

Without the Action, the same thing is three environment variables:

Terminal
SETSQUARE_RUN_ID="$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" \SETSQUARE_LANE="shard-1" \SETSQUARE_RUN_PARTS=4 \  npx playwright test --shard=1/4

GITHUB_RUN_ATTEMPT in the id is not decoration: a re-run must not merge into the previous attempt's evidence.

What run-parts does and does not buy. It does not make a sharded run complete. Parts are merged from a directory and matrix jobs do not share a filesystem, so each shard reports 1 of 4 declared invocations — partial. That is still the right thing to declare, because it changes the answer from unknown to partial: an undeclared shard cannot stop anyone quoting its conformance figures as the whole application's, and a partial one announces that it is a slice.

Consolidating the shards into one view is Setsquare Cloud. Locally, invocations merge because they share a directory; matrix jobs do not share a filesystem, so there is nothing to merge into. Each shard is stamped with the same run-group id and the declared census, which is what the platform groups on — four envelopes in, one run out. Free, you get each slice honestly labelled as one of four; you do not get the four turned into one.

6. A monorepo with several Playwright configs

setsquare init wraps every config it finds, so this mostly works already. Two things bite.

pnpm workspaces do not link binaries for transitive dependencies. npx setsquare fails in a package that gets the CLI through @setsquare/review-sdk. Use:

Terminal
pnpm exec setsquare audit

Each config writes its own .setsquare, and evidence does not merge across them. setsquare doctor flags this as pw.output-dir-divergence, because run groups from configs with different output locations cannot merge — and that failure is silent. If you want one logical run across packages, give them one output directory:

ts
// apps/web/playwright.config.ts and apps/admin/playwright.config.ts
export default withSetsquare(defineConfig(yourConfig), { outputDir: '../../.setsquare' });

Otherwise treat them as separate runs, which is usually the honest description anyway.

In the Action, point at the package rather than the root:

yaml
- uses: ./.github/actions/setsquare
  with:
    working-directory: apps/web

7. Putting the evidence somewhere else

In the config, permanently:

ts
export default withSetsquare(defineConfig(yourConfig), { outputDir: 'artifacts/setsquare' });

For one invocation, without touching the config:

Terminal
SETSQUARE_OUTPUT_DIR=artifacts/setsquare npx playwright test

The environment variable outranks the config, deliberately: it changes per invocation and the config file does not. Every reading command follows the evidence — audit, report, findings, sarif and verify look for .setsquare beside each config they find, so a custom location does not leave them permanently cold. If you need to point one of them at an exact directory:

Terminal
npx setsquare findings --run artifacts/setsquare/runs --out findings.md

Add your output directory to .gitignore. setsquare init does this for the default location; a custom one is yours to add.

8. Auditing coverage before you install anything

audit and doctor install nothing into your suite and send nothing anywhere:

Terminal
npx @setsquare/review-cli audit

On a Next, SvelteKit or Nuxt repository it finds your routes itself. When it cannot — a framework it does not know, or routes assembled at runtime — hand it a list rather than let it guess:

Terminal
# routes.txt — one path per line, # for comments, :param verbatim//jobs/jobs/:id/jobs/:id/apply
Terminal
npx @setsquare/review-cli audit --routes routes.txt

A sitemap works too, with one caveat you should read before believing the number:

Terminal
npx @setsquare/review-cli audit --sitemap public/sitemap.xml

A sitemap lists public, indexable pages. Every authenticated surface is absent from it by definition, so a logged-in application looks small and well covered. audit prints that caveat itself; it is repeated here because it is the one that misleads.

9. doctor in CI, without a gate

yaml
- name: What the suite is configured not to see
  run: npx setsquare doctor

That is the whole recipe, and the missing part is the point: there is no --fail-on, and doctor always exits 0. A graded configuration gets gamed rather than fixed, and a non-zero exit is one inch from a build gate we deliberately do not ship. Setsquare will not fail your build on a finding, locally or in CI.

For an agent or a dashboard, --json emits the report and nothing else — no styling, no prose:

Terminal
npx setsquare doctor --json > doctor.json

The shape, as shipped:

jsonc
{
  "schemaVersion": 1,
  "tool": "@setsquare/review-cli",
  "root": "/path/to/repo",
  "generatedAt": "2026-08-24T10:00:00.000Z",
  "checks": [
    {
      "id": "axe.disabled-rules",          // dotted, stable, part of the contract
      "severity": "blind-spot",            // blind-spot | degraded | hygiene
      "status": "flagged",                 // flagged | clear | unknown
      "detail": "…",
      "evidence": [{ "file": "…", "line": 24, "excerpt": "…" }],
      "criteria": ["1.4.3", "1.4.6"],   // bare criterion ids, not prose
      "remediation": "…",                  // one mechanical line about YOUR config
      "unknownReason": "…"                 // present exactly when status is unknown
    }
  ],
  "counts": { "flagged": 7, "clear": 11, "unknown": 2 },
  "caveats": ["…"]
}

The top-level shape is frozen by a fixture test at schemaVersion: 1. generatedAt is there so a consumer can tell a report written moments ago from one committed to a branch six months back — a stale configuration report reads exactly like a current one.

Every check appears, including clear and unknown ones, so a consumer can tell "we looked and it is fine" from "we do not have that check" — and unknown is a first-class answer with a stated reason, not a silent omission. doctor never evaluates your Playwright config (it is executable, it imports your plugins and your secrets), so retries: process.env.CI ? 2 : 0 resolves and retries: RETRIES comes back unknown. That is the trade, on purpose.

10. SARIF: findings in the pull request and the editor

yaml
permissions:
  contents: read
  security-events: write   # required by the upload, and only by the upload

steps:
  - uses: ./.github/actions/setsquare
    id: setsquare
    with:
      sarif: 'true'

  - uses: github/codeql-action/upload-sarif@v4
    if: always() && steps.setsquare.outputs.sarif-file != ''
    with:
      sarif_file: ${{ steps.setsquare.outputs.sarif-file }}

By hand, from any run:

Terminal
npx setsquare sarif --out setsquare.sarif

Two properties worth knowing before you wire it to a PR.

Every incomplete arrives as a note, never an error. An "automation could not decide" result showing up as a red annotation is the fastest way to lose trust in the whole export.

A sharded run uploads one analysis per shard, each under its own category (setsquare/<branch>/<lane>). GitHub overwrites earlier results when two uploads share a category, so a shared one would show a quarter of the findings while looking complete — four visibly partial analyses beat one silently wrong one. Merging them into a single view is Setsquare Cloud, and the SARIF file says so itself.

A line number appears only when a distinguishing token was actually found on that line. Otherwise the result points at the spec that visited the surface, labelled as such — never an invented location. Fingerprints exclude the file and line, so a refactor does not arrive as a wave of new alerts.

For an agent rather than a code-scanning UI, the markdown export carries more — selectors, context, candidate source files and a command that reproduces each finding:

Terminal
npx setsquare findings --out findings.md

11. Checking a fix locally

Terminal
npx setsquare verify --surface job-search

It re-runs your Playwright against that one surface and answers in exactly three words: still failing, no longer detected, newly detected.

Not "fixed". A rule can stop firing because the element stopped rendering, and verify says so every time it reports one. There is no score, no delta and no better-or-worse claim — those need history, and a local run keeps none.

It refuses outright if the re-scan used a different ruleset from the run it is comparing against. That is the feature: two runs with different rules answer different questions, so an axe-core upgrade would otherwise read as a wave of new failures.

It writes to .setsquare/verify/, never over .setsquare/runs/, so verifying cannot destroy the run that audit, report, findings and sarif all read.

12. Scanning only what a change can reach

Terminal
npx setsquare changed --base origin/mainSETSQUARE_CHANGED_ONLY=.setsquare/changed.json npx playwright test

The first command works out which routes your changed files can reach — by walking imports backwards from each changed file to the route files that render it, because a filename cannot tell you which surfaces a component appears on. The second scans only those surfaces; the rest are recorded as skipped, with the reason.

It refuses more often than you would expect, and that is deliberate. If any relative import in the graph did not resolve, it writes no set at all: a hole in the graph and a genuinely unaffected file look identical from the outside, and scanning fewer surfaces than the change touched produces a clean run over a broken application. --allow-partial overrides that and labels what it wrote as unvouched-for.

Two things this is not:

  • It is not a large CI-time saving. It removes axe scans, not test execution. The whole suite still runs. The big lever is test selection, which is Playwright's job.
  • It is not a description of your application. Every figure in a changed-only run describes a slice, and the summary says so near the top for exactly that reason. Do not quote those numbers as coverage.

13. Getting credit for the criteria your own tests already cover

ts
test('no horizontal scrolling at 320px', async ({ page }) => {
  setsquare.covers('1.4.10');
  await page.setViewportSize({ width: 320, height: 800 });
  // …your assertion
});

The criterion then appears in the report as covered by your own test, with the test named beside it, instead of as "no automated result".

Why this exists: axe is a single-snapshot DOM analyser, and a large group of WCAG criteria are about what happens when you drive the page — reflow at a narrow viewport, focus not being obscured, no keyboard trap, navigation staying consistent between pages. A mature suite is usually checking several of them already, and the projection had no way to see it, because a Playwright spec arrives in the run envelope as a pass or a fail with no criterion attached.

It is recorded as your claim, and every renderer says so. We do not check that a test declaring 1.4.10 tests reflow, so it is never merged into the state that means every automated rule passed — separate state, separate wording, separate glyph in the grid. A declaring test that fails reads as "your own test for this criterion did not pass" and stops the level reading clean, but never becomes a failure we claim to have detected: a red test can be red for a reason unrelated to the criterion.

An id that is not a WCAG 2.2 success criterion throws in your test rather than being dropped. That is the one thing in the SDK that throws, and it is deliberate — a silently ignored declaration would leave you believing you have coverage you do not have.

14. Criteria checked beyond axe

Nothing to configure — these run already. They are listed because people reasonably assume a scanner is axe and nothing else, and then wonder where a finding came from.

CriterionHow it is checked
1.4.12 Text SpacingThe SC's four spacing metrics are applied and what clips is measured.
2.1.4 Character Key Shortcuts, opt-inaddEventListener is wrapped before your scripts run, to see which key handlers exist and on which characters.
2.1.2 No Keyboard TrapTab and Shift+Tab are pressed and the sequence of focus moves is read. A cycle focus never leaves, that Escape does not release, is a failure.
2.4.7 Focus VisibleThe computed style is diffed before and after focus, ::before/::after included.
2.4.11 Focus Not Obscured (Minimum)Each focusable element is focused; anything covering it entirely is a failure.
2.4.12 Focus Not Obscured (Enhanced), AAAThe same walk, stricter: any part covered is a failure. Opt-in with AAA.
2.4.13 Focus Appearance, AAAThe indicator's thickness and contrast are measured against the SC's 2px and 3:1, and both figures are in the finding.
3.1.5 Reading Level, AAAA Flesch–Kincaid grade per page.
3.2.1 On FocusEach control is focused and watched for a navigation, a stolen focus or a new window.
3.2.2 On Input, opt-inEach <select>, checkbox and radio is changed and watched the same way.
3.2.3 / 3.2.4 / 3.2.6 Consistent Navigation, Identification, HelpCompared across surfaces, after the run, from what the scans already carried.
2.4.5 Multiple WaysAlso cross-surface: navigation, a search control and a sitemap link, counted across the run.
1.3.1, 1.3.2, 1.3.3, 2.4.6, 1.2.1–1.2.5, and with AAA 1.2.6–1.2.9, 2.4.8, 2.4.10, 3.1.4Nine heuristics, read from the page. Where the question is about meaning they ask a person (1.3.1, 1.3.2, 2.4.6 and the media); where the criterion names a mechanism or states a test, they decide and can report a failure (1.3.3, 2.4.8, 2.4.10, 3.1.4). 1.2.4 and 1.2.6–1.2.9 are answered only on a page with no media at all, as inapplicable.
3.3.1 / 4.1.3 Error Identification / Status MessagesReads a page your test put into an error state — see { state: … } above.

Two things worth knowing about the focus walk.

They run at teardown only. Focusing an element fires your page's own focus, focusin and blur handlers; the keyboard walk presses Tab and Escape, which fires your key handlers too. A handler that opened a menu or started a request has already run by the time anything is restored. Scroll position and the previously focused element are put back; side effects cannot be. So the walk happens where your test is over and the page is about to be discarded — which means a scan of a named state carries no focus evidence, and whatever your test left on screen does.

They are bounded, and they say so. The focus walk focuses at most 120 elements. The keyboard walk presses Tab enough to go round your page's tab order twice in each direction, up to 1,000 presses, which is two laps of a page with up to 499 tab stops. Both give themselves a wall-clock budget, and both say how many of how many they covered, in the finding itself — a bounded walk reported as a complete one is a number that looks like a statement about your page and is a statement about part of it.

For 2.1.2 the bound is the feature rather than a defence: a trap is precisely the condition under which "tab until you reach the end" never terminates.

The last row needs two surfaces, and says so when it does not have them. A run with fewer than two surfaces reports 3.2.3, 3.2.4 and 3.2.6 as untested — "these criteria compare pages against each other; this run had one surface" — rather than as passing. A one-page run cannot evidence consistency, and a clean pass on a run that had nothing to compare is the easiest lie in this whole tool to ship by accident.

Only links in navigation, header and footer landmarks are compared: 3.2.4 is about repeated components, and naming an inline prose link differently in two sentences is ordinary writing.

Twenty criteria are reported as "no automated rule can decide this", kept apart from the ones nobody has built a rule for yet — an auditor's first question about an untested criterion is which of the two it is. Each entry in packages/schema/src/automation-limits.ts carries its reason and what would have to change, so it is reviewable rather than a wall, and entries where presence is checkable but adequacy is not say so at that granularity.

The heuristics are on and individually switchable. Each fires only above a margin argued in the source and says what would resolve it. Four of them can report a failure, where the criterion names a mechanism or states a test the page either has or fails; the rest ask a person. If you disagree with one:

ts
export default withSetsquare(defineConfig({ … }), {
  heuristics: { sensoryCharacteristics: false },
});

That silences one and leaves the other eight, which is deliberate: a customer who disagrees with one heuristic must not have to turn off the lot. The three that read English words are silent on a page in another language rather than guessing at it.

Five criteria need turning on too, with listeners: true, and for a different reason: it is the only thing this SDK installs that runs before your own code. One wrapper serves 2.1.4 Character Key Shortcuts, 2.5.1 Pointer Gestures, 2.5.2 Pointer Cancellation, 2.5.4 Motion Actuation and 2.5.7 Dragging Movements — all five are questions about a handler rather than about markup, so none of them is reachable any other way, and turning it on costs the same for one as for five. (keyShortcuts: true is the old name and still works.) The wrapper records and then delegates with your original arguments, so removeEventListener still matches — a wrapped listener would be a different function object and your page would silently keep handlers it asked to be rid of. The fixture suite proves it by adding and removing a handler and checking the key does nothing.

They only ever report needs-a-person, or a pass carrying a count, because each is satisfied by an affordance no check can see — turn the shortcut off, remap it, offer a button that does what the gesture does, provide an undo. Not one of them can be a failure from a listener alone.

3.2.2 needs turning on:

ts
export default withSetsquare(defineConfig({ … }), { onInput: true });

Off by default because changing a control's value is where an application saves, and writing to your data is a worse failure than any finding the rule could produce. Turn it on where the suite runs against disposable data. Only <select>, checkboxes and radios are touched and their values are put back; nothing is ever typed into a text field, no form is ever submitted (submit events are cancelled and form.submit() is patched, which is not the same thing), and no navigation is ever allowed to commit.

While it is off, 3.2.2 reports as a check that exists and did not run here — which is what it is, and is a different statement from "no automated check exists".

2.5.5's click probe needs turning on:

ts
export default withSetsquare(defineConfig({ … }), { clickProbe: true });

Off by default for the same reason. It exists because geometry gets 2.5.5 wrong on a shape every design system ships: a 40px <input> inside a 44px wrapper whose click handler focuses it. The effective target is 44px, the DOM says 40, and there is nothing in the DOM to say otherwise — the handler is attached at the framework's root and getEventListeners is a DevTools API. The only thing that can see a delegated handler is a pointer action, so this performs one.

What it touches: only controls this run has already failed on a short pointer region, one click each, on one ancestor, at teardown, after the scan. The default action of every click is suppressed for the probe's duration, so nothing navigates, submits or toggles — while your own handlers still run, which is the point. Focus is put back. What it cannot stop is your own JavaScript: a handler that routes or POSTs will.

It only ever moves a row from failing to passing, and only when that control was activated — a toolbar whose handler fires and focuses something else does not clear the icon button beside it. Turning it on also makes the teardown sweep re-scan pages it would otherwise skip, so a suite pays one extra scan per unchanged page for the correction.

While it is off, those controls stay violations and the rule's own text says what it could not see.

Two things 2.1.2 will not claim. A cycle is only a failure if it cannot be left, so a modal dialog that releases on Escape passes — cycling focus is what a correct modal does. And a page containing an iframe reports needs a person, because focus moving into a frame is handled by that frame's own document and the parent gets no event for it: a trap inside the frame would look exactly like a clean walk from outside.

Where the answer is a judgement it reports needs a person, never a pass and never a fail: a translucent overlay over the focused element, an indicator drawn with a box-shadow (whose area computed style does not pin down), a ring against a gradient, or the browser's own outline-style: auto ring, whose painted thickness is not what outline-width reports.

One more, worth knowing if you use the modern pseudo-class: where :focus-visible did not match, "nothing changed on focus" is reported as needs-review rather than as a 2.4.7 failure. A programmatic focus does not always match :focus-visible, and an indicator styled for it only would not have applied — so that is a weaker claim than "a keyboard user sees nothing".

15. Tailwind's line heights and WCAG 1.4.8

If you use Tailwind and see 1.4.8 line-spacing findings across most of your prose, the cause is almost certainly one line of theme configuration rather than anything you wrote.

Tailwind's type scale ships a line height with every size, and several are below the 1.5 the SC asks for — text-sm is 20px on a 14px font, which is 1.43. The part that makes it spread is that line height inherits: a card wrapper with text-sm on it hands 1.43 to every paragraph inside, whatever size those paragraphs are. One utility on one container, dozens of findings.

The fix is in @theme, once:

css
@theme inline {
  --text-xs--line-height: 1.55;
  --text-sm--line-height: 1.55;
  --text-base--line-height: 1.55;
  --text-lg--line-height: 1.55;
  --text-xl--line-height: 1.55;
}

That raises the default and leaves leading-tight and friends working, so headings and single-line labels keep whatever leading they are meant to have — which matters, because 1.4.8 is about blocks of text and WCAG defines one as more than one sentence. A two-line heading is not in scope and should not be loosened to satisfy a rule that does not apply to it.

1.55 rather than 1.5 so that a clamped font size or an inherited ratio cannot land a block a hair under the bar.

This is written from fixing exactly this on our own site, where it accounted for every 1.4.8 finding we had. Other utility frameworks ship the same kind of default; the shape of the fix is the same — raise the inherited default rather than chase the individual blocks.

16. The four criteria that are usually one attribute

Four of the AAA-and-adjacent criteria report as failures far more often than they should, and in nearly every case the cause is a mechanism that is missing rather than content that is wrong. They look alarming on a first scan and most of them are a line each.

3.1.4 Abbreviations — an <abbr> on first use, or one glossary

A finding here means a token has no mechanism at all: no <abbr> of its own, no expansion in the page's own prose, and no glossary linked from the page. The check looks for all three, so it is not asking you to mark up every occurrence of every acronym.

Three ways to clear it, in increasing order of how much they cover:

html
<!-- 1. The prose already does it. Nothing to change. -->
<p>Web Content Accessibility Guidelines (WCAG) 2.2 …</p>

<!-- 2. One <abbr> anywhere on the page clears that token for the page. -->
<p>Scanned against <abbr title="Web Content Accessibility Guidelines">WCAG</abbr> 2.2 …</p>
html
<!-- 3. A glossary, linked from a footer that every page shares, clears
     every token on every page — including pages nobody has written yet. -->
<a href="/glossary">Glossary</a>

The third is G62 and is the one to reach for on a documentation site: a technical product accumulates abbreviations faster than anyone maintains inline markup, and the per-page approach fails silently the first time somebody adds a page and forgets. The check matches a glossary by the link to it rather than by any <dl> in the page, because a definition list is the markup for a glossary and for a dozen other things.

We report this at minor because an all-capital token is a correlate for an abbreviation — a page can capitalise something that is neither. The tokens are named in the finding for exactly that reason: if we have it wrong, you can see which words we meant in one line.

2.4.8 Location — aria-current="page", which you have probably already styled

A finding means the page has navigation to other pages and carries none of the mechanisms the SC accepts: no breadcrumb, no aria-current on any navigation item, no link to a site map, and no rel stating its place in a collection.

Almost always the nav item for the current page is already styled as the active one and the attribute is simply not there:

html
<a href="/pricing" class="active" aria-current="page">Pricing</a>

If a page is deliberately not in the nav — a pricing page you have not launched, a campaign landing page — then no nav item can ever be current, and a breadcrumb is the right answer rather than forcing it into the menu:

html
<nav aria-label="Breadcrumb">
  <ol>
    <li><a href="/">Home</a></li>
    <li aria-current="page">Pricing</li>
  </ol>
</nav>

That is G65, and it is what we did on our own pricing page when this check found it.

2.4.10 Section Headings — name the sections you have already declared

The failure to fix first is a <section>, <article> or role="region" with no heading inside it and no aria-label. Your markup has already said this content divides into sections; the criterion asks that they are named. Three ways out, and the third is often the honest one:

html
<section><h2>Pricing</h2>…</section>
<section aria-label="Pricing">…</section>
<div>…</div>   <!-- if it was never a section -->

The other half of this rule — a long run of text with no heading — is reported at minor, because the character count is ours and not the SC's. The commonest real cause we have seen is a heading that is styled like a heading and is not one: a <p class="h2"> or a step title rendered as a paragraph. Both looked identical on our own site and neither was reachable by anybody navigating by heading.

1.3.3 Sensory Characteristics — add the name

We only report an instruction where the sensory characteristic is the only thing identifying its target. A sentence that names the control, or that describes rather than instructs, is not reported.

html
<!-- reported -->
<p>To continue, press the button on the right.</p>
<!-- not reported -->
<p>To continue, press the Save button on the right.</p>

Adding the name is the whole fix, and it is worth doing even where you disagree with the finding: "the Save button on the right" is better writing than "the button on the right" for a sighted reader too.

17. The five criteria only your suite can reach

Five WCAG criteria are not silent in a Setsquare report because nobody has built the check. They are silent because the thing they are about is a property of your application that no markup states, and the only way they will ever carry evidence is setsquare.covers().

CriterionWhy no rule can reach itThe test that reaches it
3.3.4 Error Prevention (Legal, Financial, Data) — AAApplies only to pages that "cause legal commitments or financial transactions for the user to occur, that modify or delete user-controllable data... or that submit user test responses". Nothing in a document says whether a form does any of that.Drive the submission inside setsquare.acrossSubmission(page, { consequential: true }, …) — we compare what was entered against what the page presents back, and report whether the Confirmed escape was there.
3.3.6 Error Prevention (All) — AAAThe same three escapes — Reversible, Checked, Confirmed — applied to every submission. Same problem, larger population.The same call with no consequential flag. Every submission your suite drives counts, and the denominator we report is exactly that: the submissions your tests drove.
2.2.5 Re-authenticating — AAANeeds a session that expires and then continues without data loss. A scan sees one moment.Wrap the expiry in setsquare.acrossReauthentication(page, …) — we read the form on both sides of your callback, so this one comes back as our measurement rather than your claim.
2.2.6 Timeouts — AAANeeds a timeout that exists, and a warning about it, neither of which a page declares.Let a form sit past your inactivity timeout, and assert the warning arrives before anything is lost and states how long they have.
2.2.3 No Timing — AAAProving that no part of a page has a time limit means proving a negative about behaviour nobody triggered.Drive whatever your app does on a timer and stay on the page long enough for it to happen.

This table said FOUR until 2026-09-21, and 2.2.3 had carried the same position in automation-limits.ts the whole time. The set is now derived from one field — suiteWouldNeedTo — which doctor, the HTML report and this table all read, so a sixth criterion appears in all three at once or in none.

The first two are worth dwelling on, because they look automatable and are not. A rule could see a <form method="post">; it cannot see that the form buys an aeroplane ticket. Guessing from the action URL — /checkout, /delete, /order — is a threshold chosen rather than measured, and every such threshold in this product has been wrong at least once. A criterion whose POPULATION is a judgement cannot be decided for that population by a rule, however mechanical the rest of it looks.

What a suite already knows, and the report does not:

ts
test('an order can be reviewed and corrected before it is placed', async ({ page }) => {
  await page.goto('/checkout');
  await fillTheOrder(page);

  // You say which submission is consequential; we look for the escape.
  await setsquare.acrossSubmission(page, { consequential: true }, async () => {
    await page.click('#review-order');
  });
});

test('a session that expires can be resumed with the form intact', async ({ page }) => {
  await page.goto('/checkout');
  await page.fill('#address', '12 Example Street');

  // Not `covers()`. You point at the moment; we take the measurement.
  await setsquare.acrossReauthentication(page, async () => {
    await page.context().clearCookies();
    await signBackIn(page);
  });
});

Three have moved, and they moved the same way: the population stayed a judgement, and pointing at it turned the rest of the criterion into something measurable. 3.3.4 and 3.3.6 look for the Confirmed escape — "a mechanism is available for reviewing, confirming, and correcting information before finalizing the submission" — and report it as ours. Finding none is never a failure: Reversible and Checked each meet the criterion alone and neither leaves anything in a page to find.

2.2.5 is the clearest of them. The criterion needs two moments and only your suite knows where the second one is — but once it points at that, "without loss of data" is the values before against the values after, which is arithmetic rather than a judgement. So it reports as our finding, with the fields named and counted. Nothing you typed leaves the page: the values are hashed in the browser with a per-call salt.

It never fails the criterion. A CSRF token is supposed to be discarded, and G181 — a sufficient technique — carries data through a re-authorization page as hidden fields we deliberately do not read.

The criterion then appears as covered by your own test, with the test named beside it, instead of as "no automated result" — and it is recorded as your claim, for the reason section 13 gives.

Declaring one is not the same as our finding one, and the report never merges the two. That is the point rather than a limitation: these five are exactly where an honest tool has to say "you know this and we do not".

setsquare doctor now says which of them your suite declares and which it does not, scoped to the level you target — so at wcag: 'AA' you hear about 3.3.4 and not about the four AAA ones. It prints the test, not advice about a finding: what to write, in your suite, in one line.

Environment variables, all of them

VariableEffect
SETSQUARE_DISABLED=1The SDK does nothing at all. The control run for "what does this cost us".
SETSQUARE_AUTOSCAN=0No automatic scanning in this invocation; explicit setsquare.scan() still works. Per lane, which a config file cannot express.
SETSQUARE_OUTPUT_DIR=<dir>Where evidence is written. Outranks outputDir.
SETSQUARE_RUN_ID=<id>Join several invocations into one logical run.
SETSQUARE_LANE=<label>Label this invocation within that run. Prefixes test ids, so it must be unique per lane.
SETSQUARE_RUN_PARTS=<n>Declare how many invocations to expect. Turns unknown coverage into partial.
SETSQUARE_CHANGED_ONLY=<file>Scan only the routes named in that file.
SETSQUARE_SUMMARY_MD=<file>Also write the run summary as markdown there, for a CI job-summary panel.