Skip to main content
A PR that changes UI files under app/ or components/ should have a ## Screenshots section with a light and a dark screenshot, plus AX5 when text moves or wraps. The UI Screenshots check (.github/workflows/ui-screenshots.yml, npm run check:pr-screenshots) is advisory:
  • It skips draft PRs.
  • Once a PR is ready, it keeps one reminder comment (<!-- ui-screenshots -->) up to date listing what’s missing, and deletes the comment when the section has light and dark images.
  • It never fails the PR.
Agents hit the same rule earlier: the pr-ready-screenshots hook in scripts/agent-hooks/guard-bash.mjs (Claude Code and Cursor) blocks gh pr ready on a UI PR whose body is missing them, and lists what’s missing. Add the screenshots or the label, then run gh pr ready again. A logic-only change takes the no-ui-change label instead. Before/after pairs make review faster. This recipe gets those screenshots from a local simulator for free, without Revyl.

1. One session per simulator

Several agent sessions run at once. Each one takes its own simulator and its own Metro port, and never drives a simulator another session booted. Claim one. The claim boots a free, shut-down iPhone, records your worktree as its owner in ~/.cache/soundcheck/sim-claims/, and prints its UDID. Use that UDID in every command below instead of booted, including the iOS Simulator tool’s device:
  • It never takes a simulator that is already booted, because another session may be driving it. To use a booted one you know is free, pass --udid <UDID>.
  • It refuses to boot another simulator once 4 are booted or the load average passes 2.5 per core (SOUNDCHECK_MAX_SIMS, SOUNDCHECK_SIM_MAX_LOAD_PER_CORE). Then node scripts/sim/claims.mjs list shows who holds each one. Wait, or release your own.
  • npm run sim:e2e-login and npm run shots use your claim when you leave out --device / --udid, and each use refreshes it. A claim nobody refreshes for 3 hours lapses.
  • It only picks runtimes at or below iOS 26.2, preferring 26.2 itself, because the iOS 27.0 simulators failed to boot and crash-looped SpringBoard. To use a newer runtime, pass --udid <udid> or set SOUNDCHECK_SIM_RUNTIME (for example SOUNDCHECK_SIM_RUNTIME=iOS-27-0). If a boot fails, the claim is released and the simulator shut down; claim another device.
  • --another claims a second simulator, for a two-account check.
  • --device-class ipad-11 or ipad-13 claims an iPad instead (an Air or Pro of that size, the iPad Air first). The default class is phone. A worktree can hold one claim per class, and mine, npm run shots and npm run sim:e2e-login take the same flag to use that class’s claim. Without the flag they use the iPhone claim, or the worktree’s only claim when it holds no iPhone. A class with no simulator installed says so instead of picking another device. Classes live in DEVICE_CLASSES in scripts/sim/claims.mjs; see Other device classes.
  • When you finish, run node scripts/sim/claims.mjs release to free the claim and shut the simulator down. The SessionEnd hook does the same in a worktree (not in the shared main checkout). Claims whose worktree was removed are freed, and their simulators shut down, by the next claim or reap.

2. Your own Metro with the test-build flag

Metro’s default 8081 is shared, so start yours on a spare port. EXPO_PUBLIC_E2E_TEST_BUILD=true is a JS env var: it unlocks the soundcheck://e2e-login route in the ordinary dev client without a new native build. CI=1 keeps Metro non-interactive.
Run it in the background from your worktree, so the bundle is your branch.

3. Install the dev client and point it at your Metro

Reuse a dev-client build whose native fingerprint matches your branch: the Soundcheck.app under ios/build/Build/Products/Debug-iphonesimulator/ in your tree or another tree on the same native deps. Without one, build it as in iOS setup.
Wait for the bundle to finish loading before the next step. The current test accounts live in .env.e2e at the repo root (NEXT_PUBLIC_AUTO_TEST_OWNER_USER for the owner, NEXT_PUBLIC_AUTO_TEST_MEMBER_USER for crew, NEXT_PUBLIC_AUTO_TEST_PASSWORD). The password in web/.env.local can be stale, so don’t use it. The helper reads .env.e2e from your tree, then the main checkout, and only falls back to web/.env.local when neither has one. It opens the link without printing the values:
Use --as user for the owner. Mobile is crew-first, so prefer the crew account unless the change is owner-only. Never paste the credentials into a command line, a log or the PR.

5. Clean the frame

  • Dev gear. The floating Tools button covers headers and send buttons. Open the dev menu (Device → Shake, or Ctrl+Cmd+Z), turn off Tools button, and check the switch really reads off, because it toggles easily.
  • Status bar. Pin a clean status bar:
    Undo it with xcrun simctl status_bar <UDID> clear.

6. Capture light, dark and AX5

The app follows the simulator’s appearance only while its own setting is System. A simulator that was used before may have Light or Dark saved, and then both shots come out in the same theme. Open You → Settings → Appearance (xcrun simctl openurl <UDID> "soundcheck://you/settings/appearance") and pick System first. Open the changed screen with a deep link (for example soundcheck://events/<eventId>), then capture. Look at each PNG before you upload it, and check that the light and dark shots really differ.
For AX5, set the text size first, then mount the screen fresh. React Native does not re-measure text that is already on screen, so changing the size live shows false clipping.
Take the “before” shots the same way on origin/dev (a detached worktree with its own Metro port) when the change is visual.

7. Put them in the PR

GitHub has no API for PR image attachments; drag-and-drop on github.com is the only way to get a user-attachments link. Agents push the PNGs to git instead. Name the files after the mode (light.png, dark.png, ax5.png, or staffing-light.png and so on) and run, from the PR’s worktree:
Keep the PNGs in your session’s scratchpad or the worktree’s gitignored tmp/. The helper refuses files anywhere else, so stray screenshots of dev seed data are never pushed. It works out the repository, branch and merge-base from the directory you run it in, so a worktree whose branch predates the helper can run another checkout’s copy by path: node <checkout>/scripts/pr-screenshots/upload.mjs light.png dark.png. Add --branch <name> to override the branch and --dry-run to print the table without pushing. The helper (scripts/pr-screenshots/upload.mjs) never touches your branch or working tree:
  • It builds a commit on top of the branch’s merge-base with origin/dev that only adds .pr-screenshots/<branch>/<file>, so pre-push plans no checks.
  • It force-pushes that commit to refs/pr-screenshots/<branch>. That ref sits outside refs/heads, so it is not a branch: no workflow runs, no ruleset applies, and a default clone or git fetch never downloads the images. Re-running it replaces the ref. When the PR merges or closes, the PR Screenshots Cleanup workflow deletes the ref. After that, GitHub may garbage-collect the images, so a closed PR’s screenshots are not a permanent record.
  • It prints a ## Screenshots table whose <img> links point at github.com/<repo>/raw/<commit-sha>/…. The SHA keeps old links working after a re-run. Paste the table into the PR body (gh pr edit <n> --body-file …), and keep the Greptile block when you replace the body.
Signed-in repo members see the images in the PR. The check confirms each repo-hosted link resolves through the contents API, so a typo’d or stale path fails it. Repo links must name a full commit SHA: a branch name with a slash cannot be told apart from the file path, so the check rejects raw/<branch>/… links. People can also drag the PNGs into the PR description on github.com, which uploads them as github.com/user-attachments/assets/… links. Lay them out in a labelled table (the template’s example is in an HTML comment, so its labels don’t count):
The check reads only the ## Screenshots section. It ignores HTML comments, code fences and the Greptile block, and it needs the words “light” and “dark” in the section (a table header, caption or alt text).

Other device classes

npm run shots and the claim script take --device-class (phone by default, ipad-11, ipad-13). The phone run is unchanged: same command, same files in <out>/<set>/. Any other class writes the same file names to <out>/<class>-<orientation>/<set>/, for example ipad-11-portrait/inventory/, and refuses a simulator that is not of that class.
  • The dev client is a universal build, so the Soundcheck.app installed on a phone simulator runs on an iPad one. Copy it from xcrun simctl get_app_container <phone UDID> com.soundchecklive.app instead of rebuilding.
  • A simulator that has never opened a soundcheck:// link shows an “Open in Soundcheck?” prompt on every link, and shots then fails with “never made a fresh connection to Metro”. Tap Open once, or approve the scheme and restart SpringBoard:
  • Hide the dev gear once on the new simulator (step 5); the setting is per device. Without a tap: xcrun simctl spawn <UDID> defaults write com.soundchecklive.app EXDevMenuShowFloatingActionButton -bool false before the app’s first launch.
  • The full inventory set on an iPad Air 11-inch, light and dark at the default text size, is 194 shots (34 crew and 63 owner screens, each in two themes). The nightly re-capture stays phone-only.
  • Only portrait exists so far. Each class lists its orientations in DEVICE_CLASSES; --orientation landscape is refused until the class lists it and shots learns to rotate the simulator.
  • A new class (for example a foldable’s folded and unfolded displays) is one DEVICE_CLASSES entry: a label, a simulator-name pattern, preferred names and orientations. Until its simulator is installed, claiming it reports that none is installed.

Troubleshooting