stableFor developers

Accessibility Regression Checking

pixmoat runs WCAG accessibility audits on every captured page using axe-core, the industry-standard accessibility engine used by Google Lighthouse and Playwright’s own a11y testing docs. Violations are stored per-snapshot, compared against baselines to detect regressions, and surfaced in the review UI overlaid on the actual screenshot.

How It Works

  1. After each page.screenshot(), the capture client runs @axe-core/playwright on the already-loaded page.
  2. Violations and incomplete results are uploaded as a accessibility_results multipart field alongside the screenshot.
  3. The server compares violations against the branch baseline to identify new, resolved, and known violations.
  4. New violations appear in the review UI with impact badges, contrast detail cards, and screenshot overlays.
  5. Reviewers approve or reject violations. Approved violation sets become the new baseline.

No extra infrastructure is required. axe-core runs in the same Playwright session used for visual capture.

Enabling Accessibility Checking

Accessibility checking is off by default. Enable it via .pixmoat.yaml or the project settings UI.

Add an accessibility section to your project’s .pixmoat.yaml:

version: 1

accessibility:
  enabled: true
  preset: wcag_aa          # contrast_only | wcag_aa | wcag_aaa
  gating: advisory         # advisory | blocking

This is a build-scoped setting: each commit carries its own accessibility configuration. A feature branch can enable or disable the feature independently of the project default.

Via Project Settings UI

Navigate to Project Settings > Accessibility and toggle the enable switch. The UI also provides controls for preset, gating mode, and thresholds.

When .pixmoat.yaml manages accessibility, the settings page shows a “managed by .pixmoat.yaml” banner. The toggle remains editable for emergency overrides.

Precedence

.pixmoat.yaml accessibility.enabled (per-commit, build-scoped)
  > project.accessibility_config.enabled (UI toggle)
    > server default (disabled)

Presets

Presets control which WCAG rules axe-core checks:

PresetRulesUse case
contrast_onlycolor-contrast, link-in-text-block (2 rules)Start narrow — focus on contrast regressions only
wcag_aa (default)WCAG 2.1 Level A + AA (~50 rules: labels, landmarks, ARIA, focus, contrast, etc.)Recommended for most teams. Aligns with EU EN 301 549 and US Section 508
wcag_aaaWCAG 2.1 Level A + AA + AAA (~60 rules, including enhanced contrast 7:1)Aspirational. Stricter contrast requirements

WCAG Contrast Ratio Reference

axe-core enforces these thresholds (defined by WCAG, not pixmoat):

LevelNormal text (<18pt / <14pt bold)Large text (>=18pt / >=14pt bold)
AA (SC 1.4.3)4.5:13:1
AAA (SC 1.4.6)7:14.5:1
Non-text UI (SC 1.4.11)3:13:1

Configuration Reference

Full .pixmoat.yaml accessibility section:

accessibility:
  enabled: true                    # Enable accessibility auditing (default: false)
  preset: wcag_aa                  # contrast_only | wcag_aa | wcag_aaa
  gating: advisory                 # advisory | blocking (default: advisory)
  thresholds:
    max_violations: null           # Hard-fail ceiling for total violations (null = no limit)
    max_serious: null              # Hard-fail ceiling for serious/critical violations
    fail_on_new: true              # Require review when any new violation appears (default: true)
  regression_detection: true       # Compare against baseline to find new/resolved (default: true)
  rules:
    exclude: []                    # Rule IDs to suppress (e.g. ["region"])

Gating modes

ModeBehavior
advisory (default)Violations are shown in the review UI but do not block the build. Start here.
blockingNew violations block “Finish review” until approved or fixed. Promote to this after establishing baselines.

Thresholds

Thresholds are evaluated in order:

  1. max_violations — if total violation count exceeds this, result is violations (hard fail when gating: blocking)
  2. max_serious — if count of serious + critical impact violations exceeds this, result is violations
  3. fail_on_new — if any new violations exist (not in baseline), result is violations (requires review decision)
  4. If only incomplete results present, result is warnings
  5. Otherwise, result is clean

Rule exclusions

Suppress specific rules that produce false positives in your project:

accessibility:
  enabled: true
  preset: wcag_aa
  rules:
    exclude:
      - region         # Landmark requirements on SPA layouts
      - meta-viewport  # Mobile viewport meta tag check

See the axe-core rule list for all available rule IDs.

Baseline Workflow

The accessibility baseline model works exactly like visual baselines:

  1. First run (no baseline): All violations have result new. Approve to establish the initial baseline.
  2. Subsequent runs: Violations are compared against the baseline by identity (rule_id, target_selector).
    • New violations (in current, not in baseline) trigger review.
    • Resolved violations (in baseline, not in current) are shown as resolved.
    • Known violations (in both) are clean — no action needed.
  3. Approving violations: Sets the current violation set as the new baseline for that snapshot on that branch.

Branch fallback

Baseline resolution follows the same branch-scoped fallback as visual baselines:

same-branch baseline > default-branch baseline > no baseline (result = new)

This means a feature branch inherits the main branch’s accessibility baselines until it establishes its own.

Review UI

Build summary

When accessibility is enabled, the build summary shows a counts row below visual and performance counts:

Accessibility: 3 new violations · 1 warning · 12 clean

When accessibility was disabled for a build, the row reads “Accessibility: not evaluated for this build” in muted text. This distinguishes “disabled” from “enabled and found nothing” (clean).

Snapshot accessibility tab

Each snapshot card gains an “Accessibility” tab showing structured violation cards:

  • Impact badge — color-coded: red for critical/serious, orange for moderate, yellow for minor
  • Rule ID — e.g. color-contrast, label, aria-required-attr
  • NEW / RESOLVED / KNOWN badge — whether this violation is new, resolved from baseline, or known
  • Element HTML — the affected element’s HTML snippet
  • CSS selector — copy-to-clipboard for developer use
  • Human message — axe-core’s full explanation
  • Help URL — link to Deque University with remediation guidance
  • WCAG criterion — extracted SC number (e.g. “SC 1.4.3”)

Contrast detail panel

For color-contrast violations, the card includes:

  • Color swatches — actual foreground and background colors
  • Ratio bar — visual 1:1 to 21:1 with markers at 3:1, 4.5:1, and 7:1 thresholds
  • Current ratio vs. required ratio
  • Font size and weight — determines which threshold applies
  • “What would pass” — suggested minimum foreground color adjustment (approximate, same-hue)

Screenshot overlay

Violations with resolved bounding boxes are rendered as colored border rectangles on the screenshot:

ImpactColor
Critical / SeriousRed
ModerateOrange
MinorYellow

Clicking an overlay highlight scrolls the violation list to the corresponding card, and vice versa.

Multi-Theme Capture (Light/Dark Mode)

Capture the same pages in multiple color scheme themes to catch dark-mode contrast regressions:

# .pixmoat.yaml
themes:
  light:
    media: "(prefers-color-scheme: light)"
  dark:
    media: "(prefers-color-scheme: dark)"

How it works

  1. For each theme, the client calls page.emulateMedia({ colorScheme }) — Playwright’s built-in mechanism for switching prefers-color-scheme. No page reload needed.
  2. A screenshot and accessibility audit are captured per theme.
  3. The theme is encoded in the browser dimension: chromium___dark, chromium___light (triple-underscore separator).
  4. Each theme produces a separate snapshot key, visual baseline, and accessibility baseline.

Composition with viewports and browsers

Multi-theme composes with existing multi-capture modes:

  • themes x viewports — all combinations captured
  • themes x browsers — theme is the inner loop within each browser dimension

Per-snapshot theme override

Override the project default for individual snapshots:

await pixmoat.snapshot(page, "settings", { themes: ["dark"] });

Theme-only use (without accessibility)

Theme capture is independent of accessibility. You can use it for purely visual regression testing of dark mode without enabling accessibility auditing.

Accessibility Statistics

Project-level accessibility page

Navigate to /{project}/accessibility for trend visibility:

  • Accessibility score (0-100) — severity-weighted: 100 - (critical*10 + serious*5 + moderate*2 + minor*1), clamped to 0. A trend indicator, not a compliance metric.
  • Violation trends — total, new, resolved violations per build over last 50 builds
  • By impact — stacked bar chart (critical/serious/moderate/minor)
  • By rule — grouped bar chart (color-contrast, label, etc.)
  • Top violating pages and top rules

Branch comparison

When viewing a feature branch, the page shows a delta vs. the default branch:

Branch: feat/dark-mode-redesign vs main

  Violations:  31 → 22  (↓ 9 resolved, ↑ 0 new)
  Score:       72 → 85  (↑ 13)

This answers “did this branch improve or worsen accessibility?” in the same review where you see visual diffs.

Project Header Status Pill

Every project shows an accessibility status pill next to the project name:

  • Accessibility: off — feature not enabled (click to navigate to settings)
  • Accessibility: on (wcag_aa, blocking) — enabled with active preset and gating mode

The pill is always rendered, even for projects that have never enabled accessibility, as a discoverability nudge.

Webhook Payloads

When accessibility is enabled, build.reviewed and build.rejected webhook payloads include an accessibility object:

{
  "event": "build.reviewed",
  "build": { "..." },
  "accessibility": {
    "score": 85,
    "total_violations": 22,
    "new_violations": 0,
    "resolved_violations": 9,
    "result": "clean"
  }
}

The object is absent (not null) when accessibility was not enabled for the build.

Example: CI Pipeline with Blocking Gating

A complete .pixmoat.yaml for a project that enforces WCAG AA compliance and captures both light and dark themes:

version: 1

accessibility:
  enabled: true
  preset: wcag_aa
  gating: blocking
  thresholds:
    max_serious: 0           # Zero tolerance for serious/critical
    fail_on_new: true
  rules:
    exclude:
      - region               # SPA layout doesn't use landmarks

themes:
  light:
    media: "(prefers-color-scheme: light)"
  dark:
    media: "(prefers-color-scheme: dark)"
  1. Start with gating: advisory — enable the feature and see what violations exist without blocking builds.
  2. Triage initial violations — in the review UI, approve known/acceptable violations to establish the baseline.
  3. Fix genuine issues — address real contrast and a11y violations in code.
  4. Promote to gating: blocking — once baselines are clean, new violations block merges.
# Step 1: advisory mode
accessibility:
  enabled: true
  preset: wcag_aa
  gating: advisory

# Step 4: promote to blocking after triage
accessibility:
  enabled: true
  preset: wcag_aa
  gating: blocking

Per-Snapshot Opt-Out

Disable the accessibility audit for individual snapshots that produce noise (e.g. third-party embedded content):

await pixmoat.snapshot(page, "third-party-widget", {
  accessibility: false,
});

The visual screenshot is still captured; only the axe-core audit is skipped.

API Endpoints

Summary

GET /api/projects/{slug}/accessibility/summary?branch=main

Returns: score, violation counts, top violating pages, top rules, trend (last 30 days resolved/new).

History

GET /api/projects/{slug}/accessibility/history?branch=main&snapshot_key=<id>&limit=50

Returns: per-build violation counts, new/resolved counts, by-impact, by-rule.

Branch comparison

GET /api/projects/{slug}/accessibility/compare?branch=feat/x&base=main

Returns: base counts, branch counts, resolved list, new list, score delta.

All endpoints require project read access and follow existing authentication patterns (project token, PAT, session).

Troubleshooting

IssueSolution
No accessibility tab on snapshotsVerify accessibility.enabled: true in .pixmoat.yaml or project settings
“Not evaluated” on build pageThe build’s resolved_config had accessibility disabled — check the config that was active at capture time
Too many false positivesStart with preset: contrast_only or add noisy rules to rules.exclude
Bounding box overlay missing for some violationsThe element’s CSS selector could not be resolved at capture time. The violation is still recorded without overlay.
Large payload rejected (400)accessibility_results exceeds the 100 KB limit. This is unusual — check for pages with thousands of DOM elements
axe-core scan timeoutPages with 50k+ elements can push scan time to 10+ seconds. Consider limiting the page scope or using accessibility: false for heavy pages
Score not improving after fixesEnsure the build is on the correct branch and the accessibility baseline reflects the latest approved state