Shotlist guide
Making runs identical
Why two runs of the same build should give byte-identical screenshots, what Shotlist pins for you, and how to fix dates, time zones, ordering, seed data, animation, Liquid Glass noise and launch chrome.
Making runs identical
Two runs on the same build should produce byte-identical images. That’s what makes change review useful: when a release’s run marks a cell as changed, something you did changed it. When the app hasn’t changed and cells still change, the cause is almost always one of the things on this page.
How Shotlist compares runs
Every cell records the SHA-256 of its flattened PNG and a 64-bit perceptual hash of the image:
| State | Meaning |
|---|---|
| unchanged | Same SHA-256 as the same cell last run, or a difference that’s rendering noise alone (below). |
| changed | Different. The manifest records the perceptual distance: how many of the 64 bits differ. |
| new | No earlier capture of this cell. |
A perceptual distance of 0 or 1 means the picture looks the same at a glance but some pixels moved: usually ordering, a timestamp, or a value that changes each launch. Larger distances are visible changes.
Before each capture, Shotlist copies the last run’s image to Shotlist/Output/previous/raw/. To see what changed, compare the two files, or select the cell in the Mac app’s Matrix, which shows them side by side or with a swipe divider.
What Shotlist pins for you
| What | How |
|---|---|
| The status bar | 9:41 (or statusBar.time), full battery (statusBar.battery), full Wi-Fi and signal, no carrier name. |
| The status bar’s clock format | Each Shotlist simulator’s region is set to en_US once, so the clock reads “9:41” rather than “09:41”. Your app’s own language and region still come from the run’s locale. |
| The app’s language and region | -AppleLanguages (de-DE) and -AppleLocale de_DE (for de-DE) at every launch. |
| Appearance | Set on the simulator for each batch. |
| Dictionary and Set order | The app is launched with SWIFT_DETERMINISTIC_HASHING=1. |
| The time zone | TZ is set to timeZone from shotlist.json, if you give one. |
| Animations | Turned off: SwiftUI transactions under ShotlistHost, and UIView animations. |
| The caret and keyboard | The caret is hidden in text fields and text views, and the keyboard is dismissed before capture. |
| Scroll indicators | Hidden before capture. |
| The home indicator after launch | The first capture after a launch waits until at least 2.5 s after it. |
| App data | The app is reinstalled at the start of every run. |
now |
context.now and Shotlist.now are the run’s fixedDate. Apps using swift-dependencies get \.date pinned too (below). |
What it can’t pin is your app’s own idea of “now”, order and randomness. The rest of this page is about those.
Dates and the clock
Anything your app shows that depends on the current time (relative dates, “Today” headers, greetings, countdowns, days until a deadline) must come from the run’s fixed date, not Date().
With swift-dependencies: pin the dependencies, and read @Dependency(\.date.now):
ShotlistHost(scenes: Screenshots.scenes.pinningDependencies()) { RootView() }
TCAScene pins its store for you. See Writing scenes.
Without it: route “now” through one injectable clock, and give it context.now in scenes and Shotlist.now in the host’s root:
/// The one place the app asks for "now", so screenshots and tests can fix it.
struct AppClock: Sendable {
let now: @Sendable () -> Date
static let live = AppClock { Date() }
static func fixed(_ date: Date) -> AppClock {
AppClock { date }
}
}
SwiftUIScene("reading") { context in
RootView(router: Router(tab: .reading), clock: .fixed(context.now))
.modelContainer(try container(context))
}
Format relative dates against the clock, not the wall clock:
extension Date {
/// "12 days ago", "in 4 days", "yesterday": relative to the app's clock, not the wall clock.
func relative(to now: Date, locale: Locale) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.locale = locale
formatter.dateTimeStyle = .named
formatter.unitsStyle = .full
return formatter.localizedString(for: self, relativeTo: now)
}
}
Choose a fixedDate that suits your screenshots: a weekday morning, a date your fixture data is built around. If you leave it out, the date is 14 June 2026, 10:30 UTC.
Fixture JSON with absolute dates won’t move relative to a fixed date, but if you change fixedDate, those relative dates change with it. Seeding in Swift, relative to context.now, avoids that.
Time zone
Without a timeZone, the simulator uses your Mac’s time zone, so a run on a Mac in another zone, or in CI, can show different times and even different days. Set it:
"timeZone": "Europe/London"
Use an IANA identifier. It reaches the app as its TZ, so TimeZone.current and every formatter use it.
Ordering
With deterministic hashing, Dictionary and Set iterate in the same order every launch. Sorting still needs care:
- Give every sort a tie-break. Two items with the same date, score or name may otherwise swap places between launches. Sort by a stable identifier last.
- Don’t show items in insertion order from a concurrent source, such as results gathered from parallel tasks.
- Database queries need a full sort descriptor. SwiftData and Core Data don’t promise an order for ties.
Seed data
- Give demo records fixed identifiers.
UUID()in seed data gives new identifiers every launch. If two records share a timestamp and the sort falls back to identifier, they can swap places. - Use fixed values, not random ones, for anything a screen shows: avatars, colours, sample numbers.
- Seed relative to
context.now, so “3 days ago” stays “3 days ago”.
With swift-dependencies, \.uuid is pinned to an incrementing generator, so code that calls @Dependency(\.uuid) produces the same identifiers in the same order each run.
Animation and live content
Shotlist turns off animations, but some content changes on its own:
- spinners and progress indicators;
- looping or Lottie-style animations;
TimelineView, timers and live clocks;- video and animated images;
- shimmer placeholders.
If the screen keeps changing, the capture fails after 13 attempts with “the screen kept changing across 13 captures; something on it is animating (a spinner, a looping animation or a ticking time)”. Usually a spinner means something is still loading: add the fixture or hold it. Otherwise, stop the animation when Shotlist.isActive:
#if DEBUG
let animatesBackground = !Shotlist.isActive
#else
let animatesBackground = true
#endif
Liquid Glass
The simulator renders Liquid Glass a level or two differently from run to run, and the iPhone Duo’s outer display flickers by a level in one corner from frame to frame, with nothing else changed. A literal pixel comparison would fail on almost every screen with a tab bar.
So Shotlist treats a small difference as rendering noise: at most 2 levels in any colour channel, in at most 1% of the pixels. Nobody can see a 2-level difference.
- Between frames (readiness step 4), two noisy frames count as settled.
- Between runs, the cell counts as unchanged, and Shotlist keeps the last run’s file in place of the new one. The bytes don’t churn, so compose and publish see no change either. The manifest records how many pixels differed as
renderingNoise, and the Mac app says so for that cell.
Anything larger, or anything over 1% of the image, is a change.
Launch settle
About 2 s after an app launches, iOS 27 fades out the home indicator and any flashed scroll indicators together. Nothing signals the fade. A fast first scene could pass the two-identical-frames check before the fade or after it, giving two different files from two runs.
So the first capture after each launch keeps taking frames until an identical pair starts at least 2.5 s after the launch. It’s the one wait in Shotlist tied to a duration, and it’s still bounded by the 13-capture limit. Later scenes in the same launch aren’t affected. ShotlistKit also hides scroll indicators before reporting ready, which covers indicators that flash during in-app navigation.
Checking a fix
-
Capture once to set a baseline.
-
Capture again without changing anything, with
--skip-buildso it’s the same build:shotlist capture --skip-build -
Every cell should be unchanged. For any that changed, compare
Shotlist/Output/previous/raw/<cell>.pngwithShotlist/Output/raw/<cell>.png, find the moving part, and fix its cause.
To repeat just the troublesome cells, narrow the run:
shotlist capture --skip-build --slots iphone-6.3 --locales de-DE --scenes library
Common causes
| Symptom | Likely cause | Fix |
|---|---|---|
| A relative date or “Today” changes | Date() called directly |
Route it through context.now, Shotlist.now or \.date. |
| Times differ by hours on another Mac or in CI | No timeZone |
Set timeZone in shotlist.json. |
| Two rows swap places | A sort without a tie-break, or random IDs in seed data | Add a tie-break on a stable identifier; give seed records fixed IDs. |
| Distance 0 or 1, no visible change | Ordering, or a value such as a timestamp in small text | Compare previous/raw with raw at full size. |
| A small change in a tab bar or toolbar only | Glass noise above the threshold | Check that nothing else changed; a material over changing content can vary more than plain glass. |
| The first scene’s home indicator differs | Shouldn’t happen: the launch settle covers it | Make sure nothing on the screen is still animating at launch. |
| “the screen kept changing” | A spinner, loop, timer or live clock | Add the missing fixture or hold, or stop the animation when Shotlist.isActive. |