Skip to content

Commit d98fe62

Browse files
balzssclaude
andcommitted
feat(ui-scripts): highlight a11y violations in the visual-diff report HTML view
Builds on the a11y report data: make the violations visible without hunting. - Expand each row's violation list by default (Tier 1 was collapsed). - In the lightbox HTML view, outline the offending nodes inside the live iframe using the recorded axe selectors, and add a legend that lists each violation and scrolls/flashes its element on click. - Embed the a11y data as a JSON block for the client; same-origin iframe access means no pixel math. Runtime-generated selectors (Emotion hashes, generated ids) resolve because the iframe app and the axe run share one build; if they ever diverge the outline no-ops but the legend and text details still show. Refs: INSTUI-5137 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bf4ec17 commit d98fe62

1 file changed

Lines changed: 84 additions & 2 deletions

File tree

packages/ui-scripts/lib/commands/visual-diff.ts

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/\.png$/, '')]
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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

Comments
 (0)