diff --git a/docs/parity-gallery.html b/docs/parity-gallery.html
new file mode 100644
index 0000000..c2517ba
--- /dev/null
+++ b/docs/parity-gallery.html
@@ -0,0 +1,278 @@
+
+
+honeyDue parity gallery
+
+honeyDue parity gallery
+71 Android · 58 iOS · 34 screens
+
+
+
+
add_residence
+
empty
light
Android missing

+
empty
dark
Android missing

+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+
add_task
+
empty
light
Android missing

+
empty
dark
Android missing

+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+
add_task_with_residence
+
empty
light
Android missing

+
empty
dark
Android missing

+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+
all_tasks
+
empty
light
Android missing

+
empty
dark
Android missing

+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+
contractors_list
+
empty
light
Android missing

+
empty
dark
Android missing

+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+
documents_warranties
+
empty
light
Android missing

+
empty
dark
Android missing

+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+
feature_comparison
+
empty
light
Android missing

+
empty
dark
Android missing

+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+
forgot_password
+
empty
light


+
empty
dark


+
populated
light

iOS missing
+
populated
dark

iOS missing
+
+
+
home
+
empty
light

iOS missing
+
empty
dark

iOS missing
+
populated
light

iOS missing
+
populated
dark

iOS missing
+
+
+
join_residence
+
empty
light
Android missing

+
empty
dark
Android missing

+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+
login
+
empty
light


+
empty
dark


+
populated
light

iOS missing
+
populated
dark

iOS missing
+
+
+
notification_preferences
+
empty
light
Android missing

+
empty
dark
Android missing

+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+
onboarding_create_account
+
empty
light


+
empty
dark


+
populated
light

iOS missing
+
populated
dark

iOS missing
+
+
+
onboarding_first_task
+
empty
light
Android missing

+
empty
dark
Android missing

+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+
onboarding_home_profile
+
empty
light

iOS missing
+
empty
dark

iOS missing
+
populated
light

iOS missing
+
populated
dark

iOS missing
+
+
+
onboarding_join_residence
+
empty
light


+
empty
dark


+
populated
light

iOS missing
+
populated
dark

iOS missing
+
+
+
onboarding_location
+
empty
light

iOS missing
+
empty
dark

iOS missing
+
populated
light

iOS missing
+
populated
dark

iOS missing
+
+
+
onboarding_name_residence
+
empty
light


+
empty
dark


+
populated
light

iOS missing
+
populated
dark

iOS missing
+
+
+
onboarding_subscription
+
empty
light


+
empty
dark


+
populated
light

iOS missing
+
populated
dark

iOS missing
+
+
+
onboarding_value_props
+
empty
light


+
empty
dark


+
populated
light

iOS missing
+
populated
dark

iOS missing
+
+
+
onboarding_verify_email
+
empty
light


+
empty
dark


+
populated
light

iOS missing
+
populated
dark

iOS missing
+
+
+
onboarding_welcome
+
empty
light


+
empty
dark


+
populated
light

iOS missing
+
populated
dark

iOS missing
+
+
+
profile_edit
+
empty
light
Android missing

+
empty
dark
Android missing

+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+
profile_tab
+
empty
light
Android missing

+
empty
dark
Android missing

+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+
register
+
empty
light


+
empty
dark


+
populated
light

iOS missing
+
populated
dark

iOS missing
+
+
+
reset_password
+
empty
light


+
empty
dark


+
populated
light

iOS missing
+
populated
dark

iOS missing
+
+
+
residence_detail
+
empty
light

iOS missing
+
empty
dark

iOS missing
+
populated
light

iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+
residences
+
empty
light

iOS missing
+
empty
dark

iOS missing
+
populated
light

iOS missing
+
populated
dark

iOS missing
+
+
+
residences_list
+
empty
light
Android missing

+
empty
dark
Android missing

+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+
task_suggestions
+
empty
light
Android missing

+
empty
dark
Android missing

+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+
task_templates_browser
+
empty
light
Android missing

+
empty
dark
Android missing

+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+
theme_selection
+
empty
light
Android missing

+
empty
dark
Android missing

+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+
verify_email
+
empty
light


+
empty
dark


+
populated
light

iOS missing
+
populated
dark

iOS missing
+
+
+
verify_reset_code
+
empty
light


+
empty
dark


+
populated
light

iOS missing
+
populated
dark

iOS missing
+
+
+
diff --git a/docs/parity-gallery.md b/docs/parity-gallery.md
new file mode 100644
index 0000000..99d477c
--- /dev/null
+++ b/docs/parity-gallery.md
@@ -0,0 +1,181 @@
+# Parity gallery — iOS ↔ Android snapshot regression
+
+Every primary screen on both platforms is captured as a PNG golden and
+committed to the repo. A PR that drifts from a golden fails CI. The
+committed `docs/parity-gallery.html` pairs iOS and Android side-by-side in
+a scrollable HTML grid you can open locally or from gitea's raw-file view.
+
+## Quick reference
+
+```
+make verify-snapshots # PR gate; fast. Both platforms diff against goldens.
+make record-snapshots # Regenerate everything + optimize. Slow (~5 min).
+make optimize-goldens # Rerun zopflipng over existing PNGs. Idempotent.
+python3 scripts/build_parity_gallery.py # Rebuild docs/parity-gallery.html
+```
+
+## How it works
+
+### Shared fixtures
+`composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/FixtureDataManager.kt`
+exposes `.empty()` and `.populated()` factories. Both platforms render the
+same screens against the same fixture graph — the only cross-platform
+differences left are actual UI code differences (by design). Fixtures use
+a fixed clock (`Fixtures.FIXED_DATE = LocalDate(2026, 4, 15)`) so dates
+never drift.
+
+### Android capture (Roborazzi)
+- `composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/screenshot/ScreenshotTests.kt`
+ declares one `@Test` per surface in `GallerySurfaces.kt`.
+- Each test captures 4 variants: `empty × light`, `empty × dark`,
+ `populated × light`, `populated × dark`.
+- Runs in Robolectric — no emulator needed, no flake from animations.
+- Goldens: `composeApp/src/androidUnitTest/roborazzi/__.png`
+- Typical size: 30–80 KB per image.
+
+### iOS capture (swift-snapshot-testing)
+- `iosApp/HoneyDueTests/SnapshotGalleryTests.swift` has 4 tests per screen.
+- Rendered at `displayScale: 2.0` (not the native 3.0) to cap per-image size.
+- Uses `FixtureDataManager.shared.empty()` / `.populated()` via SKIE.
+- Goldens: `iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_..png`
+- Typical size: 150–300 KB per image after `zopflipng` post-processing.
+
+### Record-mode trigger
+Both platforms record only when explicitly requested:
+- Android: `./gradlew :composeApp:recordRoborazziDebug`
+- iOS: `SNAPSHOT_TESTING_RECORD=1 xcodebuild test …`
+
+`make record-snapshots` does both, plus runs `scripts/optimize_goldens.sh`
+to shrink the output PNGs. No code edits required to switch between record
+and verify — the env var / gradle task controls everything.
+
+## When to record vs verify
+
+**Verify** is what CI runs on every PR. It is the gate. If verify fails,
+ask: *was this drift intentional?*
+
+**Record** is what you run locally when a UI change is deliberate and you
+want to publish the new look as the new baseline. Commit the regenerated
+goldens alongside your code change so reviewers see both the code and the
+visual result in one PR.
+
+Running record by mistake (on a branch where you didn't intend to change
+UI) will produce a large image-diff in `git status`. That diff is the
+signal — revert the goldens, investigate what unintentionally changed.
+
+## Adding a screen to the gallery
+
+### Android
+Add one entry to
+`composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/screenshot/GallerySurfaces.kt`:
+
+```kotlin
+GallerySurface("my_new_screen") { MyNewScreen(onNavigateBack = {}, /* required params from fixtures */) },
+```
+
+If the screen needs a specific model (`task`, `residence`, etc.) pass one
+from `Fixtures.*` — e.g. `Fixtures.tasks.first()`. If the screen renders
+differently in empty vs populated, the `LocalDataManager` provider wiring
+in `ScreenshotTests.kt` handles it automatically.
+
+### iOS
+Add 4 test functions to `iosApp/HoneyDueTests/SnapshotGalleryTests.swift`:
+
+```swift
+func test_myNewScreen_empty_light() { snap("my_new_screen_empty_light", empty: true, dark: false) { MyNewView() } }
+func test_myNewScreen_empty_dark() { snap("my_new_screen_empty_dark", empty: true, dark: true) { MyNewView() } }
+func test_myNewScreen_populated_light() { snap("my_new_screen_populated_light", empty: false, dark: false) { MyNewView() } }
+func test_myNewScreen_populated_dark() { snap("my_new_screen_populated_dark", empty: false, dark: true) { MyNewView() } }
+```
+
+Then `make record-snapshots` to generate goldens, `git add` the PNGs
+alongside your test changes.
+
+## Approving intentional UI drift
+
+```bash
+# 1. Regenerate goldens against your new UI.
+make record-snapshots
+
+# 2. Review the PNG diff — did only the intended screens change?
+git status composeApp/src/androidUnitTest/roborazzi/ iosApp/HoneyDueTests/__Snapshots__/
+git diff --stat composeApp/src/androidUnitTest/roborazzi/ iosApp/HoneyDueTests/__Snapshots__/
+
+# 3. Stage and commit alongside the UI code change.
+git add \
+ composeApp/src/androidUnitTest/roborazzi/ \
+ iosApp/HoneyDueTests/__Snapshots__/
+git commit -m "feat: "
+```
+
+Reviewers see the code diff AND the golden diff in one PR — makes intent
+obvious.
+
+## Image size budget
+
+Per-file soft budget: **400 KB**. Enforced by CI.
+
+Android images are rarely this large. iOS images can exceed 400 KB for
+gradient-heavy screens (Onboarding welcome, organic blob backgrounds).
+If a new screen exceeds budget:
+1. Check whether the screen really needs a full-viewport gradient.
+2. If yes, consider rendering at `displayScale: 1.0` for just that test
+ (the `snap` helper accepts an override).
+
+## Tool installation
+
+The optimizer script needs one of:
+```bash
+brew install zopfli # preferred — better compression
+brew install pngcrush # fallback
+```
+
+Neither installed? `make record-snapshots` warns and skips optimization —
+goldens are still usable, just larger.
+
+## HTML gallery
+
+`docs/parity-gallery.html` is regenerated by
+`scripts/build_parity_gallery.py` whenever goldens change. It's a
+self-contained HTML file with relative `
` paths that resolve within
+the repo — so gitea's raw-file view renders it without any server.
+
+To view locally:
+```bash
+python3 scripts/build_parity_gallery.py
+open docs/parity-gallery.html
+```
+
+The gallery groups by screen name. Each row shows Android vs iOS for one
+{state, mode} combination, with sticky headers for quick navigation.
+
+## Current coverage
+
+Written to the output on each regeneration — check the top of
+`docs/parity-gallery.html` for the current count.
+
+## Known limitations
+
+- **iOS populated-state coverage is partial**. Swift Views today instantiate
+ their ViewModels via `@StateObject viewModel = FooViewModel()`; the
+ ViewModels read `DataManagerObservable.shared` directly rather than
+ accepting an injected `IDataManager`. Until ViewModels gain a DI seam,
+ populated-state snapshots require per-screen ad-hoc workarounds.
+ Tracked as a follow-up.
+
+- **Android detail-screen coverage is partial**. Screens that require a
+ pre-selected model (`ResidenceDetailScreen(residence = ...)`,
+ `ContractorDetailScreen(contractor = ...)`) silently skip rendering
+ unless `GallerySurfaces.kt` passes a fixture item. Expanding these to
+ full coverage is a follow-up PR — low-risk additions to
+ `GallerySurfaces.kt`.
+
+- **Cross-platform diff is visual, not pixel-exact**. SF Pro (iOS) vs
+ SansSerif (Android) render different glyph shapes by design. Pixel-diff
+ is only used within a platform — the HTML gallery is for side-by-side
+ human review.
+
+- **Roborazzi path mismatch**. The historical goldens lived at
+ `composeApp/src/androidUnitTest/roborazzi/`. The Roborazzi Gradle block
+ sets `outputDir` to match. If `verifyRoborazziDebug` ever reports
+ "original file not found", confirm the `outputDir` hasn't drifted.
diff --git a/scripts/build_parity_gallery.py b/scripts/build_parity_gallery.py
new file mode 100755
index 0000000..d9a0e1f
--- /dev/null
+++ b/scripts/build_parity_gallery.py
@@ -0,0 +1,161 @@
+#!/usr/bin/env python3
+"""Build docs/parity-gallery.html pairing iOS + Android goldens per screen.
+
+The output is a single self-contained HTML file that gitea's raw-file view
+can render directly. Relative
paths resolve within the repo so the
+images load without any webserver.
+
+Filename convention (both platforms):
+ __.png
+
+iOS snapshots live under
+ iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/
+with swift-snapshot-testing's `test_..png` prefix which we
+strip to align with Android's plain `.png`.
+
+Usage: python3 scripts/build_parity_gallery.py
+"""
+from __future__ import annotations
+import glob
+import html
+import os
+import re
+import sys
+
+REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ANDROID_DIR = "composeApp/src/androidUnitTest/roborazzi"
+IOS_DIR = "iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests"
+OUT = "docs/parity-gallery.html"
+
+# swift-snapshot-testing names files "test_..png" — strip prefix
+IOS_NAME_RE = re.compile(r"^test_[^.]+\.(.+)\.png$")
+
+
+def canonical_name(platform: str, path: str) -> str | None:
+ """Return the canonical __ key or None if unparseable."""
+ base = os.path.basename(path)
+ if platform == "ios":
+ m = IOS_NAME_RE.match(base)
+ if not m:
+ return None
+ return m.group(1)
+ return base[:-4] if base.endswith(".png") else None
+
+
+def load(platform: str, directory: str) -> dict[str, str]:
+ out: dict[str, str] = {}
+ full = os.path.join(REPO_ROOT, directory)
+ if not os.path.isdir(full):
+ return out
+ for p in glob.glob(f"{full}/**/*.png", recursive=True):
+ key = canonical_name(platform, p)
+ if key is None:
+ continue
+ out[key] = os.path.relpath(p, os.path.join(REPO_ROOT, "docs"))
+ return out
+
+
+def parse_key(key: str) -> tuple[str, str, str]:
+ """(screen, state, mode) — e.g. 'login_empty_dark' → ('login', 'empty', 'dark')."""
+ m = re.match(r"^(.+)_(empty|populated)_(light|dark)$", key)
+ if m:
+ return m.group(1), m.group(2), m.group(3)
+ return key, "?", "?"
+
+
+def main() -> int:
+ android = load("android", ANDROID_DIR)
+ ios = load("ios", IOS_DIR)
+ keys = sorted(set(android) | set(ios))
+ screens = sorted({parse_key(k)[0] for k in keys})
+
+ out_path = os.path.join(REPO_ROOT, OUT)
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
+ with open(out_path, "w", encoding="utf-8") as f:
+ f.write(PAGE_HEAD)
+ f.write(f"{len(android)} Android · {len(ios)} iOS · {len(screens)} screens
\n")
+ f.write("\n")
+ f.write(
+ "\n"
+ )
+ for screen in screens:
+ f.write(f"\n")
+ f.write(f"
{html.escape(screen)}
\n")
+ for state in ("empty", "populated"):
+ for mode in ("light", "dark"):
+ key = f"{screen}_{state}_{mode}"
+ a = android.get(key)
+ i = ios.get(key)
+ a_cell = (
+ f"
})
"
+ if a
+ else "
Android missing
"
+ )
+ i_cell = (
+ f"
})
"
+ if i
+ else "
iOS missing
"
+ )
+ f.write(
+ f"
"
+ f"
{state}
{mode}
"
+ f"{a_cell}{i_cell}"
+ f"
\n"
+ )
+ f.write("
\n")
+ f.write(PAGE_FOOT)
+
+ print(f"wrote {OUT}: {len(screens)} screens, {len(android)} Android + {len(ios)} iOS images")
+ return 0
+
+
+PAGE_HEAD = """
+
+honeyDue parity gallery
+
+honeyDue parity gallery
+"""
+
+PAGE_FOOT = """
+
+"""
+
+
+if __name__ == "__main__":
+ sys.exit(main())