Visual Regression (shared engine)
Screenshot diffing for an iOS app's top-level screens across languages and
light/dark. This skill is the shared engine; each project supplies its own
VisualRegression/config.json and a DEBUG-only in-app harness that renders one
screen in isolation with seeded fixtures — no manual navigation, no network.
Located project = $VR_PROJECT or the current working directory (must contain
VisualRegression/config.json).
Command (run from the project root)
~/.claude/skills/mufeng-ios-visual-regression/run.sh check # after a UI change
~/.claude/skills/mufeng-ios-visual-regression/run.sh baseline # accept current UI as truth
~/.claude/skills/mufeng-ios-visual-regression/run.sh open # reopen last report
~/.claude/skills/mufeng-ios-visual-regression/run.sh init # scaffold a NEW project
Add --build to check/baseline to force a clean xcodebuild first. check
exits non-zero when any screen regresses past diff_threshold_pct.
Per project, everything lives under <project>/VisualRegression/:
config.json — scheme, workspace, bundle_id, source_dir, screens, styles
baselines/<lang>/<style>/<screen>.png — committed source of truth
current/, report/ — transient (gitignore them)
Mandatory workflow after a UI change
After you modify any UI code, BEFORE reporting "done":
- Run
run.sh check from the project root.
- Open
VisualRegression/report/index.html; read summary.json. Every bad
row is a changed region.
- For each regression decide intended vs self-introduced bug. Fix
unintended ones and re-run
check until only intended changes remain.
- Only then report, and show the user the report. Do NOT run
baseline to
bless changes without the user's sign-off.
Onboarding a NEW project
cd <project> && ~/.claude/skills/mufeng-ios-visual-regression/run.sh init.
- Edit
VisualRegression/config.json: scheme, the build container
(workspace for a CocoaPods/.xcworkspace project, or xcodeproj for a
plain .xcodeproj), bundle_id, source_dir (the folder holding your
*.lproj), simulator_name, and screens[].
- Move
VisualRegression/VisualRegressionHarness.swift.template into the app
source (e.g. <App>/DevSupport/VisualRegressionHarness.swift), fill the
registry with your view controllers + light fixtures, add it to the app
target. This step is inherently per-app and hand-written — the harness
knows your VCs, initializers, localization and display-style mechanisms.
- Wire
SceneDelegate (or your window setup) with the #if DEBUG early return
shown in the template.
run.sh baseline, then commit VisualRegression/baselines/.
Determinism notes
- Status bar is frozen (
simctl status_bar override, 9:41).
- The device is restarted once per run and the app's TCC state reset, so no
leftover system permission alert or stale Allow/Deny bleeds into the shots.
A stuck alert is drawn by SpringBoard, so relaunching the app does not clear
it — it dims every screenshot that follows. Opt out per project with
"reboot_simulator": false / "reset_privacy": false.
- A harness that renders a screen touching Photos/Contacts/etc. should avoid the
real framework under VR (fixtures + an
isActive flag), or it will raise the
very prompt that pollutes the run.
- Animations disabled; use fixed dates/fixtures for any date-bearing screen.
Prefer midday UTC — a near-midnight fixture renders a different calendar
day depending on the capturing machine's time zone.
- Use the SAME simulator device+OS for baseline and check (pin via
simulator_name). Pixel diffs are resolution-sensitive.
- Threshold default 0.5% (the home-indicator anti-aliasing is a ~0.2% noise
floor). Raise per project if a screen has unavoidable dynamic content.
- Dead-frame sentinel. Capture races the app: shooting before it is
foregrounded yields the springboard wallpaper, shooting before it renders
yields a flat colour. Both used to be written into
baselines/ silently and
then served as the "correct answer" on every later check. Each shot is now
validated (unique-colour count + mean saturation over the content area);
a bad frame is retried up to twice with a longer settle, and if it still
fails the whole run exits non-zero rather than storing garbage. Opt out with
"dead_frame_check": false. Needs Pillow; the check silently no-ops without it.
Seeing this fire means settle_seconds is too low for the machine — raise it.
Known failure
CLI xcodebuild can hit Embed Pods Frameworks / rsync … Operation not permitted (sandbox). If capture reports a build failure, open the workspace in
Xcode, build once (Cmd-B) for the pinned simulator, then re-run — it reuses the
Xcode-built app.
1---2name: mufeng-ios-visual-regression3description: Visual regression testing for iOS apps (UIKit or SwiftUI). Use after ANY UI change (layout, colors, spacing, fonts, dark-mode, localization strings) to screenshot each top-level screen in every supported language and appearance via the simulator, diff against committed baselines, and produce an HTML report boxing the changed regions. Shared engine; each project supplies its own config + in-app DEBUG harness. Also use to scaffold a new project (`init`) or regenerate baselines. Triggers include "视觉回归", "visual regression", "截图对比", "UI 改动后检查", "screenshot diff", "did my UI change break anything".4---56# Visual Regression (shared engine)78Screenshot diffing for an iOS app's top-level screens across languages and9light/dark. This skill is the **shared engine**; each project supplies its own10`VisualRegression/config.json` and a DEBUG-only in-app harness that renders one11screen in isolation with seeded fixtures — no manual navigation, no network.1213Located project = `$VR_PROJECT` or the current working directory (must contain14`VisualRegression/config.json`).1516## Command (run from the project root)1718```bash19~/.claude/skills/mufeng-ios-visual-regression/run.sh check # after a UI change20~/.claude/skills/mufeng-ios-visual-regression/run.sh baseline # accept current UI as truth21~/.claude/skills/mufeng-ios-visual-regression/run.sh open # reopen last report22~/.claude/skills/mufeng-ios-visual-regression/run.sh init # scaffold a NEW project23```2425Add `--build` to `check`/`baseline` to force a clean `xcodebuild` first. `check`26exits non-zero when any screen regresses past `diff_threshold_pct`.2728Per project, everything lives under `<project>/VisualRegression/`:29- `config.json` — scheme, workspace, bundle_id, source_dir, screens, styles30- `baselines/<lang>/<style>/<screen>.png` — committed source of truth31- `current/`, `report/` — transient (gitignore them)3233## Mandatory workflow after a UI change3435After you modify any UI code, BEFORE reporting "done":36371. Run `run.sh check` from the project root.382. Open `VisualRegression/report/index.html`; read `summary.json`. Every `bad`39 row is a changed region.403. For each regression decide **intended** vs **self-introduced bug**. Fix41 unintended ones and re-run `check` until only intended changes remain.424. Only then report, and show the user the report. Do NOT run `baseline` to43 bless changes without the user's sign-off.4445## Onboarding a NEW project46471. `cd <project> && ~/.claude/skills/mufeng-ios-visual-regression/run.sh init`.482. Edit `VisualRegression/config.json`: `scheme`, the build container49 (`workspace` for a CocoaPods/`.xcworkspace` project, or `xcodeproj` for a50 plain `.xcodeproj`), `bundle_id`, `source_dir` (the folder holding your51 `*.lproj`), `simulator_name`, and `screens[]`.523. Move `VisualRegression/VisualRegressionHarness.swift.template` into the app53 source (e.g. `<App>/DevSupport/VisualRegressionHarness.swift`), fill the54 `registry` with your view controllers + light fixtures, add it to the app55 target. **This step is inherently per-app and hand-written** — the harness56 knows your VCs, initializers, localization and display-style mechanisms.574. Wire `SceneDelegate` (or your window setup) with the `#if DEBUG` early return58 shown in the template.595. `run.sh baseline`, then commit `VisualRegression/baselines/`.6061## Determinism notes6263- Status bar is frozen (`simctl status_bar override`, 9:41).64- The device is restarted once per run and the app's TCC state reset, so no65 leftover system permission alert or stale Allow/Deny bleeds into the shots.66 A stuck alert is drawn by SpringBoard, so relaunching the app does not clear67 it — it dims every screenshot that follows. Opt out per project with68 `"reboot_simulator": false` / `"reset_privacy": false`.69- A harness that renders a screen touching Photos/Contacts/etc. should avoid the70 real framework under VR (fixtures + an `isActive` flag), or it will raise the71 very prompt that pollutes the run.72- Animations disabled; use fixed dates/fixtures for any date-bearing screen.73 Prefer **midday UTC** — a near-midnight fixture renders a different calendar74 day depending on the capturing machine's time zone.75- Use the SAME simulator device+OS for baseline and check (pin via76 `simulator_name`). Pixel diffs are resolution-sensitive.77- Threshold default 0.5% (the home-indicator anti-aliasing is a ~0.2% noise78 floor). Raise per project if a screen has unavoidable dynamic content.79- **Dead-frame sentinel.** Capture races the app: shooting before it is80 foregrounded yields the springboard wallpaper, shooting before it renders81 yields a flat colour. Both used to be written into `baselines/` silently and82 then served as the "correct answer" on every later check. Each shot is now83 validated (unique-colour count + mean saturation over the content area);84 a bad frame is retried up to twice with a longer settle, and if it still85 fails the whole run exits non-zero rather than storing garbage. Opt out with86 `"dead_frame_check": false`. Needs Pillow; the check silently no-ops without it.87 Seeing this fire means `settle_seconds` is too low for the machine — raise it.8889## Known failure9091CLI `xcodebuild` can hit `Embed Pods Frameworks / rsync … Operation not92permitted` (sandbox). If capture reports a build failure, open the workspace in93Xcode, build once (Cmd-B) for the pinned simulator, then re-run — it reuses the94Xcode-built app.