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
- After each
page.screenshot(), the capture client runs@axe-core/playwrighton the already-loaded page. - Violations and incomplete results are uploaded as a
accessibility_resultsmultipart field alongside the screenshot. - The server compares violations against the branch baseline to identify new, resolved, and known violations.
- New violations appear in the review UI with impact badges, contrast detail cards, and screenshot overlays.
- 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.
Via .pixmoat.yaml (recommended)
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:
| Preset | Rules | Use case |
|---|---|---|
contrast_only | color-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_aaa | WCAG 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):
| Level | Normal text (<18pt / <14pt bold) | Large text (>=18pt / >=14pt bold) |
|---|---|---|
| AA (SC 1.4.3) | 4.5:1 | 3:1 |
| AAA (SC 1.4.6) | 7:1 | 4.5:1 |
| Non-text UI (SC 1.4.11) | 3:1 | 3: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
| Mode | Behavior |
|---|---|
advisory (default) | Violations are shown in the review UI but do not block the build. Start here. |
blocking | New violations block “Finish review” until approved or fixed. Promote to this after establishing baselines. |
Thresholds
Thresholds are evaluated in order:
max_violations— if total violation count exceeds this, result isviolations(hard fail whengating: blocking)max_serious— if count ofserious+criticalimpact violations exceeds this, result isviolationsfail_on_new— if any new violations exist (not in baseline), result isviolations(requires review decision)- If only
incompleteresults present, result iswarnings - 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:
- First run (no baseline): All violations have result
new. Approve to establish the initial baseline. - 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.
- 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:
| Impact | Color |
|---|---|
| Critical / Serious | Red |
| Moderate | Orange |
| Minor | Yellow |
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
- For each theme, the client calls
page.emulateMedia({ colorScheme })— Playwright’s built-in mechanism for switchingprefers-color-scheme. No page reload needed. - A screenshot and accessibility audit are captured per theme.
- The theme is encoded in the browser dimension:
chromium___dark,chromium___light(triple-underscore separator). - 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)"
Recommended rollout sequence
- Start with
gating: advisory— enable the feature and see what violations exist without blocking builds. - Triage initial violations — in the review UI, approve known/acceptable violations to establish the baseline.
- Fix genuine issues — address real contrast and a11y violations in code.
- 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
| Issue | Solution |
|---|---|
| No accessibility tab on snapshots | Verify accessibility.enabled: true in .pixmoat.yaml or project settings |
| “Not evaluated” on build page | The build’s resolved_config had accessibility disabled — check the config that was active at capture time |
| Too many false positives | Start with preset: contrast_only or add noisy rules to rules.exclude |
| Bounding box overlay missing for some violations | The 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 timeout | Pages 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 fixes | Ensure the build is on the correct branch and the accessibility baseline reflects the latest approved state |