> ## Documentation Index
> Fetch the complete documentation index at: https://docs.soundchecklive.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Agent screenshots on the iOS Simulator

> Take the light, dark and AX5 screenshots a mobile UI PR needs, from your own Metro and a spare simulator

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`:

```bash theme={null}
node scripts/sim/claims.mjs claim --note "ENG-1234 sim check"
open -a Simulator
```

* 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](#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.

```bash theme={null}
EXPO_PUBLIC_E2E_TEST_BUILD=true CI=1 npx expo start --dev-client --port 8085
```

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](https://docs.soundchecklive.io/engineering/mobile/ios-setup.md).

```bash theme={null}
xcrun simctl install <UDID> ios/build/Build/Products/Debug-iphonesimulator/Soundcheck.app
xcrun simctl openurl <UDID> "soundcheck://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8085"
```

Wait for the bundle to finish loading before the next step.

## 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:

```bash theme={null}
npm run sim:e2e-login -- --device <UDID> --as member
```

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:

  ```bash theme={null}
  xcrun simctl status_bar <UDID> override --time 9:41 --batteryState charged --batteryLevel 100 --cellularMode active --cellularBars 4 --wifiBars 3
  ```

  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.

```bash theme={null}
xcrun simctl ui <UDID> appearance light
xcrun simctl io <UDID> screenshot --type=png "$SCRATCH/light.png"
xcrun simctl ui <UDID> appearance dark
xcrun simctl io <UDID> screenshot --type=png "$SCRATCH/dark.png"
```

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.

```bash theme={null}
xcrun simctl ui <UDID> content_size accessibility-extra-extra-extra-large
xcrun simctl openurl <UDID> "soundcheck://events/<eventId>"
xcrun simctl io <UDID> screenshot --type=png "$SCRATCH/ax5.png"
xcrun simctl ui <UDID> content_size large
```

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:

```bash theme={null}
npm run pr:screenshots -- "$SCRATCH/light.png" "$SCRATCH/dark.png" "$SCRATCH/ax5.png"
```

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):

```markdown theme={null}
## Screenshots

| Light | Dark | AX5 |
| --- | --- | --- |
| <img width="300" src="https://github.com/user-attachments/assets/…"> | <img width="300" src="…"> | <img width="300" src="…"> |
```

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.

```bash theme={null}
node scripts/sim/claims.mjs claim --device-class ipad-11 --note "ENG-1234 iPad check"
xcrun simctl install <UDID> <path to Soundcheck.app>
npm run shots -- inventory --device-class ipad-11 --port 8085 --sizes default --out "$SCRATCH/shots"
```

* 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:

  ```bash theme={null}
  xcrun simctl spawn <UDID> defaults write com.apple.launchservices.schemeapproval "com.apple.CoreSimulator.CoreSimulatorBridge-->soundcheck" -string com.soundchecklive.app
  xcrun simctl spawn <UDID> launchctl kickstart -k system/com.apple.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

| Symptom | Cause |
| - | - |
| e2e-login bounces to the login screen | Metro started without `EXPO_PUBLIC_E2E_TEST_BUILD=true`, or the link was sent before the bundle loaded. |
| "Event not found" as crew | Crew cannot open a draft gig. Use an active gig the crew account is on. |
| Typing does nothing or opens the inspector | No field has focus, so keys go to the dev menu shortcuts. |
| The simulator never finishes booting | Some runtimes (iPhone 17 in Oct 2026) fail to boot. Pick another device. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.