From 707a90e5f135edb9647a427ce6b626e57bd8e393 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sat, 18 Apr 2026 23:45:20 -0500 Subject: [PATCH] P4: HTML parity gallery generator + comprehensive docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/build_parity_gallery.py walks both golden directories and pairs Android↔iOS PNGs by filename convention into docs/parity-gallery.html — a self-contained HTML file with relative paths that renders directly from gitea's raw-file view (no server needed). Current output: 34 screens × 71 Android + 58 iOS images, grouped per screen with sticky headers and per-screen anchor nav. docs/parity-gallery.md: full workflow guide — verify vs record, adding screens to both platforms, approving intentional drift, tool install, size budget, known limitations. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/parity-gallery.html | 278 ++++++++++++++++++++++++++++++++ docs/parity-gallery.md | 181 +++++++++++++++++++++ scripts/build_parity_gallery.py | 161 ++++++++++++++++++ 3 files changed, 620 insertions(+) create mode 100644 docs/parity-gallery.html create mode 100644 docs/parity-gallery.md create mode 100755 scripts/build_parity_gallery.py 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
+ +
Android
iOS
+
+

add_residence

+
empty
light
Android missing
add_residence_empty_light iOS
+
empty
dark
Android missing
add_residence_empty_dark iOS
+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+

add_task

+
empty
light
Android missing
add_task_empty_light iOS
+
empty
dark
Android missing
add_task_empty_dark iOS
+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+

add_task_with_residence

+
empty
light
Android missing
add_task_with_residence_empty_light iOS
+
empty
dark
Android missing
add_task_with_residence_empty_dark iOS
+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+

all_tasks

+
empty
light
Android missing
all_tasks_empty_light iOS
+
empty
dark
Android missing
all_tasks_empty_dark iOS
+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+

contractors_list

+
empty
light
Android missing
contractors_list_empty_light iOS
+
empty
dark
Android missing
contractors_list_empty_dark iOS
+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+

documents_warranties

+
empty
light
Android missing
documents_warranties_empty_light iOS
+
empty
dark
Android missing
documents_warranties_empty_dark iOS
+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+

feature_comparison

+
empty
light
Android missing
feature_comparison_empty_light iOS
+
empty
dark
Android missing
feature_comparison_empty_dark iOS
+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+

forgot_password

+
empty
light
forgot_password_empty_light Androidforgot_password_empty_light iOS
+
empty
dark
forgot_password_empty_dark Androidforgot_password_empty_dark iOS
+
populated
light
forgot_password_populated_light Android
iOS missing
+
populated
dark
forgot_password_populated_dark Android
iOS missing
+
+
+

home

+
empty
light
home_empty_light Android
iOS missing
+
empty
dark
home_empty_dark Android
iOS missing
+
populated
light
home_populated_light Android
iOS missing
+
populated
dark
home_populated_dark Android
iOS missing
+
+
+

join_residence

+
empty
light
Android missing
join_residence_empty_light iOS
+
empty
dark
Android missing
join_residence_empty_dark iOS
+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+

login

+
empty
light
login_empty_light Androidlogin_empty_light iOS
+
empty
dark
login_empty_dark Androidlogin_empty_dark iOS
+
populated
light
login_populated_light Android
iOS missing
+
populated
dark
login_populated_dark Android
iOS missing
+
+
+

notification_preferences

+
empty
light
Android missing
notification_preferences_empty_light iOS
+
empty
dark
Android missing
notification_preferences_empty_dark iOS
+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+

onboarding_create_account

+
empty
light
onboarding_create_account_empty_light Androidonboarding_create_account_empty_light iOS
+
empty
dark
onboarding_create_account_empty_dark Androidonboarding_create_account_empty_dark iOS
+
populated
light
onboarding_create_account_populated_light Android
iOS missing
+
populated
dark
onboarding_create_account_populated_dark Android
iOS missing
+
+
+

onboarding_first_task

+
empty
light
Android missing
onboarding_first_task_empty_light iOS
+
empty
dark
Android missing
onboarding_first_task_empty_dark iOS
+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+

onboarding_home_profile

+
empty
light
onboarding_home_profile_empty_light Android
iOS missing
+
empty
dark
onboarding_home_profile_empty_dark Android
iOS missing
+
populated
light
onboarding_home_profile_populated_light Android
iOS missing
+
populated
dark
onboarding_home_profile_populated_dark Android
iOS missing
+
+
+

onboarding_join_residence

+
empty
light
onboarding_join_residence_empty_light Androidonboarding_join_residence_empty_light iOS
+
empty
dark
onboarding_join_residence_empty_dark Androidonboarding_join_residence_empty_dark iOS
+
populated
light
onboarding_join_residence_populated_light Android
iOS missing
+
populated
dark
onboarding_join_residence_populated_dark Android
iOS missing
+
+
+

onboarding_location

+
empty
light
onboarding_location_empty_light Android
iOS missing
+
empty
dark
onboarding_location_empty_dark Android
iOS missing
+
populated
light
onboarding_location_populated_light Android
iOS missing
+
populated
dark
onboarding_location_populated_dark Android
iOS missing
+
+
+

onboarding_name_residence

+
empty
light
onboarding_name_residence_empty_light Androidonboarding_name_residence_empty_light iOS
+
empty
dark
onboarding_name_residence_empty_dark Androidonboarding_name_residence_empty_dark iOS
+
populated
light
onboarding_name_residence_populated_light Android
iOS missing
+
populated
dark
onboarding_name_residence_populated_dark Android
iOS missing
+
+
+

onboarding_subscription

+
empty
light
onboarding_subscription_empty_light Androidonboarding_subscription_empty_light iOS
+
empty
dark
onboarding_subscription_empty_dark Androidonboarding_subscription_empty_dark iOS
+
populated
light
onboarding_subscription_populated_light Android
iOS missing
+
populated
dark
onboarding_subscription_populated_dark Android
iOS missing
+
+
+

onboarding_value_props

+
empty
light
onboarding_value_props_empty_light Androidonboarding_value_props_empty_light iOS
+
empty
dark
onboarding_value_props_empty_dark Androidonboarding_value_props_empty_dark iOS
+
populated
light
onboarding_value_props_populated_light Android
iOS missing
+
populated
dark
onboarding_value_props_populated_dark Android
iOS missing
+
+
+

onboarding_verify_email

+
empty
light
onboarding_verify_email_empty_light Androidonboarding_verify_email_empty_light iOS
+
empty
dark
onboarding_verify_email_empty_dark Androidonboarding_verify_email_empty_dark iOS
+
populated
light
onboarding_verify_email_populated_light Android
iOS missing
+
populated
dark
onboarding_verify_email_populated_dark Android
iOS missing
+
+
+

onboarding_welcome

+
empty
light
onboarding_welcome_empty_light Androidonboarding_welcome_empty_light iOS
+
empty
dark
onboarding_welcome_empty_dark Androidonboarding_welcome_empty_dark iOS
+
populated
light
onboarding_welcome_populated_light Android
iOS missing
+
populated
dark
onboarding_welcome_populated_dark Android
iOS missing
+
+
+

profile_edit

+
empty
light
Android missing
profile_edit_empty_light iOS
+
empty
dark
Android missing
profile_edit_empty_dark iOS
+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+

profile_tab

+
empty
light
Android missing
profile_tab_empty_light iOS
+
empty
dark
Android missing
profile_tab_empty_dark iOS
+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+

register

+
empty
light
register_empty_light Androidregister_empty_light iOS
+
empty
dark
register_empty_dark Androidregister_empty_dark iOS
+
populated
light
register_populated_light Android
iOS missing
+
populated
dark
register_populated_dark Android
iOS missing
+
+
+

reset_password

+
empty
light
reset_password_empty_light Androidreset_password_empty_light iOS
+
empty
dark
reset_password_empty_dark Androidreset_password_empty_dark iOS
+
populated
light
reset_password_populated_light Android
iOS missing
+
populated
dark
reset_password_populated_dark Android
iOS missing
+
+
+

residence_detail

+
empty
light
residence_detail_empty_light Android
iOS missing
+
empty
dark
residence_detail_empty_dark Android
iOS missing
+
populated
light
residence_detail_populated_light Android
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+

residences

+
empty
light
residences_empty_light Android
iOS missing
+
empty
dark
residences_empty_dark Android
iOS missing
+
populated
light
residences_populated_light Android
iOS missing
+
populated
dark
residences_populated_dark Android
iOS missing
+
+
+

residences_list

+
empty
light
Android missing
residences_list_empty_light iOS
+
empty
dark
Android missing
residences_list_empty_dark iOS
+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+

task_suggestions

+
empty
light
Android missing
task_suggestions_empty_light iOS
+
empty
dark
Android missing
task_suggestions_empty_dark iOS
+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+

task_templates_browser

+
empty
light
Android missing
task_templates_browser_empty_light iOS
+
empty
dark
Android missing
task_templates_browser_empty_dark iOS
+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+

theme_selection

+
empty
light
Android missing
theme_selection_empty_light iOS
+
empty
dark
Android missing
theme_selection_empty_dark iOS
+
populated
light
Android missing
iOS missing
+
populated
dark
Android missing
iOS missing
+
+
+

verify_email

+
empty
light
verify_email_empty_light Androidverify_email_empty_light iOS
+
empty
dark
verify_email_empty_dark Androidverify_email_empty_dark iOS
+
populated
light
verify_email_populated_light Android
iOS missing
+
populated
dark
verify_email_populated_dark Android
iOS missing
+
+
+

verify_reset_code

+
empty
light
verify_reset_code_empty_light Androidverify_reset_code_empty_light iOS
+
empty
dark
verify_reset_code_empty_dark Androidverify_reset_code_empty_dark iOS
+
populated
light
verify_reset_code_populated_light Android
iOS missing
+
populated
dark
verify_reset_code_populated_dark Android
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( + "
" + "
" + "
Android
" + "
iOS
" + "
\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"{key} Android" + if a + else "
Android missing
" + ) + i_cell = ( + f"{key} iOS" + 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())