Skip to content

Commit 9493349

Browse files
committed
feat(ui-scripts): show a11y violations on the screenshot itself
The a11y section of the visual-diff report was a nested list of axe rule ids, CSS selectors, and raw failureSummary prose, repeated per theme. Nothing was anchored to the image, so the artifact designers actually read - the screenshot - carried no accessibility information at all. Capture: while the page is still rendered, record each violating element's bounding box, a human-readable name, and the contrast numbers axe already computed. a11y.json gains a per-screenshot page size so boxes can be positioned as percentages and hold at any rendered scale. Report: numbered boxes over each offending element on the "Actual" image, colored and ordered by impact; a card per finding leading with plain language, the element's name, a suggested fix, and - for contrast - swatches with the measured and required ratios; a run-level overview grouped by rule that collapses the three themes into one line; impact and rule filters that narrow boxes and cards together; and a lightbox A11y mode pairing the full-size screenshot with its findings. Raw axe output moves behind a Technical details disclosure. The PR comment now names the failing rules in a table instead of pointing at the run logs. Reports generated from a11y.json captured before this change still render: the boxes are omitted, and contrast is recovered by parsing axe's prose.
1 parent d98fe62 commit 9493349

8 files changed

Lines changed: 1522 additions & 179 deletions

File tree

.github/workflows/visual-regression.yml

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ jobs:
125125
run: |
126126
node -e "
127127
const fs = require('fs');
128-
const { summary: s, results } = JSON.parse(fs.readFileSync('regression-test/.report/summary.json', 'utf8'));
128+
const { summary: s, results, a11y = [] } = JSON.parse(fs.readFileSync('regression-test/.report/summary.json', 'utf8'));
129129
const base = 'https://instructure.design/visual-regression/pr-' + process.env.PR_NUMBER;
130130
const interesting = results.filter(r => r.status === 'changed' || r.status === 'removed');
131131
let md = '';
@@ -141,6 +141,17 @@ jobs:
141141
}
142142
md += '</details>';
143143
}
144+
// Name the a11y rules in the comment. Numbers are per offending
145+
// element; the same rule across themes is one row, matching the
146+
// report's overview panel.
147+
let a11yMd = '✅ No violations.';
148+
if (a11y.length) {
149+
a11yMd = '⚠️ **' + s.a11yViolations + ' violation' + (s.a11yViolations === 1 ? '' : 's') +
150+
'** on ' + s.a11yPages + ' screenshot' + (s.a11yPages === 1 ? '' : 's') + '.\n\n' +
151+
'| Impact | Issue | Rule | Elements | Screenshots |\n|---|---|---|---:|---:|\n' +
152+
a11y.map(r => '| ' + r.impact + ' | ' + r.title + ' | [`' + r.rule + '`](' + r.helpUrl + ') | ' +
153+
r.nodes + ' | ' + r.screens + ' |').join('\n');
154+
}
144155
const lines = [
145156
'total=' + s.total,
146157
'unchanged=' + s.unchanged,
@@ -151,6 +162,7 @@ jobs:
151162
];
152163
let out = lines.join('\n') + '\n';
153164
out += 'diff_images<<DIFFIMAGES_EOF\n' + md + '\nDIFFIMAGES_EOF\n';
165+
out += 'a11y<<A11Y_EOF\n' + a11yMd + '\nA11Y_EOF\n';
154166
fs.appendFileSync(process.env.GITHUB_OUTPUT, out);
155167
"
156168
@@ -161,7 +173,7 @@ jobs:
161173
message: |
162174
## Visual regression report
163175
164-
**Cypress assertions (a11y + console errors):** ${{ steps.cypress.outcome == 'failure' && format('❌ **Failing** — see the [run logs]({0}/{1}/actions/runs/{2}).', github.server_url, github.repository, github.run_id) || '✅ Passing' }}
176+
**Cypress suite:** ${{ steps.cypress.outcome == 'failure' && format('❌ **Failing** — see the [run logs]({0}/{1}/actions/runs/{2}).', github.server_url, github.repository, github.run_id) || '✅ Passing' }}
165177
166178
**Visual diff:** ${{ steps.summary.outputs.status }}
167179
@@ -172,11 +184,13 @@ jobs:
172184
| New | ${{ steps.summary.outputs.added }} |
173185
| Removed | ${{ steps.summary.outputs.removed }} |
174186
175-
📊 **[View full report](https://instructure.design/visual-regression/pr-${{ github.event.pull_request.number }}/index.html)**
187+
**Accessibility (axe):** ${{ steps.summary.outputs.a11y }}
188+
189+
📊 **[View full report](https://instructure.design/visual-regression/pr-${{ github.event.pull_request.number }}/index.html)** — every violation is boxed and numbered on its screenshot, with the offending element named and contrast failures shown as color swatches.
176190
177191
${{ steps.summary.outputs.diff_images }}
178192
179-
<sub>Baselines come from the `visual-baselines` branch. They refresh on every merge to `master`. The <b>Cypress assertions</b> line above covers a11y (axe) and console-error checks — a ❌ there means the suite found real issues even if the visual diff is clean.</sub>
193+
<sub>Baselines come from the `visual-baselines` branch. They refresh on every merge to `master`. The <b>Cypress suite</b> line covers the a11y and console-error assertions — a ❌ there means the suite found real issues even if the visual diff is clean.</sub>
180194
181195
- name: Fail if regressions or Cypress failed
182196
if: steps.diff.outcome == 'failure' || steps.cypress.outcome == 'failure'

docs/contributing/testing/visual-regression.md

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -59,35 +59,58 @@ open .report/index.html
5959

6060
`npm run visual-diff` invokes the same `ui-scripts visual-diff` command CI uses. Pass extra flags to override the defaults:
6161

62-
| Flag | Default | Description |
63-
| -------------------------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
64-
| `--actual-dir <dir>` | `cypress/screenshots` | Newly captured screenshots. |
65-
| `--baseline-dir <dir>` | `.baselines` | Baselines to compare against. |
66-
| `--output-dir <dir>` | `visual-report` | Where the HTML report + diff PNGs are written. |
67-
| `--threshold <0..1>` | `0.1` | Per-pixel YIQ color threshold. Lower is stricter. |
68-
| `--fail-on-missing-baseline` / `--no-fail-on-missing-baseline` | on | Whether to exit 1 when actual screenshots have no baseline. |
69-
| `--pr-number <n>` || Rendered in the report header as a link to the PR. |
70-
| `--pr-url <url>` || Target of the PR link. |
71-
| `--meta <file>` || JSON mapping screenshot slug → visited URL path. Enables per-row source-file links in the report. Produced automatically by `spec.cy.ts` via `cy.task('recordMeta', ...)`. |
72-
| `--source-base-url <url>` || GitHub blob URL of `regression-test/src/app` on the branch being reviewed. Combined with `--meta` to build links like `treebrowser/page.tsx`. |
73-
| `--facets <list>` || Comma-separated facet suffixes (e.g. `canvas,light,dark`) rendered as one-click filter chips that match screenshots named `<name>-<facet>`. |
74-
| `--app-path <path>` || Path, relative to the report root, where a static export of the app is published (CI uses `app`). Enables the lightbox **HTML** view, which iframes the live rendered page. |
62+
| Flag | Default | Description |
63+
| -------------------------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
64+
| `--actual-dir <dir>` | `cypress/screenshots` | Newly captured screenshots. |
65+
| `--baseline-dir <dir>` | `.baselines` | Baselines to compare against. |
66+
| `--output-dir <dir>` | `visual-report` | Where the HTML report + diff PNGs are written. |
67+
| `--threshold <0..1>` | `0.1` | Per-pixel YIQ color threshold. Lower is stricter. |
68+
| `--fail-on-missing-baseline` / `--no-fail-on-missing-baseline` | on | Whether to exit 1 when actual screenshots have no baseline. |
69+
| `--pr-number <n>` || Rendered in the report header as a link to the PR. |
70+
| `--pr-url <url>` || Target of the PR link. |
71+
| `--meta <file>` || JSON mapping screenshot slug → visited URL path. Enables per-row source-file links in the report. Produced automatically by `spec.cy.ts` via `cy.task('recordMeta', ...)`. |
72+
| `--source-base-url <url>` || GitHub blob URL of `regression-test/src/app` on the branch being reviewed. Combined with `--meta` to build links like `treebrowser/page.tsx`. |
73+
| `--facets <list>` || Comma-separated facet suffixes (e.g. `canvas,light,dark`) rendered as one-click filter chips that match screenshots named `<name>-<facet>`. |
74+
| `--app-path <path>` || Path, relative to the report root, where a static export of the app is published (CI uses `app`). Enables the lightbox **HTML** view, which iframes the live rendered page. |
75+
| `--a11y <file>` || JSON of axe violations keyed by screenshot slug. Enables everything in [Accessibility findings](#accessibility-findings) below. Produced automatically by `spec.cy.ts` via `cy.task('recordA11y', ...)`. |
7576

7677
### Interpreting the report
7778

7879
The HTML report is a single self-contained page showing one section per screenshot. Each row has a status badge (`ok`, `changed`, `new`, `removed`), a three-up grid of baseline / actual / diff images, and — when metadata is available — a link to the source `page.tsx`.
7980

8081
Top-bar controls:
8182

82-
- **Filter buttons** (`All`, `Changed`, `New`, `Removed`, `Unchanged`) — the active one is highlighted.
83+
- **Filter buttons** (`All`, `Changed`, `New`, `Removed`, `Unchanged`, `⚠ A11y`) — the active one is highlighted.
8384
- **Search input** — live, debounced substring filter on the screenshot name.
85+
- **Theme chips** — narrow to one theme's screenshots.
86+
- **Impact chips** and **Show markers on screenshots** — see below.
8487
- **Lightbox** — click any thumbnail to open a fullscreen viewer. Inside:
8588
- `Baseline`, `Actual`, `Diff` buttons switch between the three images.
8689
- `Slider` mode overlays baseline and actual with a drag handle so you can scrub the boundary back and forth.
90+
- `⚠ N a11y` mode shows the screenshot at full size with every violating element boxed, and the findings docked alongside. Shown only for screenshots with findings.
8791
- `HTML` mode iframes the live rendered page (a static export of the app published next to the report) so you can inspect the real DOM, computed styles, and text alongside the pixels. Shown only when the page still exists and the app was published (`--app-path`).
8892
- `1:1` / `Fit` toggles between native pixel size (scrollable) and fit-to-window.
8993
- `` / `` step through visible rows (respects the current filter).
9094

95+
### Accessibility findings
96+
97+
axe runs against every page and theme, and the report shows what it found **on the picture**, so a designer can act on it without reading a selector.
98+
99+
While the page is still on screen, `spec.cy.ts` records each violating element's bounding box, a human-readable name for it, and — for contrast failures — the foreground/background colors and ratios axe computed (see `cypress/support/a11y-capture.ts`). The report then renders:
100+
101+
- **A run-level overview** at the top of the page: one line per rule with its impact, how many elements it affects, and how many screenshots it appears on. The same rule tripping in `canvas`, `light`, and `dark` collapses to a single line. Click a rule to narrow the list to just the screenshots it affects.
102+
- **Numbered boxes on the “Actual” image**, colored by impact. The numbers match the cards below, so “#3” is the same finding everywhere it appears. Uncheck **Show markers on screenshots** when they get in the way of comparing pixels.
103+
- **One card per offending element**, leading with a plain-language headline ("Text contrast is too low") rather than the rule id, naming the element (`button “Archive this item”`), and suggesting a fix. Contrast failures show both colors as swatches with the measured and required ratios. The raw axe output — rule id, selector, and `failureSummary` — sits behind **Technical details**.
104+
- **Impact chips** (`critical` / `serious` / `moderate` / `minor`) that narrow both the list and the boxes on each image.
105+
106+
Clicking a card's number badge opens the lightbox in **A11y** mode, scrolled to that finding. In that view, clicking a card highlights its box and vice versa.
107+
108+
Findings are ordered worst-impact-first, so `#1` on a screenshot is always the most serious thing on it.
109+
110+
Plain-language copy comes from a rule dictionary in `packages/ui-scripts/lib/commands/visual-diff.ts` (`RULE_COPY`). A rule that isn't in it still renders correctly — it just falls back to axe's own `help` text. Add an entry when a new rule starts showing up.
111+
112+
A page with a known, tracked a11y issue can opt out of the axe check entirely with `a11y: false` and an `a11ySkipReason` in the `PAGES` array in `spec.cy.ts`.
113+
91114
## Adding a new component page
92115

93116
1. Create `regression-test/src/app/<component-name>/page.tsx`. The page component must start with `'use client'` and render meaningful variations of the component you're covering.

0 commit comments

Comments
 (0)