From 9e5a85fe01e94489198a64a458f9b09584594f80 Mon Sep 17 00:00:00 2001 From: cirakg Date: Wed, 28 Oct 2026 00:03:01 +0100 Subject: chore(release): 1.0.0, with the Connect IQ Store listing The second real-watch round on the Forerunner 70 passed: codes from otpauth:// links, the 4-digit PIN, touch scrolling and Aegis JSON import. Version 1.0.0 ships the fixes of the 0.9.0 review. - docs/STORE_LISTING.md: title, description and What's New for the Garmin developer page, with measured character counts. - docs/store/: hero (1440x720), cover (500x500) and seven round screen images, built by tools/store_assets.py from the fr70 goldens. - CHANGELOG 1.0.0, README status, TESTING round 2 results, YOUR_STEPS marked done, fr70 about.png golden at 1.0.0. make ci green on fr70. Co-Authored-By: Claude Opus 5.5 --- tools/store_assets.py | 177 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 tools/store_assets.py (limited to 'tools/store_assets.py') diff --git a/tools/store_assets.py b/tools/store_assets.py new file mode 100644 index 0000000..be7ae9a --- /dev/null +++ b/tools/store_assets.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +# Author: cirakg & claude ai +# Summary: Builds the Connect IQ Store listing images (round screens, cover, hero banner) into docs/store/. +"""Store listing assets from the fr70 golden screenshots and the launcher icon. + +Run from anywhere: python3 tools/store_assets.py. Needs Pillow only. +The launcher icon is 70 px at most, so the lock motif is redrawn here. +""" + +from __future__ import annotations + +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont +from PIL.PngImagePlugin import PngInfo + +ROOT = Path(__file__).resolve().parent.parent +GOLDEN = ROOT / "test" / "golden" / "fr70" +OUT = ROOT / "docs" / "store" + +TEAL = (0, 160, 160) +ICON_TEAL = (20, 130, 140) +WHITE = (255, 255, 255) +SS = 4 # supersampling factor + +SCREENS = [ + ("1-list", "list"), + ("2-otp", "otp"), + ("3-qr", "code_qr"), + ("4-glance", "glance_code"), + ("5-pin", "pin"), + ("6-pdf417", "code_pdf417"), + ("7-import", "import_results"), +] + +FONT_BOLD = [ + "/usr/share/fonts/TTF/Roboto-Bold.ttf", + "/usr/share/fonts/roboto/Roboto-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", + "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", +] +FONT_REG = [ + "/usr/share/fonts/TTF/Roboto-Regular.ttf", + "/usr/share/fonts/roboto/Roboto-Regular.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/dejavu/DejaVuSans.ttf", +] + + +def font(paths: list[str], size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + for p in paths: + if Path(p).exists(): + return ImageFont.truetype(p, size) + return ImageFont.load_default(size) + + +def round_mask(size: int) -> Image.Image: + m = Image.new("L", (size * SS, size * SS), 0) + ImageDraw.Draw(m).ellipse((0, 0, size * SS - 1, size * SS - 1), fill=255) + return m.resize((size, size), Image.Resampling.LANCZOS) + + +def round_screen(name: str) -> Image.Image: + im = Image.open(GOLDEN / f"{name}.png").convert("RGBA") + im.putalpha(round_mask(im.width)) + return im + + +def lock_icon(size: int) -> Image.Image: + """The launcher motif (teal disc, white padlock with three dots), redrawn.""" + s = size * SS + im = Image.new("RGBA", (s, s), (0, 0, 0, 0)) + d = ImageDraw.Draw(im) + u = s / 70.0 + d.ellipse((0, 0, s - 1, s - 1), fill=ICON_TEAL) + # shackle + d.rounded_rectangle((24 * u, 12 * u, 46 * u, 40 * u), radius=11 * u, fill=WHITE) + d.rounded_rectangle((29 * u, 18 * u, 41 * u, 44 * u), radius=6 * u, fill=ICON_TEAL) + # body + d.rounded_rectangle((17 * u, 31 * u, 53 * u, 57 * u), radius=4 * u, fill=WHITE) + for cx in (26, 35, 44): + d.ellipse((cx * u - 2.4 * u, 44 * u - 2.4 * u, cx * u + 2.4 * u, 44 * u + 2.4 * u), fill=ICON_TEAL) + return im.resize((size, size), Image.Resampling.LANCZOS) + + +Font = ImageFont.FreeTypeFont | ImageFont.ImageFont + + +def centered_text(d: ImageDraw.ImageDraw, at: tuple[int, int], text: str, f: Font, fill: tuple[int, int, int]) -> None: + cx, y = at + w = d.textlength(text, font=f) + d.text((cx - w / 2, y), text, font=f, fill=fill) + + +def cover() -> Image.Image: + im = Image.new("RGB", (500, 500), (0, 0, 0)) + d = ImageDraw.Draw(im) + icon = lock_icon(300) + im.paste(icon, (100, 60), icon) + centered_text(d, (250, 392), "OTP Wallet", font(FONT_BOLD, 56), WHITE) + d.rounded_rectangle((210, 466, 290, 471), radius=2, fill=TEAL) + return im + + +def bezel_watch(name: str, size: int) -> Image.Image: + ring = 14 + total = size + 2 * ring + s = total * SS + out = Image.new("RGBA", (s, s), (0, 0, 0, 0)) + d = ImageDraw.Draw(out) + d.ellipse((0, 0, s - 1, s - 1), fill=(58, 62, 68)) + d.ellipse((4 * SS, 4 * SS, s - 4 * SS - 1, s - 4 * SS - 1), fill=(22, 24, 28)) + out = out.resize((total, total), Image.Resampling.LANCZOS) + scr = round_screen(name).resize((size, size), Image.Resampling.LANCZOS) + out.paste(scr, (ring, ring), scr) + return out + + +def hero() -> Image.Image: + w, h = 1440, 720 + # Diagonal gradient, slightly teal toward the bottom right. + grad = Image.new("RGB", (w, h)) + px = grad.load() + assert px is not None + for y in range(h): + for x in range(w): + t = x / w * 0.6 + y / h * 0.4 + px[x, y] = (int(8 + 4 * t), int(12 + 30 * t), int(16 + 34 * t)) + im = grad.convert("RGBA") + d = ImageDraw.Draw(im) + + # text, left + x0 = 100 + d.text((x0, 150), "OTP Wallet", font=font(FONT_BOLD, 96), fill=WHITE) + d.rounded_rectangle((x0, 268, x0 + 120, 274), radius=3, fill=TEAL) + d.text((x0, 300), "One-time codes and barcodes", font=font(FONT_REG, 38), fill=(220, 230, 232)) + d.text((x0, 346), "on your wrist", font=font(FONT_REG, 38), fill=(220, 230, 232)) + bf = font(FONT_REG, 32) + for i, line in enumerate( + ["TOTP, HOTP and Steam Guard", "QR, PDF417, Aztec and more barcodes", "Encrypted on the watch. No cloud."] + ): + y = 440 + i * 60 + d.ellipse((x0, y + 12, x0 + 16, y + 28), fill=TEAL) + d.text((x0 + 36, y), line, font=bf, fill=WHITE) + + # watches, right: back-left list, back-right qr, front otp + for name, size, cx, cy in (("list", 290, 930, 400), ("code_qr", 290, 1215, 330), ("otp", 360, 1075, 440)): + wt = bezel_watch(name, size) + shadow = Image.new("RGBA", wt.size, (0, 0, 0, 0)) + ImageDraw.Draw(shadow).ellipse((0, 0, wt.width - 1, wt.height - 1), fill=(0, 0, 0, 110)) + im.alpha_composite(shadow, (cx - wt.width // 2 + 8, cy - wt.height // 2 + 12)) + im.alpha_composite(wt, (cx - wt.width // 2, cy - wt.height // 2)) + return im.convert("RGB") + + +def save_png(img: Image.Image, path: Path, description: str) -> None: + # Every PNG in the repo carries Author and Description chunks (lint HDR001). + info = PngInfo() + info.add_text("Author", "cirakg & claude ai") + info.add_text("Description", description) + img.save(path, format="PNG", optimize=True, pnginfo=info) + + +def main() -> None: + OUT.mkdir(parents=True, exist_ok=True) + for tag, name in SCREENS: + description = f"Connect IQ Store screen image: {name} on the Forerunner 70." + save_png(round_screen(name), OUT / f"screen-{tag}.png", description) + save_png(cover(), OUT / "cover-500.png", "Connect IQ Store cover image, 500 x 500.") + save_png(hero(), OUT / "hero-1440x720.png", "Connect IQ Store hero image for mobile, 1440 x 720.") + for p in sorted(OUT.glob("*.png")): + with Image.open(p) as im: + print(f"{p.name}: {im.size[0]}x{im.size[1]} {p.stat().st_size} bytes") + + +if __name__ == "__main__": + main() -- cgit v1.2.3