@@ -55,10 +55,33 @@ type Args = {
5555 sourceBaseUrl ?: string
5656 facets ?: string
5757 appPath ?: string
58+ a11y ?: string
5859}
5960
6061type Meta = Record < string , string >
6162
63+ // Accessibility violations captured by the spec's axe run, keyed by screenshot
64+ // slug (name minus `.png`, e.g. `button-dark`). Written by the `recordA11y`
65+ // Cypress task; see regression-test/cypress.config.ts.
66+ type A11yNode = { target : string ; html : string ; summary : string }
67+ type A11yViolation = {
68+ id : string
69+ impact : string | null
70+ help : string
71+ helpUrl : string
72+ nodes : A11yNode [ ]
73+ }
74+ type A11y = Record < string , A11yViolation [ ] >
75+
76+ /** @internal — exported only for tests; not part of the package's public API. */
77+ export function esc ( s : string ) : string {
78+ return s
79+ . replace ( / & / g, '&' )
80+ . replace ( / < / g, '<' )
81+ . replace ( / > / g, '>' )
82+ . replace ( / " / g, '"' )
83+ }
84+
6285/** @internal — exported only for tests; not part of the package's public API. */
6386export function sourceLinkFor (
6487 name : string ,
@@ -260,12 +283,62 @@ export function thumb(mode: string, name: string): string {
260283 return `<img loading="lazy" src="${ mode } /${ name } " data-name="${ name } " data-mode="${ mode } " class="thumb" />`
261284}
262285
286+ /**
287+ * Build the a11y badge (for the row header) and the collapsible violation list
288+ * (rule, impact, offending selector, and axe's failure summary) for a
289+ * screenshot. Returns empty strings and count 0 when there are no violations.
290+ *
291+ * @internal — exported only for tests; not part of the package's public API.
292+ */
293+ export function a11yFor (
294+ name : string ,
295+ a11y : A11y | null
296+ ) : { badge : string ; details : string ; count : number } {
297+ const slug = name . replace ( / \. p n g $ / , '' )
298+ const violations = a11y ?. [ slug ] ?? [ ]
299+ const count = violations . length
300+ if ( ! count ) return { badge : '' , details : '' , count : 0 }
301+
302+ const plural = count === 1 ? '' : 's'
303+ const badge = `<span class="pill a11y" title="${ count } accessibility violation${ plural } ">⚠ ${ count } a11y</span>`
304+
305+ const items = violations
306+ . map ( ( v ) => {
307+ const impact = v . impact
308+ ? `<span class="a11y-impact ${ esc ( v . impact ) } ">${ esc ( v . impact ) } </span> `
309+ : ''
310+ const rule = v . helpUrl
311+ ? `<a href="${ esc (
312+ v . helpUrl
313+ ) } " target="_blank" rel="noopener"><code>${ esc ( v . id ) } </code></a>`
314+ : `<code>${ esc ( v . id ) } </code>`
315+ const nodes = v . nodes
316+ . map (
317+ ( n ) =>
318+ `<li><code class="sel">${ esc ( n . target ) } </code>${
319+ n . summary
320+ ? `<div class="a11y-summary">${ esc ( n . summary ) } </div>`
321+ : ''
322+ } </li>`
323+ )
324+ . join ( '' )
325+ return `<li>${ impact } ${ rule } — ${ esc (
326+ v . help
327+ ) } <ul class="a11y-nodes">${ nodes } </ul></li>`
328+ } )
329+ . join ( '' )
330+
331+ const details = `<details class="a11y-details"><summary>${ count } accessibility violation${ plural } </summary><ul class="a11y-list">${ items } </ul></details>`
332+ return { badge, details, count }
333+ }
334+
263335function row (
264336 r : Result ,
265337 meta : Meta | null ,
266338 sourceBaseUrl ?: string ,
267339 facets : string [ ] = [ ] ,
268- appPath ?: string
340+ appPath ?: string ,
341+ a11y : A11y | null = null
269342) : string {
270343 const b = r . status === 'added' ? '' : thumb ( 'baseline' , r . name )
271344 const a = r . status === 'removed' ? '' : thumb ( 'actual' , r . name )
@@ -283,14 +356,20 @@ function row(
283356 const htmlUrl =
284357 r . status === 'removed' ? '' : appUrlFor ( r . name , meta , facets , appPath )
285358 const htmlAttr = htmlUrl ? ` data-html-url="${ htmlUrl } "` : ''
359+ const {
360+ badge : a11yBadge ,
361+ details : a11yDetails ,
362+ count : a11yCount
363+ } = a11yFor ( r . name , a11y )
286364 return `
287365 <section class="row" data-status="${ r . status } " data-name="${
288- r . name
289- } " data-has-both="${ hasBoth } "${ htmlAttr } >
366+ r . name
367+ } " data-has-both="${ hasBoth } " data-a11y=" ${ a11yCount } "${ htmlAttr } >
290368 <header><h2>${ r . name } </h2>${ badgeFor (
291- r . status
292- ) } ${ pixelMeta } ${ source } </header>
369+ r . status
370+ ) } ${ a11yBadge } ${ pixelMeta } ${ source } </header>
293371 <div class="grid"><figure><figcaption>Baseline</figcaption>${ b } </figure><figure><figcaption>Actual</figcaption>${ a } </figure><figure><figcaption>Diff</figcaption>${ d } </figure></div>
372+ ${ a11yDetails }
294373 </section>`
295374}
296375
@@ -302,14 +381,15 @@ function renderHtml(
302381 meta ?: Meta | null ,
303382 sourceBaseUrl ?: string ,
304383 facets : string [ ] = [ ] ,
305- appPath ?: string
384+ appPath ?: string ,
385+ a11y : A11y | null = null
306386) : string {
307387 const prBadge =
308388 prNumber && prUrl
309389 ? `<a href="${ prUrl } " style="font-weight:600;color:#0969da;text-decoration:none;">PR #${ prNumber } </a>`
310390 : prNumber
311- ? `<span style="font-weight:600;">PR #${ prNumber } </span>`
312- : ''
391+ ? `<span style="font-weight:600;">PR #${ prNumber } </span>`
392+ : ''
313393 // Land on the "Changed" view by default so reviewers see regressions first,
314394 // but fall back to "All" when nothing changed (otherwise the list is blank).
315395 const defaultFilter = summary . changed > 0 ? 'changed' : 'all'
@@ -318,7 +398,8 @@ function renderHtml(
318398 [ 'changed' , 'Changed' ] ,
319399 [ 'added' , 'New' ] ,
320400 [ 'removed' , 'Removed' ] ,
321- [ 'unchanged' , 'Unchanged' ]
401+ [ 'unchanged' , 'Unchanged' ] ,
402+ [ 'a11y' , '⚠ A11y' ]
322403 ]
323404 return `<!doctype html>
324405<html><head><meta charset="utf-8"><title>Visual regression report</title>
@@ -340,7 +421,19 @@ function renderHtml(
340421 .fail { background: #ffebe9; color: #cf222e; }
341422 .new { background: #ddf4ff; color: #0969da; }
342423 .gone { background: #fff1e5; color: #9a6700; }
424+ .a11y { background: #fbefff; color: #8250df; }
343425 .meta { font-size: 12px; color: #666; }
426+ .a11y-details { padding: 10px 16px; border-top: 1px solid #f0f0f0; font-size: 13px; }
427+ .a11y-details > summary { cursor: pointer; color: #8250df; font-weight: 600; }
428+ .a11y-list { margin: 10px 0 2px; padding-left: 18px; }
429+ .a11y-list > li { margin-bottom: 8px; }
430+ .a11y-nodes { margin: 4px 0 0; padding-left: 16px; list-style: circle; }
431+ .a11y-nodes code.sel { font-size: 12px; color: #57606a; word-break: break-all; }
432+ .a11y-summary { white-space: pre-wrap; color: #666; font-size: 12px; margin: 2px 0 6px; }
433+ .a11y-impact { padding: 1px 6px; border-radius: 8px; font-size: 10px; font-weight: 700; text-transform: uppercase; color: #fff; }
434+ .a11y-impact.critical, .a11y-impact.serious { background: #cf222e; }
435+ .a11y-impact.moderate { background: #9a6700; }
436+ .a11y-impact.minor { background: #57606a; }
344437 .filter { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; }
345438 .filter button { font-size: 12px; padding: 4px 10px; border: 1px solid #ddd; background: #fff; border-radius: 4px; cursor: pointer; }
346439 .filter button.active { background: #0969da; color: #fff; border-color: #0969da; }
@@ -378,7 +471,12 @@ function renderHtml(
378471 <span style="color:#1a7f37;">OK: ${ summary . unchanged } </span>
379472 <span style="color:#cf222e;">Changed: ${ summary . changed } </span>
380473 <span style="color:#0969da;">New: ${ summary . added } </span>
381- <span style="color:#9a6700;">Removed: ${ summary . removed } </span>
474+ <span style="color:#9a6700;">Removed: ${ summary . removed } </span>${
475+ summary . a11yPages
476+ ? `
477+ <span style="color:#8250df;">⚠ A11y: ${ summary . a11yPages } </span>`
478+ : ''
479+ }
382480 </div>
383481 <div class="filter" id="status-filter">
384482 ${ statusFilters
@@ -402,7 +500,7 @@ function renderHtml(
402500 }
403501 </header>
404502 <main>${ results
405- . map ( ( r ) => row ( r , meta ?? null , sourceBaseUrl , facets , appPath ) )
503+ . map ( ( r ) => row ( r , meta ?? null , sourceBaseUrl , facets , appPath , a11y ) )
406504 . join ( '' ) } </main>
407505
408506 <div class="lightbox" id="lb" aria-hidden="true">
@@ -430,7 +528,8 @@ function renderHtml(
430528 function applyFilters() {
431529 const q = listState.query.toLowerCase()
432530 document.querySelectorAll('.row').forEach(r => {
433- const matchesFilter = listState.filter === 'all' || r.dataset.status === listState.filter
531+ const matchesFilter = listState.filter === 'all'
532+ || (listState.filter === 'a11y' ? Number(r.dataset.a11y) > 0 : r.dataset.status === listState.filter)
434533 const matchesQuery = !q || r.dataset.name.toLowerCase().includes(q)
435534 const matchesFacet = listState.facet === 'all' || r.dataset.name.includes('-' + listState.facet + '.')
436535 if (matchesFilter && matchesQuery && matchesFacet) r.removeAttribute('data-hidden')
@@ -664,12 +763,26 @@ function run(args: Args): number {
664763 results . push ( { name, status, numDiff, sizeMismatch } )
665764 }
666765
766+ let a11y : A11y | null = null
767+ if ( args . a11y && existsSync ( args . a11y ) ) {
768+ try {
769+ a11y = JSON . parse ( readFileSync ( args . a11y , 'utf8' ) )
770+ } catch {
771+ a11y = null
772+ }
773+ }
774+ const a11yEntries = a11y
775+ ? Object . values ( a11y ) . filter ( ( v ) => ( v ?. length ?? 0 ) > 0 )
776+ : [ ]
777+
667778 const summary = {
668779 total : results . length ,
669780 unchanged : results . filter ( ( r ) => r . status === 'unchanged' ) . length ,
670781 changed : results . filter ( ( r ) => r . status === 'changed' ) . length ,
671782 added : results . filter ( ( r ) => r . status === 'added' ) . length ,
672- removed : results . filter ( ( r ) => r . status === 'removed' ) . length
783+ removed : results . filter ( ( r ) => r . status === 'removed' ) . length ,
784+ a11yPages : a11yEntries . length ,
785+ a11yViolations : a11yEntries . reduce ( ( n , v ) => n + v . length , 0 )
673786 }
674787
675788 writeFileSync (
@@ -700,7 +813,8 @@ function run(args: Args): number {
700813 meta ,
701814 args . sourceBaseUrl ,
702815 facets ,
703- args . appPath
816+ args . appPath ,
817+ a11y
704818 )
705819 )
706820
@@ -770,6 +884,11 @@ export default {
770884 type : 'string' ,
771885 describe :
772886 'Path (relative to the report root) where the live app is published, e.g. "app". Enables an "HTML" view in the lightbox that iframes the rendered page for each screenshot.'
887+ } ,
888+ a11y : {
889+ type : 'string' ,
890+ describe :
891+ 'Path to a JSON file of axe accessibility violations keyed by screenshot slug. Renders an a11y badge, an "⚠ A11y" filter, and a per-page violation list (rule, impact, selector, help link) in the report.'
773892 }
774893 } ,
775894 handler : ( argv : Args ) => {
0 commit comments