Builds a per-PR visual-diff summary that clusters intentional vs incidental changes across snapshots emitted by Percy, Chromatic, Playwright `toHaveScreenshot`, Storybook test-runner, and other visual testing tools - groups diffs by component / route, separates "intent-aligned with PR scope" from "cascade / regression suspect", surfaces baseline-update recommendations, and emits a single PR comment that points the reviewer at the screenshots that need actual eyes. Use when a PR has 20+ visual diffs and the reviewer needs help triaging which ones to actually open.
74
93%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
A 50-diff visual review causes diff blindness - reviewers rubber-stamp or skip. This skill turns "50 diffs" into "3 components changed as the PR intended; 1 component changed unexpectedly - focus here":
The intent-vs-diff classification mirrors the same logic used by snapshot golden-file management and visual-diff classification - a wrong-but-consistent visual baseline is worse than no baseline at all.
If the PR has 1 - 2 diffs in scoped files, this skill is overkill - the reviewer can open them directly. The value compounds at scale.
Each tool exposes per-snapshot diff data (a diff_ratio per snapshot) via API or local artifact - see references/tool-outputs.md for the source per tool. This skill is downstream of the qa-visual-regression per-tool wrappers and consumes their output.
interface SnapshotDiff {
tool: 'percy' | 'chromatic' | 'playwright' | 'storybook' | 'loki';
storyOrPage: string; // e.g. "Button/with-icon" or "/checkout"
componentOrRoute: string; // derived: "Button" or "checkout"
variant?: string; // e.g. viewport, theme, hover state
beforeImageUrl?: string;
afterImageUrl?: string;
diffImageUrl?: string;
diffRatio: number; // 0..1, fraction of pixels that changed
baselineSnapshotId?: string;
}Cluster keys:
function clusterKey(d: SnapshotDiff): string {
return d.componentOrRoute; // "Button", "checkout", etc.
}The PR's title + description + labels is the stated intent
signal. Pull via gh pr view:
gh pr view --json title,body,labels,files,headRefOidExtract structural intent:
interface Intent {
title: string;
body: string;
labels: string[];
changedFiles: string[]; // src paths
// Derived:
scopeKeywords: string[]; // e.g. ["Button", "checkout", "design-tokens"]
changedComponents: Set<string>; // mapped from changedFiles
}scopeKeywords extraction:
Button" →
["Button"]).area:checkout → ["checkout"].src/components/ or src/routes/ is a component / route name.type Classification = 'aligned' | 'adjacent' | 'unrelated';
function classify(cluster: string, diffs: SnapshotDiff[], intent: Intent): Classification {
if (intent.scopeKeywords.includes(cluster) ||
intent.changedComponents.has(cluster)) {
return 'aligned';
}
// Adjacent: cluster is a child / parent of a changed component (e.g. PR touches
// Modal; Button-inside-Modal also diffs).
if (intent.changedComponents.has(parentOf(cluster)) ||
intent.changedComponents.has(childOf(cluster))) {
return 'adjacent';
}
return 'unrelated';
}parentOf/childOf use the team's component-graph manifest (from
Storybook or a hand-maintained JSON) to identify hierarchy.
Emit a sticky PR comment grouping clusters aligned → adjacent → unrelated, each with a per-cluster recommendation and a "Quick actions" block that auto-approves only the aligned cluster. The full report template (headers, per-cluster tables, quick-action commands) is in references/report-format.md.
The reviewer scans top-down. Order:
Inside each group, sort by max diff ratio descending - biggest visual change first.
Checkpoint before approving: open one sample diff per aligned cluster and confirm it matches the PR's stated intent; only then run the auto-accept command. The summary includes the safe-to-run command for the aligned cluster only - adjacent and unrelated clusters never get an auto-update suggestion.
This matches the adversarial logic of per-snapshot visual-diff classification: aligned diffs go through; unrelated diffs are refused with a recommendation to escalate to a regression bisect.
- name: Fetch tool diff data
run: |
npx chromatic --dry-run --json > visual-diffs.json
- name: Generate summary
run: python scripts/visual_summary.py visual-diffs.json --pr ${{ github.event.pull_request.number }} > summary.md
- name: Post sticky comment
uses: marocchino/sticky-pull-request-comment@v2
with:
header: visual-diff-summary
path: summary.md| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Posting one comment per snapshot | PR conversation is buried; reviewer can't see the forest. | One sticky summary (Step 8); per-snapshot detail in linked tool UI. |
| No intent classification - just listing diffs | Reviewer has to figure out what's expected vs surprise; same problem as no summary. | Aligned / adjacent / unrelated buckets (Step 4). |
| Auto-approving every diff | Regressions silently become baselines. The point of visual tests is defeated. | Auto-approve only aligned cluster (Step 7); refuse unrelated. |
| Sorting by alphabetical name | High-impact diffs sit below low-impact alphabetical earlier-letter ones. | Sort by max diff ratio within each classification group (Step 6). |
| Cross-tool deduplication missing | Same snapshot appears in 3 reports (Percy + Playwright + Chromatic dual-instrumentation). | Deduplicate by (componentOrRoute, variant) cluster key. |
| Reporting unchanged snapshots | Hides the few that did change; reviewer scrolls past. | Report only changed; unchanged count in the summary header. |
| Treating "diff% = 0.1" as a real diff | Anti-aliasing / font rendering jitter; not a visual change. | Threshold (typically diff% > 0.5 or pixel count > 10) before counting. |
parentOf/childOf requires a
curated graph (typical: Storybook tree). Without it, every diff
outside the changed-files set looks unrelated.qa-visual-regression plugin's per-tool wrappers
(percy-visual-regression-testing,
chromatic-visual-regression-testing, playwright-snapshots,
storybook-visual-regression-testing) - the producers of the
upstream diff data this skill consumes.junit-xml-analysis,
coverage-diff-reporter -
sibling PR-summary skills.