@@ -328,7 +328,9 @@ export function a11yFor(
328328 } )
329329 . join ( '' )
330330
331- const details = `<details class="a11y-details"><summary>${ count } accessibility violation${ plural } </summary><ul class="a11y-list">${ items } </ul></details>`
331+ // Expanded by default: the violations are the point of the a11y view, so
332+ // don't make the reviewer click to reveal them.
333+ const details = `<details class="a11y-details" open><summary>${ count } accessibility violation${ plural } </summary><ul class="a11y-list">${ items } </ul></details>`
332334 return { badge, details, count }
333335}
334336
@@ -401,6 +403,17 @@ function renderHtml(
401403 [ 'unchanged' , 'Unchanged' ] ,
402404 [ 'a11y' , '⚠ A11y' ]
403405 ]
406+ // A11y data for the client, keyed by full screenshot name so the lightbox's
407+ // HTML view can outline the offending nodes in the live iframe. `<` is escaped
408+ // so the embedded JSON can't terminate the <script> block.
409+ const a11yByName : Record < string , A11yViolation [ ] > = { }
410+ if ( a11y ) {
411+ for ( const r of results ) {
412+ const v = a11y [ r . name . replace ( / \. p n g $ / , '' ) ]
413+ if ( v && v . length ) a11yByName [ r . name ] = v
414+ }
415+ }
416+ const a11yJson = JSON . stringify ( a11yByName ) . replace ( / < / g, '\\u003c' )
404417 return `<!doctype html>
405418<html><head><meta charset="utf-8"><title>Visual regression report</title>
406419<style>
@@ -449,11 +462,17 @@ function renderHtml(
449462 .lightbox .bar h3 { margin: 0; font-size: 14px; font-weight: 600; flex: 1; }
450463 .lightbox .bar button { background: transparent; color: #eee; border: 1px solid #555; padding: 4px 10px; border-radius: 4px; cursor: pointer; font: inherit; }
451464 .lightbox .bar button.active { background: #fff; color: #111; border-color: #fff; }
452- .lightbox .viewer { flex: 1; overflow: auto; display: flex; align-items: center; justify-content: center; padding: 16px; }
465+ .lightbox .viewer { position: relative; flex: 1; overflow: auto; display: flex; align-items: center; justify-content: center; padding: 16px; }
453466 .lightbox .viewer.actual-size { align-items: flex-start; }
454467 .lightbox .viewer > img { display: block; background: #fff; max-width: 100%; max-height: 100%; object-fit: contain; }
455468 .lightbox .viewer.actual-size > img { max-width: none; max-height: none; image-rendering: pixelated; }
456469 .lightbox .viewer > iframe { width: 100%; height: 100%; border: 0; background: #fff; }
470+ .a11y-legend { position: absolute; top: 12px; left: 12px; z-index: 5; max-width: min(46%, 520px); max-height: 72%; overflow: auto; background: rgba(20,20,20,0.94); color: #eee; border: 1px solid #444; border-radius: 6px; padding: 8px 10px; font-size: 12px; box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
471+ .a11y-legend .hd { font-weight: 700; color: #ffb3b3; margin-bottom: 6px; }
472+ .a11y-legend button { display: block; width: 100%; text-align: left; background: transparent; color: #eee; border: 0; border-top: 1px solid #333; padding: 6px 4px; cursor: pointer; font: inherit; }
473+ .a11y-legend button:hover { background: rgba(255,255,255,0.08); }
474+ .a11y-legend code { color: #ffb3b3; }
475+ .a11y-legend .sel { color: #9fb0c0; word-break: break-all; }
457476 .lightbox .slider { position: relative; user-select: none; background: #fff; flex-shrink: 0; }
458477 .lightbox .slider img { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
459478 .lightbox .slider .top { clip-path: inset(0 0 0 var(--split, 50%)); }
@@ -521,10 +540,60 @@ function renderHtml(
521540 <div class="viewer" id="lb-viewer"></div>
522541 </div>
523542
543+ <script type="application/json" id="a11y-data">${ a11yJson } </script>
524544 <script>
525545 (function () {
526546 const listState = { filter: '${ defaultFilter } ', query: '', facet: 'all' }
527547
548+ // A11y violations keyed by screenshot name (see --a11y). Used to outline
549+ // the offending nodes inside the HTML view's live iframe.
550+ let A11Y = {}
551+ try { A11Y = JSON.parse(document.getElementById('a11y-data').textContent || '{}') } catch (e) {}
552+ function escHtml(s) {
553+ return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
554+ }
555+ function a11yItems(name) {
556+ return (A11Y[name] || []).flatMap(v =>
557+ (v.nodes || []).map(n => ({ id: v.id, impact: v.impact, target: n.target, summary: n.summary }))
558+ )
559+ }
560+ function outlineA11y(iframe, items) {
561+ let doc
562+ try { doc = iframe.contentDocument } catch (e) { return }
563+ if (!doc) return
564+ const paint = () => {
565+ if (doc.head && !doc.getElementById('a11y-hl-style')) {
566+ const st = doc.createElement('style')
567+ st.id = 'a11y-hl-style'
568+ st.textContent = '[data-a11y-hl]{outline:3px solid #cf222e !important;outline-offset:2px !important;}[data-a11y-hl].a11y-flash{box-shadow:0 0 0 6px rgba(207,34,46,0.45)!important;}'
569+ doc.head.appendChild(st)
570+ }
571+ items.forEach((n, i) => {
572+ let els = []
573+ try { els = doc.querySelectorAll(n.target) } catch (e) {}
574+ els.forEach(el => {
575+ el.setAttribute('data-a11y-hl', String(i + 1))
576+ if (!el.getAttribute('title')) el.setAttribute('title', n.id + (n.summary ? ' — ' + n.summary : ''))
577+ })
578+ })
579+ }
580+ // Re-apply after hydration: the app applies the theme in an effect after
581+ // mount, so the target nodes may not exist at iframe 'load' time.
582+ paint()
583+ setTimeout(paint, 500)
584+ }
585+ function locateA11y(iframe, item) {
586+ let doc
587+ try { doc = iframe.contentDocument } catch (e) { return }
588+ if (!doc) return
589+ let el
590+ try { el = doc.querySelector(item.target) } catch (e) {}
591+ if (!el) return
592+ el.scrollIntoView({ block: 'center', inline: 'center' })
593+ el.classList.add('a11y-flash')
594+ setTimeout(() => el.classList.remove('a11y-flash'), 1200)
595+ }
596+
528597 function applyFilters() {
529598 const q = listState.query.toLowerCase()
530599 document.querySelectorAll('.row').forEach(r => {
@@ -653,6 +722,19 @@ function renderHtml(
653722 wrap.addEventListener('touchmove', (e) => { onMove(e); e.preventDefault() }, { passive: false })
654723 } else if (state.mode === 'html') {
655724 lbViewer.innerHTML = '<iframe src="' + htmlUrl + '" title="' + name + '"></iframe>'
725+ const iframe = lbViewer.querySelector('iframe')
726+ const items = a11yItems(name)
727+ if (items.length) {
728+ const legend = document.createElement('div')
729+ legend.className = 'a11y-legend'
730+ legend.innerHTML = '<div class="hd">⚠ ' + items.length + ' a11y violation' + (items.length === 1 ? '' : 's') + ' — click to locate</div>' +
731+ items.map((n, i) => '<button type="button" data-i="' + i + '"><b>' + (i + 1) + '.</b> <code>' + escHtml(n.id) + '</code> <span class="sel">' + escHtml(n.target) + '</span></button>').join('')
732+ lbViewer.appendChild(legend)
733+ legend.querySelectorAll('button').forEach(b => {
734+ b.addEventListener('click', () => locateA11y(iframe, items[+b.dataset.i]))
735+ })
736+ iframe.addEventListener('load', () => outlineA11y(iframe, items))
737+ }
656738 } else {
657739 lbViewer.innerHTML = '<img src="' + state.mode + '/' + name + '" alt="' + name + '" />'
658740 }
0 commit comments