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.
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). Thennode scripts/sim/claims.mjs listshows who holds each one. Wait, or release your own. npm run sim:e2e-loginandnpm run shotsuse 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 setSOUNDCHECK_SIM_RUNTIME(for exampleSOUNDCHECK_SIM_RUNTIME=iOS-27-0). If a boot fails, the claim is released and the simulator shut down; claim another device. --anotherclaims a second simulator, for a two-account check.--device-class ipad-11oripad-13claims an iPad instead (an Air or Pro of that size, the iPad Air first). The default class isphone. A worktree can hold one claim per class, andmine,npm run shotsandnpm run sim:e2e-logintake 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 inDEVICE_CLASSESinscripts/sim/claims.mjs; see Other device classes.- When you finish, run
node scripts/sim/claims.mjs releaseto 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 nextclaimorreap.
2. Your own Metro with the test-build flag
Metro’s default8081 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.
3. Install the dev client and point it at your Metro
Reuse a dev-client build whose native fingerprint matches your branch: theSoundcheck.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.
4. Sign in with the e2e-login link
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:
--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.
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 auser-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:
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/devthat 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 outsiderefs/heads, so it is not a branch: no workflow runs, no ruleset applies, and a default clone orgit fetchnever 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
## Screenshotstable whose<img>links point atgithub.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.
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):
## 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.appinstalled on a phone simulator runs on an iPad one. Copy it fromxcrun simctl get_app_container <phone UDID> com.soundchecklive.appinstead of rebuilding. -
A simulator that has never opened a
soundcheck://link shows an “Open in Soundcheck?” prompt on every link, andshotsthen 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 falsebefore the app’s first launch. -
The full
inventoryset 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
portraitexists so far. Each class lists itsorientationsinDEVICE_CLASSES;--orientation landscapeis refused until the class lists it andshotslearns to rotate the simulator. -
A new class (for example a foldable’s folded and unfolded displays) is one
DEVICE_CLASSESentry: a label, a simulator-name pattern, preferred names and orientations. Until its simulator is installed, claiming it reports that none is installed.