The accessibility scanner catches WCAG 2.1 AA issues across every public page — and lets you turn those findings into branded compliance reports you can hand to clients, stakeholders, or external auditors. It works in three layers that cover different parts of the problem:
- Source-level scanner — inspects row data while you're editing. Catches issues by looking at what's saved in the editor (alt text, headings, ARIA attributes, color combinations, etc.) before pages even render. Points at the exact field that needs fixing.
- Rendered-DOM scanner — loads each public page in a hidden iframe and runs axe-core against the final HTML. Catches what only exists after templates, Alpine, Livewire, and snippets all render — dynamic IDs, computed contrast on gradient backgrounds, ARIA introduced at runtime.
- Compliance reports — bundles current source + DOM findings into a frozen, branded snapshot, complete with a 0–100 accessibility score. View in the browser, download as a real PDF, or share a client-facing link — no dashboard login required.
All three layers live behind one dashboard page under Dashboard → Accessibility and one feature toggle: Dashboard → Settings → Features → Accessibility Scanner. On member installs it defaults ON because most benefit from it immediately; on installs without an active membership the feature is unavailable.
Reports can be generated on demand or automatically once a week, so there's always a recent score to track on the Reports tab's trend chart.
Why it matters
Web accessibility lawsuits in the US grew from a few hundred per year a decade ago to thousands per year now, with typical settlements in the $20K–$60K range plus required remediation costs. The underlying WCAG 2.1 AA standard isn't optional in many jurisdictions — EU European Accessibility Act, US Section 508, California Unruh Act, Ontario AODA, and more.
Even sites that never see a lawsuit lose customers. People with disabilities are roughly 15–20% of the population in most countries. A site that can't be used with a screen reader or keyboard quietly bleeds users.
Catching accessibility issues at edit time — before they ship — is dramatically cheaper than fixing them after launch. Each of the three scanner layers is built around that workflow: every issue surfaces with a clickable jump straight to the editor field that produced it (source scanner) or with the rendered selector and WCAG reference (DOM scanner).
Layer 1 — Source-level scanner
What it checks
Fifteen categories of checks run against every row's resolved field values. Each issue carries a severity (error blocks "all green" status; warning flags content you should review; info is cleanup suggestion). Error-severity issues also feed the accessibility score (see below) and, when Block publishing on errors is turned on, can block a page from going live — see "Blocking publish on errors" below.
| Check | What it catches | Example |
|---|---|---|
| Image alt text | Images uploaded without alt text, or still showing placeholder URLs | Hero image is missing alt text |
| Heading hierarchy | No H1, multiple H1s, skipped levels (H1 → H3 without H2) | This page has no H1 heading |
| Link labels | Links with empty visible text; dead links whose URL is # (the never-filled-in template default renders as a real anchor that goes nowhere) or, in rich text, an empty href |
"View" button has no label · "Get Started" points at "#" |
| Duplicate element IDs | The same id value used on two elements on the same page |
ID "hero" used 3 times — IDs must be unique |
| Accessible link names | Generic phrases ("click here," "read more") that don't make sense out of context; symbol-only labels; a bare URL as the link text (spelled out character by character); two adjacent rich-text links to the same place (announced twice). Suppressed for links the site repairs itself — see below | Link text "Read more" is non-descriptive — use specific text like "Read our pricing" |
| External link indicators | Any link that opens in a new tab without a visible cue like "(opens in new tab)" or an icon — the surprise is the new tab, not the destination, so same-site links are held to it too (new-tab-no-indicator); external ones get the external-link-no-indicator message |
External link with no indicator that it opens elsewhere |
| Color contrast | Text/background combinations that drop below the configured WCAG level's threshold — AA: 4.5:1 (3:1 large text); AAA: 7:1 (4.5:1 large text); Level A skips contrast entirely, since Level A has no contrast success criterion. "Large" is read from the same class string: text-2xl and up, or text-xl with a bold weight. The background is the row's section preset (bg_classes) when one is set — the preset is what paints, and x-dl.section strips any bg-*/text-* the row's own classes carry — else the row's section_classes, else the page body (branding.page_bg_light / _dark). Light and dark mode are graded as separate pairs (dark: variants, the preset's dark background, text_color_dark) whenever the site can show dark mode (branding.dark_mode not light, or the visitor toggle on). Resolves Tailwind palette colors, arbitrary bg-[#hex] values and the site's brand colors (primary/secondary/tone, all 19 shades, via the branding settings' OKLCH values) — opacity-modified colors, gradients and image/video backgrounds are skipped, since the actual painted color isn't known at scan time |
Body text on this background has 3.2:1 contrast — needs 4.5:1. Try "text-primary-800" (7.21:1) instead |
| Alt text quality | Alt text that's present but unhelpful ("image," "photo," filename-only, or starting with a redundant "image of" / "photo of") | Alt text "img-1234" doesn't describe the image |
| File-type link hints | Download links missing format / size cues like "(PDF, 2 MB)" | Link to a .pdf file with no file-type indicator |
| Embed/animation review | Embeds that need a motion/autoplay warning — videos, animations, anything with auto-motion; autoplaying motion with no pause (a section background video, or a video player set to autoplay with its controls hidden) — WCAG 2.2.2 needs a pause, stop or hide control | Auto-playing video — confirm reduced-motion support |
| ARIA attributes | Invalid aria-* attribute names, invalid enum values, invalid roles, broken aria-controls / aria-labelledby / aria-describedby references, aria-hidden on interactive elements |
aria-expanded="maybe" is not a valid value |
| Iframe titles | Embedded <iframe> elements with no (or blank) title attribute |
An embedded map iframe has no title |
| Visible/accessible name mismatch | An element's aria-label doesn't contain its visible label text, so what screen readers announce diverges from what sighted users see |
aria-label="Submit" on a button whose visible text reads "Send Now" |
| Duplicate landmarks | More than one row using the same landmark semantic tag (header, footer, main, nav, aside) on the same page |
Two rows both rendering as a header landmark |
| Snippet hazards | Saved snippets containing <meta http-equiv="refresh">, which auto-redirects the page — a WCAG 2.2.1 fail |
Snippet "Redirect" contains a meta-refresh tag |
Hidden rows, rich text, and the site chrome
Three scoping rules decide what the checks above see:
- Hidden rows are skipped. A row hidden in the editor is written to disk inside an
@if(false)wrap and never renders, so its headings, images and links don't count. (Before 2026-09 a hidden alternate hero still contributed an H1 — a false "Multiple H1" error that the publish gate would have blocked on.) - Rich-text bodies are parsed.
<img>tags without alt text, links with empty or generic text, and heading tags typed into a rich-text or HTML field feed the same image, link and outline checks as component fields do. An<img>inside rich text can't be fixed from the media library inline, so its issue deep-links to the field instead. - The site chrome is scanned once per run. The active header and footer partials (
layout.default_header_slug/_footer_slug) appear as their own "Site header" / "Site footer" entries in a dashboard or scheduled scan, and site-wide findings (today: snippet hazards) appear once under a single "Site-wide" entry instead of being repeated on every page. Neither counts toward "pages scanned"; both count toward the score like any other entry.
Generic link text the site already repairs
The Descriptive link text setting (SEO → Settings, on by default — see seo.md → Link text) makes a generic label descriptive at render time by appending the card's own heading as visually-hidden text. When that applies, the published link has a perfectly good accessible name even though the stored label reads "Learn More", so the scanner suppresses its generic-link-text warning rather than reporting a defect the live page doesn't have. Both sides derive the rule from the same place — DescriptiveLinkText::isGeneric() for the blocklist and titleKeyCandidates() for the heading lookup — so the report and the renderer can't drift apart.
The suppression is deliberately conservative. The scanner can only see stored overrides, while the renderer can additionally see a heading that exists solely as a baked blade default, so a card whose title was never edited still gets flagged even though the live page repairs it. That direction is the safe one: every surviving warning is actionable, and the fix (writing a specific label) is right either way.
WCAG conformance level
Dashboard → Accessibility → Scan settings has a WCAG conformance level select — A, AA, or AAA (default AA; setting accessibility.wcag_level). This changes what "compliant" means for both scanning layers:
- Level A skips the color contrast check entirely — Level A has no contrast success criterion, so flagging it would be a false positive.
- Level AA (the previous fixed behavior) requires 4.5:1 for normal text and 3:1 for large text.
- Level AAA raises the bar to 7:1 for normal text and 4.5:1 for large text — the enhanced-contrast criterion.
The rendered-DOM scanner (Layer 2) honors the same level: it passes the level to axe-core as rule tags, so a Level A scan runs only axe's Level-A success criteria plus best practices, AA adds the AA ruleset (this matches the scanner's previous default behavior), and AAA adds the AAA ruleset — including axe's enhanced-contrast rule.
Every report records the level it was scanned at and shows it on the cover, so a report generated under AA and one generated under AAA are never confused with each other.
Where it runs
Two surfaces share the same check engine:
- Dashboard → Accessibility has a "Run Scan" button that queues every public page and runs every check. Progress streams in real time; results group by page with field-level deep links into the editor.
- In the page editor, the scan runs automatically after every save. Issues for the page you're editing surface in an "Accessibility" modal alongside the editor sidebar. Click an issue → drills into that row's design panel with the bad field highlighted.
How issues are surfaced
Every issue carries:
- A severity — error / warning / info
- A plain-language message — what's wrong and how to fix it
- A pointer to the exact row, field key, and group that produced it (this is what makes the source scanner uniquely actionable)
- A stable fingerprint so the issue persists across re-scans even if rows are reordered
Marking an image as decorative
Some images are purely decorative — a background photo behind a hero, an ornamental icon next to a heading — and shouldn't be announced to screen reader users at all. For those, the <x-dl.image> and <x-dl.media> components expose a "Decorative image (no alt needed)" toggle in the editor's content tab. When it's on:
- The rendered
<img>getsrole="presentation"andaria-hidden="true", so assistive tech skips the image entirely. - The image's alt attribute is forced to empty, regardless of any saved alt value.
- The source scanner's
missing-altcheck accepts the empty alt as intentional.
placeholder-image still fires when decorative is on — a placeholder URL is a real bug regardless of intent, and decorative images should be replaced with real (or empty) media before publishing.
Gallery item shapes also support a per-item decorative boolean key. Set it on individual items in the repeater JSON to opt that one image out of the alt-text check.
Fixing missing alt text with AI
When Dashboard → Accessibility finishes a source scan and any missing-alt issues point at real media-library images, a "Fix missing alt with AI (N)" button appears — gated on AiCapabilities::textProviderSupportsVision(), the same probe every ✨ alt button uses, so it shows on managed-AI installs and on any per-task alt-text override that resolves to a vision-capable model, and hides only for a vision-blind driver. Clicking it:
- Collects every missing-alt issue that maps to a media-library image, deduped so a reused image is only processed once.
- Walks the list one image at a time, generating a concise alt with the same vision-model pipeline the single-image "sparkle" button uses, and saves it straight to the media library.
- Re-grades the affected issues live as each save lands, so the issue list and the score both update without a manual re-scan.
A progress bar tracks saved/failed counts as it works. Canceling partway through keeps whatever's already been saved — nothing is rolled back.
Ignoring intentional exceptions
Sometimes a flagged issue is actually fine — a non-decorative image legitimately has empty alt text in a specific layout, a contrast call is a design decision. The scanner persists per-issue "ignore" choices keyed by fingerprint (page + type + row slug + field key + image path). Re-scanning after an ignore skips the previously-ignored issue. Re-uploading the same image to a different row keeps the image-level ignore.
For decorative images specifically, prefer the Decorative image toggle above over a per-issue ignore — the toggle also emits the correct ARIA on the rendered page, whereas an ignore only suppresses the warning.
There's also a page-level ignore for whole pages where the scanner shouldn't run — useful for system pages (error templates, etc.) that don't represent shippable content. Both ignore lists are reviewable and reversible from the dashboard.
Layer 2 — Rendered-DOM scanner
What it catches that Layer 1 can't
Layer 1 reads row data and never renders anything. That's its strength (it knows about fields) and its limitation (it can't see anything that's computed at render time). Layer 2 fills the gap by loading each public page in a hidden iframe and running axe-core against the final rendered HTML.
axe-core is the industry-standard accessibility engine (the same one Lighthouse and the WAVE extension use internally). It checks roughly 90 WCAG rules covering things Layer 1 doesn't see:
- ARIA validity at the output level —
aria-controlspointing at a non-existent ID after templates conditionally render, malformedaria-labelledbychains, etc. - Duplicate IDs from dynamic sources — Alpine-generated IDs, snippet-introduced IDs.
- Real form label association at the DOM level.
- Focus indicator visibility, touch target size, keyboard traps.
- Computed contrast on gradient backgrounds — Layer 1 can compare two solid colors; axe sees what the browser actually paints.
How it runs
The Accessibility dashboard page has a "Run DOM Scan" button below the Layer 1 controls. Clicking it triggers an Alpine component that iterates the public-pages queue — every static page plus one sample record for each route-parameter template (see below) — opening each one in a hidden iframe, running axe.run() against the iframe's document from the parent context (same-origin → no restriction), and batching results back to the Livewire component.
axe runs inside the frame, not in the dashboard. axe measures the document of the window it was loaded in, and it cannot be retargeted from outside: handing the dashboard's axe the frame's Document is rejected outright (axe.run arguments are invalid — which is what the scanner did on every page from its first release until 2026-09, so Layer 2 recorded a per-page error and never produced a single violation), and handing it {include: [frameDoc.documentElement]} instead resolves against axe's own document, so the scan silently reports the dashboard's accessibility against whichever public page is queued. The scanner therefore loads a fresh copy of axe into each frame by <script src> (same-origin, so it survives the app's script-src 'self' CSP) and runs it there. A side benefit: axe is no longer bundled into the dashboard, which drops that bundle from ~586 KB to ~4 KB.
Two more details of that iframe matter for what axe can see. It sits inside the dashboard viewport (invisible, no pointer events) rather than off-screen, and each page is scrolled top to bottom before axe runs: every entrance animation on the public site starts at opacity: 0 and is revealed by an IntersectionObserver, which in a nested frame only fires for what the top-level viewport can see — a frame parked off-screen never reveals anything, and axe would skip every animated row as invisible. And because the frame is same-origin, the admin's session rides along: the page renders as the signed-in admin sees it (the @auth branches, the footer's "Dashboard" link). The admin edit bar is excluded from the scan by node; everything else is what a signed-in visitor gets, which is the one known divergence from a guest's page.
Rules tagged best-practice run at every level but are not WCAG success criteria; the dashboard and the report label those findings Best practice (a violation with no wcag* tag) so a report titled "WCAG AA" doesn't count them as failures.
Results render in a parallel section grouped by page + violation type. Each violation shows the axe rule, impact (critical / serious / moderate / minor), affected CSS selector, snippet of the offending element, and a "learn more" link to Deque University's per-rule guidance.
Why two separate scanners instead of one
The two layers describe the same concerns from different angles:
| Layer 1 (source) | Layer 2 (DOM) | |
|---|---|---|
| Runs in | PHP | Browser JS |
| Sees | Row data — field values, ARIA in _attrs, declared IDs |
Rendered HTML — actual element output, computed styles, dynamic IDs |
| Issue addresses | Row + field + group (deep-links to editor field) | DOM selector + page attribution |
| Catches | Editor-time mistakes | Runtime-only mistakes |
Merging them would either lose Layer 1's field-level deep-links (its killer feature) or muddle Layer 2's DOM-selector findings. They're rendered in separate sections so the value of each is preserved. Layer 3 reports show both side by side.
Why iframe + axe-core, not a server-side headless browser
axe-core has to run in a real browser (it needs layout, computed styles, focus traversal). Two ways to provide one:
- Iframe in the dashboard tab (what we do) — zero new server dependencies, real Chrome environment, accurate results. Requires a human at the dashboard.
- Headless browser server-side (Playwright/Puppeteer) — could run from CI/cron, but adds a Node + Chrome dep to the server.
For a CMS where an admin runs scans on-demand from the dashboard, the iframe approach is the right trade. Scheduled scans (see below) cover Layer 1 only — axe-core needs a real browser tab to compute layout and paint, which a headless cron job doesn't have. If a scheduled Layer 2 run becomes a real need, a server-side headless browser is the next step.
Layer 3 — Compliance reports
What a report is
A frozen snapshot of the current scan state. When you click Generate Report — or when the weekly scheduled scan runs — the dashboard bundles the current Layer 1 + Layer 2 results into a persisted row in the accessibility_reports table. The payload captures:
- Site branding at generate time (logo URL, name, app URL)
- All Layer 1 row-attributed issues
- All Layer 2 DOM-attributed violations
- The 0–100 accessibility score and the WCAG conformance level the scan ran at
- The trigger that produced the report ("Manual" or "Scheduled")
The report view page renders entirely from that payload. Re-viewing a 6-month-old report shows what the site looked like at scan time, regardless of how the site has changed since. Branding tokens are captured so reports stay stable even after a rebrand.
The report view
/dashboard/accessibility/reports/{id} opens a branded report page. Cover section has the site logo, site name, scan timestamp, the accessibility score as a donut chart, the WCAG conformance level the scan ran at, and severity-broken-down summary cards for both layers. Below that, per-page detail interleaves Layer 1 source findings (with row/field pointers) and Layer 2 DOM findings (with CSS selectors + WCAG references) for each scanned page.
Downloading as PDF
The Download PDF button renders the report server-side with dompdf and streams back a real PDF — no browser print dialog in the way, no rendering differences between browsers to worry about. A Print button remains alongside it as a fallback: it calls the browser's native window.print() against the same print stylesheet (hides nav, sidebars, buttons) for anyone who'd rather use the browser's own print pipeline.
Both paths render from the same report payload, so the PDF and the on-screen view always agree.
Sharing a report with a client
Every report can get a share link — click Share → Create share link on the report view. This mints an opaque 40-character token and a URL at /a11y-report/{token} that renders the same branded report (and its PDF download) with no dashboard login required, so a client, an auditor, or anyone else can see the state of play without an account.
Share links stay live until you revoke them from the same panel; a revoked link 404s immediately. The reports list shows which reports currently have an active share link.
When to generate one
- Before a launch — proof of state for the team / for archive
- Quarterly compliance audits
- When handing a site off to a client — branded, professional record
- Anytime you want a shareable point-in-time record
Generating and revoking reports stays admin-only. Share links (above) are the way to extend read-only access to one specific report without handing out a dashboard account.
Accessibility score
Every report — manual or scheduled — carries a 0–100 accessibility score, computed from Layer 1 (source-scan) findings only. Layer 2's DOM scan needs a real browser tab, which a scheduled cron run doesn't have; keeping the score source-based means a manual report and a scheduled report are always directly comparable on the trend chart, instead of the score swinging depending on whether a human happened to also click "Run DOM Scan" that day.
How it's calculated
Issue types group into ten weighted categories:
| Category | Weight |
|---|---|
| Images | 20 |
| Contrast | 15 |
| ARIA | 15 |
| Headings | 10 |
| Links | 10 |
| Alt quality | 10 |
| Names | 5 |
| Structure | 5 |
| Media | 5 |
| Other | 5 |
Each category scores 100 × (1 − affected ÷ pages scanned), and the total score is the weighted mean across all ten. "Affected" is not a plain page count:
- A page counts with diminishing returns. A page with N distinct issues in a category contributes
1 − 0.5^Nof a page — one issue is half a page, two are three quarters, four is as good as fully affected. A page with forty missing alts no longer scores the same as a page with one, and fixing any single issue always moves the number. - A shared-row issue counts once. Issues are identified without their page (type + row + field + image), so a footer logo with no alt that appears on every page is one issue on the first page it is seen, not a full category wipe-out across the site.
- "Pages scanned" counts the pages the run actually scanned — ignored pages are left out of the denominator, and the site-chrome / site-wide entries are not pages.
A category with no issues scores a full 100; a category where every page carries several distinct issues approaches 0.
Health bands
Scores group into the same three bands as the SEO scanner, so a client comparing both reports reads them the same way:
- ≥ 90 — Healthy
- ≥ 50 — Needs work
- Below 50 — Poor
The score and its band show on the report cover (as a donut chart), the reports list, and the Accessibility dashboard widget card.
Score trend
The Reports tab shows a sparkline across the last 20 scored reports, with the delta versus the previous report and a health-band chip — so you can see at a glance whether the last fix pass moved the needle.
Scheduled scans
Dashboard → Accessibility → Scan settings has a Weekly scheduled scan toggle (setting accessibility.scheduled_enabled, default on). When it's on, a LazyCron-driven accessibility:scan artisan command ticks daily and runs a full Layer 1 source scan once per week, saving a scored report with trigger "Scheduled" (its title carries the scan time in the site's timezone, like a manual report). Pass --force to run one immediately regardless of the weekly window.
Emailing the report
Dashboard → Accessibility → Scan settings has an Email the weekly report switch (accessibility.email_enabled, default off) and a Send to field (blank = every admin). When it's on, the scheduled scan emails the score, the change since the previous report, and the pages carrying the most findings, with a link to the full report.
The email links to the dashboard report, not a share link — a share token exempts its report from the 20-report prune forever, so auto-sharing every weekly scan would grow the table without bound and publish a URL nobody chose to publish. Hand a client a share link deliberately, from the report view.
An agency managing the site can turn this on from its own fleet dashboard. When a site is added to a fleet the agency picks who receives its reports — only the agency, only the client, or both — and that choice arrives inside the head's signed claims. An agency asking for reports counts as the opt-in on its own, so the client's switch does not also have to be found and flipped on every managed site. See fleet-dashboard.md.
Each install also reports its latest score on its license check-in, so the fleet drill-down shows a banded A11y badge per site and the weekly fleet digest ranks every managed site by accessibility score, worst first.
Scheduled runs are source-scan only — see "Why iframe + axe-core, not a server-side headless browser" above for why Layer 2 can't run headlessly on a cron.
To keep the accessibility_reports table from growing unbounded, a scheduled run prunes scheduled reports down to the newest 20 after saving. Reports you generate manually are never auto-pruned, and neither is any report with an active share link (a link handed to a client must not 404 because the weekly scan kept running) — delete those explicitly from the Reports tab.
Blocking publish on errors
Dashboard → Accessibility → Scan settings has a Block publishing on errors toggle (setting accessibility.block_publish_on_errors, default off). Turn it on and a page carrying error-severity Layer 1 issues can't transition into published state:
- In the page editor, saving Page Settings with a
publishedstatus runs a fresh audit — including whatever unsaved edits are in the editor right now — and blocks the save with a validation message if it finds error-severity issues. - From the Pages list, the publish toggle runs the same check before flipping a page live.
The gate only blocks the transition into published. An already-published page can still save other settings (title, SEO, layout, etc.) even while carrying error-severity issues — this stops new violations from shipping without locking editors out of a page that's already live.
Configuration
The scanner lives under Dashboard → Settings → Features → Accessibility Scanner as a single toggle. Default is on. Turning it off hides the dashboard scanner page and stops the in-editor audit; persisted "ignored issues" and historical reports stay in the database (toggling back on restores them).
Dashboard → Accessibility → Scan settings holds the rest of the scanner's configuration:
| Setting | Values | Default | Effect |
|---|---|---|---|
accessibility.wcag_level |
A / AA / AAA |
AA |
Which WCAG conformance level the contrast check and the Layer 2 axe-core ruleset scan against. |
accessibility.scheduled_enabled |
boolean | true |
Whether the weekly scheduled source scan runs. |
accessibility.block_publish_on_errors |
boolean | false |
Whether error-severity issues block a page from transitioning to published. |
accessibility.ignored_pages |
list | — | Pages excluded from scanning entirely. |
accessibility.ignored_issues |
list | — | Individually-ignored issue fingerprints. |
There's no per-check disable yet — checks are all-or-nothing. If a particular check generates persistent false positives for a site, use the "ignore this issue" flow per-instance.
Where the scanner doesn't help
Four things to know:
- Compliance is more than a scanner. A clean scan doesn't mean a site is WCAG-compliant — it means no automated rule fired. Real-world accessibility requires keyboard testing, screen reader testing, and manual review of subjective qualities (label clarity, error message helpfulness, focus order intentionality). The scanner is a high-confidence first pass, not a final verdict.
- Layer 2 currently scans only public, unauthenticated pages. Authenticated pages need iframe credential handling that's deferred. Source-level checks work on every page regardless of access.
- Route-parameter templates (
blog/show,events/show) are scanned through one sample record each — the newest record the template's@previewContextfrontmatter resolves (the same lookup the editor's "Preview as" dropdown uses), so a scan of a 200-post blog checks one post. Layer 1 inspects the template file itself but skips every{item.*}data binding, so per-record content is only ever checked by Layer 2. - Scheduled scans cover Layer 1 only. The weekly cron has no browser tab to run axe-core in, so a scheduled report never includes Layer 2 findings — run "Run DOM Scan" manually from the dashboard whenever you need those.
For developers extending the scanner
Adding a new source-level check
Four-step pattern that mirrors any existing check:
- Add
'mycheck' => 'Human-readable label'to AccessibilityScanner::CHECKS. - Add
'mycheck' => $this->checkMyCheck($resolvedRows),to therunCheck()match. - Add
private function checkMyCheck(array $resolvedRows): arrayreturning a list ofissuearrays withseverity,type,message,row,row_slug,field_key, andgroup. - Add a Pest test calling
scanResolvedRowsForCheck($rows, 'mycheck')with hand-built fixtures covering each issue type.
The dashboard scanner page and EditorAuditActions both iterate AccessibilityScanner::CHECKS automatically — new checks surface in both UIs with no further wiring. The "What we scan for" modal is generated from CheckGuide, which is keyed by the same CHECKS list (a test fails if the two drift), so a new check also needs its card there: icon plus one line per finding it can produce. Issue messages are __() strings with placeholders, so a new one is picked up by the next dashboard translation pass.
The page editor keeps each page's last audit (issues plus the save counters that tell the A11y panel it is stale) in the cache via EditorAuditState, never in the settings table — three Setting rows per page used to be decoded on every public request by Setting::loadAll(). The editor re-runs the audit silently on every load, so a cache miss costs nothing. The "ignore this issue" persistence layer works for any new check type without changes — it keys on the issue fingerprint, not the check name. If you don't also map the new check's issue type to one of the ten score categories in AccessibilityScore, it falls into "Other" (weight 5) by default.
Adding a new DOM-level rule
axe-core ships with the rules we run. Custom rules are possible via axe's axe.configure({ rules: [...] }) API in resources/js/axe-dom-scanner.js but should be reserved for rules that aren't in axe's default ruleset.
Extending the report payload
The report view (⚡report.blade.php) renders entirely from $report->payload. Adding new fields to the payload — e.g. a "comments" field for editor annotations, a "remediation status" field per issue — means adding them to the generateReport() method's payload array and the view template. The migration's payload column is JSON, so schema changes are free.
Related docs
- docs/cms-features/accessible-navigation.md — the built-in WCAG-compliant navigation features the scanner verifies. (No UI surface; just out-of-the-box ARIA behavior in every header / nav / mobile menu.)
- docs/menu-accessibility-audit.md — internal audit / scope doc for the navigation accessibility pass.
- docs/aria-validity-check-scope.md — Phase 1 scope for the ARIA check extension to the source scanner.
- docs/axe-dom-scan-scope.md — Phase 2 scope for the rendered-DOM scanner.
- docs/a11y-report-scope.md — Phase 3 scope for the compliance report.