From qa-visual-regression
Builds a CI release-readiness gate for visual regression. Reads diff classifications from the visual-diff-classifier agent and the engine's acceptance log (Percy / Chromatic build approval, Playwright snapshot --update-snapshots commit), refuses to pass when intentional-looking diffs lack explicit acceptance, emits a single go/no-go verdict with markdown + JSON artifact for the CI step.
How this skill is triggered — by the user, by Claude, or both
Slash command
/qa-visual-regression:visual-baseline-gateThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
A typical visual-regression CI run produces an engine-specific verdict
A typical visual-regression CI run produces an engine-specific verdict (Chromatic exit code, Percy build status, Playwright snapshot pass/fail). That's not enough for a strict gate, because:
1
on changes (review needed), Percy returns success but flags the build
pending, Playwright fails the test outright.--update-snapshots
commit is a self-approval; for safety-relevant components it should
require a sign-off.This skill defines a build-an-X workflow that consumes both the diff classifier output and an explicit acceptance log to emit a single go/no-go verdict.
data-quality-gate
for data quality).--update-snapshots commit by the PR author cannot
self-approve a baseline change to a critical component.visual-diff-classifier
output to become CI-blocking rather than advisory.If the project uses one engine only and trusts engine-native review (e.g. all changes go through Chromatic UI approval), prefer the engine's native CI integration - see the matching engine's "CI integration" section in its SKILL.md.
The gate consumes two inputs:
Classification artifact - JSON output from the
visual-diff-classifier
agent, one record per snapshot:
{
"snapshot": "dashboard-mobile-375",
"engine": "playwright",
"category": "intentional|incidental|regression",
"pattern": "text-truncation|...|null",
"paired_change": true,
"diff_url": "playwright-report/data/dashboard-mobile-375-diff.png"
}
Acceptance log - a YAML file at .visual-acceptance.yml
committed by reviewers (NOT the PR author) that explicitly accepts
each intentional baseline change for the current PR:
# .visual-acceptance.yml — committed by reviewers in the PR's review pass
pr: 1234
accepted_by: reviewer-handle
accepted_at: 2026-05-04T12:00:00Z
snapshots:
- snapshot: dashboard-mobile-375
reason: "Intentional CTA color change per design spec DS-456"
- snapshot: pricing-tablet-768
reason: "Intentional pricing tier rename"
The acceptance file lives in the PR branch; merging the PR records the acceptance in git history.
def visual_gate(classifications, acceptance_log, *,
require_reviewer_acceptance=True):
accepted = {s["snapshot"] for s in acceptance_log.get("snapshots", [])}
blockers = []
for c in classifications:
if c["category"] == "regression":
blockers.append((c, "regression — blocks unconditionally"))
elif c["category"] == "intentional" and require_reviewer_acceptance:
if c["snapshot"] not in accepted:
blockers.append((c, "intentional — missing reviewer acceptance"))
elif c["category"] == "incidental":
# incidental requires investigation but does NOT block by default
pass
return {
"verdict": "no-go" if blockers else "go",
"blockers": blockers,
"incidentals": [c for c in classifications if c["category"] == "incidental"],
"intentional_accepted": [c for c in classifications
if c["category"] == "intentional"
and c["snapshot"] in accepted],
}
Default behavior:
.visual-acceptance.yml.For low-risk projects, set require_reviewer_acceptance=False so
intentional changes pass without explicit acceptance - this collapses
the gate to "block on regressions only."
For a stricter gate, validate that the commit adding .visual-acceptance.yml
was authored by someone other than the PR author:
ACCEPTANCE_AUTHOR=$(git log --format='%ae' -1 .visual-acceptance.yml)
PR_AUTHOR=$(gh pr view --json author --jq '.author.login + "@..."')
if [[ "$ACCEPTANCE_AUTHOR" == "$PR_AUTHOR" ]]; then
echo "ERROR: PR author cannot self-approve baseline changes"
exit 1
fi
This is the visual-regression analog of GitHub's "require approval from someone other than the last committer" branch protection.
Markdown summary (matches the
data-quality-gate
shape for cross-domain consistency):
# Visual Baseline Gate — verdict: NO-GO
**Blockers: 2**
| Snapshot | Engine | Category | Reason | Diff |
|---------------------------|------------|-------------|--------------------------------|------|
| dashboard-mobile-375 | playwright | regression | text-truncation | [diff](playwright-report/data/dashboard-mobile-375-diff.png) |
| pricing-desktop-1280 | chromatic | intentional | missing reviewer acceptance | [build](https://chromatic.com/build/...) |
**Incidentals (advisory): 1**
| Snapshot | Engine | Category | Pattern |
|-------------------------|--------|------------|-----------------|
| onboarding-tablet-768 | percy | incidental | anti-aliasing |
**Intentional + accepted: 5**
(see .visual-acceptance.yml for rationale)
Plus a JSON sibling for downstream tooling:
{
"verdict": "no-go",
"blockers": [...],
"incidentals": [...],
"intentional_accepted": [...]
}
A no-go verdict exits non-zero so CI halts.
# scripts/run_visual_gate.py
import json, os, sys, yaml
from pathlib import Path
CLASS_PATH = Path("visual-classifications.json") # output of visual-diff-classifier
ACCEPT_PATH = Path(".visual-acceptance.yml")
if not CLASS_PATH.exists():
print("No visual classifications produced — fail closed.")
sys.exit(1)
classifications = json.loads(CLASS_PATH.read_text())
acceptance = yaml.safe_load(ACCEPT_PATH.read_text()) if ACCEPT_PATH.exists() else {"snapshots": []}
accepted = {s["snapshot"] for s in acceptance.get("snapshots", [])}
blockers = []
for c in classifications:
if c["category"] == "regression":
blockers.append((c, "regression"))
elif c["category"] == "intentional" and c["snapshot"] not in accepted:
blockers.append((c, "missing reviewer acceptance"))
verdict = "no-go" if blockers else "go"
print(f"# Visual Baseline Gate — verdict: {verdict.upper()}")
for c, reason in blockers:
print(f"- {c['engine']} :: {c['snapshot']} :: {reason}")
sys.exit(0 if verdict == "go" else 1)
CI wiring (after each engine has produced its diff manifest, and after
the visual-diff-classifier has produced visual-classifications.json):
- name: Run visual-diff-classifier (advisory)
run: |
# produces visual-classifications.json
...
- name: Visual baseline gate
run: python scripts/run_visual_gate.py
- name: Upload gate artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: visual-baseline-gate
path: |
visual-classifications.json
visual-gate.json
visual-gate.md
retention-days: 14
visual-diff-classifier - produces the classification input.percy-visual-regression-testingchromatic-visual-regression-testingplaywright-snapshotsstorybook-visual-regression-testingvisual-baseline-conventions - the conventions this gate enforces.data-quality-gate - sibling gate skill for data-quality results, same artifact shape.npx claudepluginhub testland/qa --plugin qa-visual-regressionProvides a checklist for code reviews covering functionality, security, performance, maintainability, tests, and quality. Use for pull requests, audits, team standards, and developer training.