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 <img> 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) <noreply@anthropic.com>
162 lines
6.3 KiB
Python
Executable File
162 lines
6.3 KiB
Python
Executable File
#!/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 <img> paths resolve within the repo so the
|
|
images load without any webserver.
|
|
|
|
Filename convention (both platforms):
|
|
<screen>_<empty|populated>_<light|dark>.png
|
|
|
|
iOS snapshots live under
|
|
iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/
|
|
with swift-snapshot-testing's `test_<name>.<nameArg>.png` prefix which we
|
|
strip to align with Android's plain `<name>.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_<func>.<name>.png" — strip prefix
|
|
IOS_NAME_RE = re.compile(r"^test_[^.]+\.(.+)\.png$")
|
|
|
|
|
|
def canonical_name(platform: str, path: str) -> str | None:
|
|
"""Return the canonical <screen>_<state>_<mode> 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"<div class='meta'>{len(android)} Android · {len(ios)} iOS · {len(screens)} screens</div>\n")
|
|
f.write("<div class='nav'>")
|
|
f.write(" ".join(f"<a href='#{html.escape(s)}'>{html.escape(s)}</a>" for s in screens))
|
|
f.write("</div>\n")
|
|
f.write(
|
|
"<div class='grid-header'>"
|
|
"<div class='label'></div>"
|
|
"<div>Android</div>"
|
|
"<div>iOS</div>"
|
|
"</div>\n"
|
|
)
|
|
for screen in screens:
|
|
f.write(f"<div class='screen' id='{html.escape(screen)}'>\n")
|
|
f.write(f" <h2>{html.escape(screen)}</h2>\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"<img loading='lazy' src='{html.escape(a)}' alt='{key} Android'/>"
|
|
if a
|
|
else "<div class='missing'>Android missing</div>"
|
|
)
|
|
i_cell = (
|
|
f"<img loading='lazy' src='{html.escape(i)}' alt='{key} iOS'/>"
|
|
if i
|
|
else "<div class='missing'>iOS missing</div>"
|
|
)
|
|
f.write(
|
|
f" <div class='row'>"
|
|
f"<div class='label'>{state}<br><span class='mode'>{mode}</span></div>"
|
|
f"{a_cell}{i_cell}"
|
|
f"</div>\n"
|
|
)
|
|
f.write("</div>\n")
|
|
f.write(PAGE_FOOT)
|
|
|
|
print(f"wrote {OUT}: {len(screens)} screens, {len(android)} Android + {len(ios)} iOS images")
|
|
return 0
|
|
|
|
|
|
PAGE_HEAD = """<!DOCTYPE html>
|
|
<html lang='en'><head><meta charset='utf-8'>
|
|
<title>honeyDue parity gallery</title>
|
|
<style>
|
|
:root { color-scheme: dark; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: #0d1117; color: #e6edf3; margin: 0; padding: 20px; }
|
|
h1 { margin: 0 0 4px; font-size: 20px; }
|
|
.meta { color: #8b949e; font-size: 13px; margin-bottom: 16px; }
|
|
.nav { position: sticky; top: 0; background: #0d1117; padding: 8px 0; margin-bottom: 16px;
|
|
border-bottom: 1px solid #30363d; font-size: 12px; z-index: 10; }
|
|
.nav a { color: #79c0ff; margin-right: 10px; text-decoration: none; white-space: nowrap; }
|
|
.nav a:hover { text-decoration: underline; }
|
|
.grid-header { display: grid; grid-template-columns: 120px 1fr 1fr; gap: 12px;
|
|
padding: 0 12px 8px; color: #8b949e; font-size: 12px; font-weight: 600;
|
|
position: sticky; top: 38px; background: #0d1117; z-index: 9;
|
|
border-bottom: 1px solid #30363d; }
|
|
.screen { background: #161b22; border-radius: 8px; padding: 12px; margin-bottom: 20px; }
|
|
.screen h2 { margin: 0 0 8px; font-size: 16px; color: #e6edf3; }
|
|
.row { display: grid; grid-template-columns: 120px 1fr 1fr; gap: 12px;
|
|
margin-bottom: 8px; align-items: start; }
|
|
.label { font-size: 12px; color: #c9d1d9; padding-top: 4px; }
|
|
.label .mode { color: #8b949e; font-weight: 400; }
|
|
.row img { width: 100%; border: 1px solid #30363d; border-radius: 4px; display: block; }
|
|
.missing { display: flex; align-items: center; justify-content: center;
|
|
min-height: 200px; background: #21262d; border: 1px dashed #484f58;
|
|
border-radius: 4px; color: #8b949e; font-size: 12px; }
|
|
</style></head><body>
|
|
<h1>honeyDue parity gallery</h1>
|
|
"""
|
|
|
|
PAGE_FOOT = """<script>
|
|
// Ctrl/Cmd-F friendly: expose screen names in the document title on anchor change.
|
|
window.addEventListener('hashchange', () => {
|
|
const s = location.hash.slice(1);
|
|
document.title = s ? `${s} · parity` : 'honeyDue parity gallery';
|
|
});
|
|
</script>
|
|
</body></html>
|
|
"""
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|