P4: HTML parity gallery generator + comprehensive docs
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>
This commit is contained in:
161
scripts/build_parity_gallery.py
Executable file
161
scripts/build_parity_gallery.py
Executable file
@@ -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 <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())
|
||||
Reference in New Issue
Block a user