P0.1: iOS reference artifacts (colors, assets, screens inventory)

This commit is contained in:
Trey T
2026-04-18 12:22:41 -05:00
parent a4d66c6ed1
commit 42b7392f39
6 changed files with 1435 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Inventory iOS Asset Catalogs (imagesets + appiconsets) for parity tracking.
Scans both production asset catalogs:
- iosApp/iosApp/Assets.xcassets/
- iosApp/HoneyDue/Assets.xcassets/
Skips build/DerivedData output (PostHog examples etc.).
Output schema:
{
"image_sets": [
{"name": "outline", "path": "...", "files": ["outline.pdf"], "format": "pdf"},
...
],
"app_icons": [
{"name": "AppIcon", "path": "...", "sizes": ["1024x1024", ...]}
],
"widget_assets": [
{ ...same shape as image_sets... }
]
}
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parent.parent
MAIN_XCASSETS = REPO_ROOT / "iosApp" / "iosApp" / "Assets.xcassets"
WIDGET_XCASSETS = REPO_ROOT / "iosApp" / "HoneyDue" / "Assets.xcassets"
OUT_PATH = REPO_ROOT / "docs" / "ios-parity" / "assets.json"
_IMAGE_EXTS = {".pdf", ".png", ".jpg", ".jpeg", ".svg", ".heic"}
def infer_format(files: list[str]) -> str:
exts = {Path(f).suffix.lower().lstrip(".") for f in files}
image_exts = exts & {"pdf", "png", "jpg", "jpeg", "svg", "heic"}
if not image_exts:
return "unknown"
if len(image_exts) == 1:
return next(iter(image_exts))
return "mixed(" + ",".join(sorted(image_exts)) + ")"
def list_asset_files(dir_path: Path) -> list[str]:
out: list[str] = []
for entry in sorted(dir_path.iterdir()):
if entry.is_file() and entry.suffix.lower() in _IMAGE_EXTS:
out.append(entry.name)
return out
def describe_imageset(imageset_dir: Path) -> dict[str, Any]:
name = imageset_dir.name[: -len(".imageset")]
files = list_asset_files(imageset_dir)
return {
"name": name,
"path": str(imageset_dir.relative_to(REPO_ROOT)),
"files": files,
"format": infer_format(files),
}
def describe_appicon(appicon_dir: Path) -> dict[str, Any]:
name = appicon_dir.name[: -len(".appiconset")]
contents = appicon_dir / "Contents.json"
sizes: list[str] = []
files = list_asset_files(appicon_dir)
if contents.is_file():
try:
data = json.loads(contents.read_text(encoding="utf-8"))
except json.JSONDecodeError:
data = {}
for image in data.get("images", []):
size = image.get("size")
scale = image.get("scale")
idiom = image.get("idiom")
if size:
label = size
if scale:
label = f"{label}@{scale}"
if idiom:
label = f"{label} ({idiom})"
sizes.append(label)
return {
"name": name,
"path": str(appicon_dir.relative_to(REPO_ROOT)),
"sizes": sizes,
"files": files,
}
def walk_catalog(root: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
imagesets: list[dict[str, Any]] = []
appicons: list[dict[str, Any]] = []
if not root.is_dir():
return imagesets, appicons
for dirpath, dirnames, _ in root.walk() if hasattr(root, "walk") else _walk(root):
p = Path(dirpath)
if p.name.endswith(".imageset"):
imagesets.append(describe_imageset(p))
dirnames[:] = [] # don't recurse inside
elif p.name.endswith(".appiconset"):
appicons.append(describe_appicon(p))
dirnames[:] = []
imagesets.sort(key=lambda x: x["name"])
appicons.sort(key=lambda x: x["name"])
return imagesets, appicons
def _walk(root: Path):
"""Fallback walker for Python < 3.12 where Path.walk is unavailable."""
import os
for dirpath, dirnames, filenames in os.walk(root):
yield dirpath, dirnames, filenames
def main() -> int:
main_images, main_icons = walk_catalog(MAIN_XCASSETS)
widget_images, widget_icons = walk_catalog(WIDGET_XCASSETS)
output = {
"image_sets": main_images,
"app_icons": main_icons + widget_icons,
"widget_assets": widget_images,
}
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
with OUT_PATH.open("w", encoding="utf-8") as f:
json.dump(output, f, indent=2)
f.write("\n")
print(f"[extract_ios_assets] wrote {OUT_PATH}")
print(
f" image_sets={len(output['image_sets'])} "
f"app_icons={len(output['app_icons'])} "
f"widget_assets={len(output['widget_assets'])}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""Extract color values from iOS Asset Catalogs into a machine-readable JSON file.
Scans two asset catalogs:
- iosApp/iosApp/Assets.xcassets/Colors/** (11 themes x 9 colors)
- iosApp/HoneyDue/Assets.xcassets/** (widget accent + bg)
Each color Contents.json defines up to two `colors` entries:
- one universal (light-mode default)
- one with appearances=[luminosity:dark]
Component values may be:
- float strings "0.000" .. "1.000" -> multiply by 255 and round
- hex strings "0xHH" -> parse as int
sRGB-only. If any display-p3 entry is encountered it is recorded
separately (at the top level) so Android implementers can decide how to
handle them; values are otherwise passed through as-is.
Output schema (see plan in rc-android-ios-parity.md):
{
"displayP3_themes": [ ... optional ... ],
"themes": { <ThemeName>: { <ColorName>: { "light": "#RRGGBB", "dark": "#RRGGBB" } } },
"widget": { <ColorName>: { "light": "#RRGGBB", "dark": "#RRGGBB" } }
}
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parent.parent
COLOR_ROOT = REPO_ROOT / "iosApp" / "iosApp" / "Assets.xcassets" / "Colors"
WIDGET_ROOT = REPO_ROOT / "iosApp" / "HoneyDue" / "Assets.xcassets"
OUT_PATH = REPO_ROOT / "docs" / "ios-parity" / "colors.json"
EXPECTED_THEME_COUNT = 11
EXPECTED_COLORS_PER_THEME = 9
EXPECTED_COLOR_NAMES = {
"Primary",
"Secondary",
"Accent",
"Error",
"BackgroundPrimary",
"BackgroundSecondary",
"TextPrimary",
"TextSecondary",
"TextOnPrimary",
}
def component_to_int(value: str | float | int) -> int:
"""Convert a color component (hex string, float-as-string, or numeric) to 0..255."""
if isinstance(value, (int, float)):
if 0.0 <= float(value) <= 1.0 and not (isinstance(value, int) and value > 1):
return round(float(value) * 255)
return int(value)
s = str(value).strip()
if s.lower().startswith("0x"):
return int(s, 16)
# float like "0.478" or "1.000"
f = float(s)
if 0.0 <= f <= 1.0:
return round(f * 255)
return int(f)
def hex_string(r: int, g: int, b: int, a: float) -> str:
if abs(a - 1.0) < 1e-6:
return f"#{r:02X}{g:02X}{b:02X}"
a_int = round(a * 255) if 0.0 <= a <= 1.0 else int(a)
return f"#{r:02X}{g:02X}{b:02X}{a_int:02X}"
def alpha_value(value: Any) -> float:
if isinstance(value, (int, float)):
return float(value)
s = str(value).strip()
if s.lower().startswith("0x"):
return int(s, 16) / 255.0
return float(s)
def parse_colorset(contents_path: Path) -> tuple[str | None, str | None, str | None]:
"""Return (light_hex, dark_hex, color_space) for a .colorset/Contents.json.
Returns (None, None, None) if the colorset has no color data (e.g. Xcode
placeholder `AccentColor` with only idiom but no components).
"""
with contents_path.open("r", encoding="utf-8") as f:
data = json.load(f)
light = None
dark = None
color_space = None
for entry in data.get("colors", []):
color = entry.get("color")
if not color:
continue
components = color.get("components") or {}
if not components:
continue
color_space = color.get("color-space") or color_space
r = component_to_int(components.get("red", 0))
g = component_to_int(components.get("green", 0))
b = component_to_int(components.get("blue", 0))
a = alpha_value(components.get("alpha", 1.0))
hex_str = hex_string(r, g, b, a)
appearances = entry.get("appearances") or []
is_dark = any(
a.get("appearance") == "luminosity" and a.get("value") == "dark"
for a in appearances
)
if is_dark:
dark = hex_str
else:
light = hex_str
if light is None and dark is None:
return None, None, None
# If one variant is missing, mirror it
if light is None:
light = dark
if dark is None:
dark = light
return light, dark, color_space
def extract_theme_colors() -> tuple[dict[str, dict[str, dict[str, str]]], set[str]]:
themes: dict[str, dict[str, dict[str, str]]] = {}
display_p3_themes: set[str] = set()
if not COLOR_ROOT.is_dir():
raise SystemExit(f"color root not found: {COLOR_ROOT}")
for theme_dir in sorted(COLOR_ROOT.iterdir()):
if not theme_dir.is_dir():
continue
theme_name = theme_dir.name
theme_colors: dict[str, dict[str, str]] = {}
for colorset_dir in sorted(theme_dir.iterdir()):
if not colorset_dir.is_dir() or not colorset_dir.name.endswith(".colorset"):
continue
color_name = colorset_dir.name[: -len(".colorset")]
contents_path = colorset_dir / "Contents.json"
if not contents_path.is_file():
continue
light, dark, cs = parse_colorset(contents_path)
if light is None:
continue
theme_colors[color_name] = {"light": light, "dark": dark}
if cs and "display-p3" in cs.lower():
display_p3_themes.add(theme_name)
if theme_colors:
themes[theme_name] = theme_colors
return themes, display_p3_themes
def extract_widget_colors() -> dict[str, dict[str, str]]:
widget: dict[str, dict[str, str]] = {}
if not WIDGET_ROOT.is_dir():
print(f"[warn] widget asset root missing: {WIDGET_ROOT}", file=sys.stderr)
return widget
for entry in sorted(WIDGET_ROOT.iterdir()):
if not entry.is_dir() or not entry.name.endswith(".colorset"):
continue
color_name = entry.name[: -len(".colorset")]
contents_path = entry / "Contents.json"
if not contents_path.is_file():
continue
light, dark, _ = parse_colorset(contents_path)
if light is None:
# Asset catalog placeholder with no concrete color; skip.
continue
widget[color_name] = {"light": light, "dark": dark}
return widget
def main() -> int:
themes, display_p3 = extract_theme_colors()
widget = extract_widget_colors()
# Validation
errors: list[str] = []
if len(themes) != EXPECTED_THEME_COUNT:
errors.append(
f"Expected {EXPECTED_THEME_COUNT} themes, got {len(themes)}: "
f"{sorted(themes.keys())}"
)
for name, colors in sorted(themes.items()):
if len(colors) != EXPECTED_COLORS_PER_THEME:
errors.append(
f"Theme {name!r} has {len(colors)} colors, expected "
f"{EXPECTED_COLORS_PER_THEME}: {sorted(colors.keys())}"
)
missing = EXPECTED_COLOR_NAMES - set(colors.keys())
extra = set(colors.keys()) - EXPECTED_COLOR_NAMES
if missing:
errors.append(f"Theme {name!r} missing colors: {sorted(missing)}")
if extra:
errors.append(f"Theme {name!r} has unexpected colors: {sorted(extra)}")
if errors:
print("[extract_ios_colors] VALIDATION FAILED:", file=sys.stderr)
for e in errors:
print(f" - {e}", file=sys.stderr)
return 1
output: dict[str, Any] = {}
if display_p3:
output["displayP3_themes"] = sorted(display_p3)
# Preserve key order: Primary, Secondary, Accent, Error, Backgrounds, Text
color_order = [
"Primary",
"Secondary",
"Accent",
"Error",
"BackgroundPrimary",
"BackgroundSecondary",
"TextPrimary",
"TextSecondary",
"TextOnPrimary",
]
ordered_themes: dict[str, dict[str, dict[str, str]]] = {}
# Put Default first if present
theme_order = sorted(themes.keys(), key=lambda n: (n != "Default", n))
for theme_name in theme_order:
ordered_themes[theme_name] = {
cname: themes[theme_name][cname]
for cname in color_order
if cname in themes[theme_name]
}
output["themes"] = ordered_themes
output["widget"] = widget
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
with OUT_PATH.open("w", encoding="utf-8") as f:
json.dump(output, f, indent=2)
f.write("\n")
print(f"[extract_ios_colors] wrote {OUT_PATH}")
print(
f" themes={len(ordered_themes)} "
f"colors/theme={EXPECTED_COLORS_PER_THEME} "
f"widget_entries={len(widget)} "
f"displayP3_themes={sorted(display_p3) if display_p3 else 'none'}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Static inventory of SwiftUI screens in iosApp/iosApp/.
Finds every `struct <Name>View: View { ... }` or `struct <Name>Screen: View { ... }`
declaration across the production iOS source tree (excluding generated/build
dirs) and categorises them by path.
Output schema:
{
"screens": [
{"name": "LoginView", "path": "iosApp/iosApp/Login/LoginView.swift", "category": "auth"},
...
]
}
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parent.parent
SOURCE_ROOT = REPO_ROOT / "iosApp" / "iosApp"
OUT_PATH = REPO_ROOT / "docs" / "ios-parity" / "screens.json"
# Also scan widget target (HoneyDue/) for completeness.
WIDGET_SOURCE_ROOT = REPO_ROOT / "iosApp" / "HoneyDue"
EXCLUDED_DIR_PARTS = {"build", "DerivedData", ".build", "Pods"}
STRUCT_RE = re.compile(
r"^\s*struct\s+([A-Za-z_][A-Za-z0-9_]*(?:View|Screen))\s*:\s*View\s*\{",
re.MULTILINE,
)
CATEGORY_RULES: list[tuple[str, str]] = [
# path-part substring (case-insensitive) -> category
("Login", "auth"),
("Register", "auth"),
("PasswordReset", "auth"),
("VerifyEmail", "auth"),
("Onboarding", "onboarding"),
("Task", "task"),
("Residence", "residence"),
("Document", "document"),
("Contractor", "contractor"),
("Profile", "profile"),
("Subscription", "subscription"),
("Widget", "widget"), # matches WidgetIconView etc. (HoneyDue/)
("HoneyDue", "widget"), # widget target dir
("Shared", "shared"),
("Core", "shared"),
("Subviews", "shared"),
("Dev", "dev"),
("AnimationTesting", "dev"),
]
def category_for(rel_path: Path) -> str:
parts_lower = [p.lower() for p in rel_path.parts]
for needle, cat in CATEGORY_RULES:
if needle.lower() in parts_lower:
return cat
# filename fallback
stem = rel_path.stem.lower()
for needle, cat in CATEGORY_RULES:
if needle.lower() in stem:
return cat
return "shared"
def should_skip(path: Path) -> bool:
return any(part in EXCLUDED_DIR_PARTS for part in path.parts)
def find_swift_files(root: Path) -> list[Path]:
if not root.is_dir():
return []
out: list[Path] = []
for p in root.rglob("*.swift"):
if should_skip(p.relative_to(REPO_ROOT)):
continue
out.append(p)
return sorted(out)
def extract_from(path: Path) -> list[dict[str, Any]]:
try:
text = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
return []
found: list[dict[str, Any]] = []
seen: set[str] = set()
for m in STRUCT_RE.finditer(text):
name = m.group(1)
if name in seen:
continue
seen.add(name)
rel = path.relative_to(REPO_ROOT)
found.append(
{
"name": name,
"path": str(rel),
"category": category_for(rel),
}
)
return found
def main() -> int:
screens: list[dict[str, Any]] = []
for swift_file in find_swift_files(SOURCE_ROOT):
screens.extend(extract_from(swift_file))
for swift_file in find_swift_files(WIDGET_SOURCE_ROOT):
screens.extend(extract_from(swift_file))
# Sort by category then name for stable output.
screens.sort(key=lambda s: (s["category"], s["name"], s["path"]))
output = {"screens": screens}
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
with OUT_PATH.open("w", encoding="utf-8") as f:
json.dump(output, f, indent=2)
f.write("\n")
print(f"[extract_ios_screens] wrote {OUT_PATH}")
print(f" screens={len(screens)}")
# category histogram
hist: dict[str, int] = {}
for s in screens:
hist[s["category"]] = hist.get(s["category"], 0) + 1
for cat, n in sorted(hist.items(), key=lambda kv: (-kv[1], kv[0])):
print(f" {cat}: {n}")
return 0
if __name__ == "__main__":
raise SystemExit(main())