diff --git a/assets/icon-1024-dark.png b/assets/icon-1024-dark.png new file mode 100644 index 0000000..112b12f Binary files /dev/null and b/assets/icon-1024-dark.png differ diff --git a/assets/icon-1024-tinted.png b/assets/icon-1024-tinted.png new file mode 100644 index 0000000..70ddaad Binary files /dev/null and b/assets/icon-1024-tinted.png differ diff --git a/assets/icon-1024.png b/assets/icon-1024.png index bd68f3c..673d56c 100644 Binary files a/assets/icon-1024.png and b/assets/icon-1024.png differ diff --git a/assets/make-icon.py b/assets/make-icon.py index 86df2a7..2776982 100644 --- a/assets/make-icon.py +++ b/assets/make-icon.py @@ -1,18 +1,28 @@ #!/usr/bin/env python3 -"""Generates the LensLink logo, app icon, and README banner. +"""Generates the LensLink logo, app icons, and README banner. The mark is a camera lens (big ring + aperture dot) with a smaller link ring layered onto its lower-right edge, in the design system's palette (docs/UI_DESIGN.md: accent #3D7BFF on dark #0E0F13). +The iOS app icon ships in Apple's three appearance variants (iOS 18+): +default (light), dark, and tinted. Only the default one carries our own +background — for dark and tinted, iOS draws the backdrop itself and +composites the artwork over it, so those two are transparent PNGs. In +tinted mode iOS throws the hue away and maps luminance through the +colour the user picked, so that variant is authored in greys. + Outputs (run from the repo root): - assets/icon-1024.png app icon master (opaque — iOS forbids alpha) + assets/icon-1024.png app icon master (opaque — iOS forbids alpha) + assets/icon-1024-dark.png dark-appearance master (transparent) + assets/icon-1024-tinted.png tinted-appearance master (transparent, greys) assets/logo.png icon on transparent, 512x512 assets/banner.png icon + wordmark on transparent (README) assets/social-preview.png 1280x640 GitHub social preview (opaque) - ios-app/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024.png + ios-app/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024{,-dark,-tinted}.png """ +import collections import math import os @@ -28,6 +38,31 @@ BG_TOP = (0x17, 0x1A, 0x22) BG_BOTTOM = (0x0D, 0x0E, 0x12) +# The four inks of the mark, so it can be re-coloured per icon appearance. +Palette = collections.namedtuple("Palette", "ring link aperture_hi aperture_lo") + +BRAND = Palette(ACCENT, ACCENT_LIGHT, APERTURE_HI, APERTURE_LO) + + +def shade(colour, factor): + return tuple(min(255, int(round(c * factor))) for c in colour) + + +def grey(v): + return (v, v, v) + + +# Dark appearance: the artwork sits on iOS's own dark backdrop with no +# gradient of ours to sit against, and a dark home screen makes saturated +# blues glare. Same hues, dialled back. +DARK = Palette(*(shade(c, 0.86) for c in BRAND)) + +# Tinted appearance: greys only — iOS remaps luminance into the user's +# tint, brightest where this image is whitest. The ordering matters more +# than the absolute values: link ring brightest, then the lens ring, with +# the aperture dot keeping enough range to still read as a sphere. +TINTED = Palette(grey(0xCC), grey(0xFF), grey(0xC2), grey(0x73)) + # Geometry (1024-space). The small ring straddles the big ring's band so # the two interlock like chain links. BIG_C = (468, 448) @@ -56,14 +91,18 @@ def circle_mask(size, center, r): def vertical_gradient(size, top, bottom): - img = Image.new("RGB", (size, size)) + # Drawn as a 1px column and stretched sideways. (Painting the column + # into a full-width image and then "resizing" it to its own size — a + # no-op — leaves every other column black; that is why the icon + # shipped on a flat black background instead of this gradient.) + col = Image.new("RGB", (1, size)) for y in range(size): t = y / (size - 1) - img.putpixel((0, y), tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3))) - return img.resize((size, size)) # broadcast the 1px column + col.putpixel((0, y), tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3))) + return col.resize((size, size)) -def make_mark(size): +def make_mark(size, palette=BRAND): """The lens+link mark with a transparent background, at size*S px.""" px = size * S mark = Image.new("RGBA", (px, px), (0, 0, 0, 0)) @@ -75,7 +114,9 @@ def make_mark(size): grad = Image.new("RGB", (px, px)) for y in range(0, px, S): t = y / (px - 1) - row = tuple(int(APERTURE_HI[i] + (APERTURE_LO[i] - APERTURE_HI[i]) * t) for i in range(3)) + row = tuple(int(palette.aperture_hi[i] + + (palette.aperture_lo[i] - palette.aperture_hi[i]) * t) + for i in range(3)) for yy in range(y, min(y + S, px)): grad.paste(row, (0, yy, px, yy + 1)) dot.paste(grad, (0, 0), circle_mask(px, BIG_C, DOT_R)) @@ -84,7 +125,7 @@ def make_mark(size): big = ring_mask(px, BIG_C, BIG_R_OUT, BIG_R_IN) small = ring_mask(px, SMALL_C, SMALL_R_OUT, SMALL_R_IN) - mark.paste(solid(ACCENT), (0, 0), big) + mark.paste(solid(palette.ring), (0, 0), big) # The two bands cross at a shallow angle, so their overlap is one # connected crescent — a chain-style over/under weave can't render @@ -96,7 +137,7 @@ def make_mark(size): keyline = ImageChops.multiply(keyline, big) mark.paste(Image.new("RGBA", (px, px), (0, 0, 0, 0)), (0, 0), keyline) - mark.paste(solid(ACCENT_LIGHT), (0, 0), small) + mark.paste(solid(palette.link), (0, 0), small) return mark @@ -111,13 +152,25 @@ def main(): mark_big = make_mark(SIZE) - # --- App icon: mark on the dark gradient, opaque RGB --------------- + # --- App icon, default (light) appearance: opaque RGB --------------- + # The only variant with a background of ours; iOS forbids alpha here. icon = vertical_gradient(SIZE * S, BG_TOP, BG_BOTTOM).convert("RGBA") icon.alpha_composite(mark_big) icon = icon.resize((SIZE, SIZE), Image.LANCZOS).convert("RGB") icon.save(os.path.join(assets, "icon-1024.png")) icon.save(os.path.join(appiconset, "icon-1024.png")) + # --- App icon, dark + tinted appearances: transparent RGBA ---------- + # iOS draws its own backdrop under these two (a dark gradient, or the + # user's tint), so shipping our gradient would double it up and box + # the mark in. Keep them transparent; the keyline gap around the link + # ring then reveals that backdrop instead of our background, which is + # the same read. + for name, palette in (("dark", DARK), ("tinted", TINTED)): + variant = make_mark(SIZE, palette).resize((SIZE, SIZE), Image.LANCZOS) + variant.save(os.path.join(assets, "icon-1024-%s.png" % name)) + variant.save(os.path.join(appiconset, "icon-1024-%s.png" % name)) + # --- Standalone logo: transparent, 512 ------------------------------ logo = mark_big.resize((512, 512), Image.LANCZOS) logo.save(os.path.join(assets, "logo.png")) @@ -188,9 +241,10 @@ def social_preview(path): social_preview(os.path.join(assets, "social-preview.png")) - print("wrote assets/icon-1024.png, assets/logo.png, assets/banner-*.png,") - print("assets/social-preview.png,") - print("and", os.path.relpath(os.path.join(appiconset, "icon-1024.png"), root)) + print("wrote assets/icon-1024{,-dark,-tinted}.png, assets/logo.png,") + print("assets/banner-*.png, assets/social-preview.png,") + print("and the same three icons in", + os.path.relpath(appiconset, root)) if __name__ == "__main__": diff --git a/assets/social-preview.png b/assets/social-preview.png index d3b383c..81d80bb 100644 Binary files a/assets/social-preview.png and b/assets/social-preview.png differ diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index b091a5b..0dc6106 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -249,6 +249,29 @@ surfaces. | Dim (app) | `moon.fill` | App-only battery saver | | Stats (app) | `gauge` | App-only toggle; shows a health pill (`60 fps · 11.9 Mb/s · 0 dropped`, monospaced) under the status bar | +### The app icon (three appearances) + +The mark is a camera lens (ring + aperture dot) with a smaller link ring +on its lower-right edge. It ships in Apple's three home-screen +appearances (iOS 18+), all generated by `assets/make-icon.py` from one +set of geometry — never hand-edited, and never re-drawn per variant: + +| Appearance | Background | Ink | +|------------|-----------|-----| +| Default (light) | our own `#171A22 → #0D0E12` vertical gradient, **opaque** (iOS forbids alpha here) | full brand palette: `accent` lens ring, `#8FB4FF` link ring | +| Dark | **transparent** — iOS draws its own dark backdrop | same hues at 86% brightness; a saturated blue glares on an all-dark home screen | +| Tinted | **transparent** — iOS draws the user's tint | **greys only.** iOS discards hue and maps luminance through the tint, so the palette becomes a brightness ranking: link ring `#FFF`, lens ring `#CCC`, aperture dot `#C2` → `#73` | + +Two rules carry across all three: the link ring always reads brighter +than the lens ring (that hierarchy is what the tinted variant preserves +instead of colour), and the keyline gap that separates the two rings +stays — on the transparent variants it reveals iOS's backdrop rather +than ours, which is the same read. + +Adding an appearance means adding a palette in `make-icon.py` **and** an +entry in `AppIcon.appiconset/Contents.json`; a PNG without the matching +`appearances` key is dead weight the asset compiler ignores. + --- ## 6. Screen specifications diff --git a/ios-app/BUILDING.md b/ios-app/BUILDING.md index 5458890..4e38fbc 100644 --- a/ios-app/BUILDING.md +++ b/ios-app/BUILDING.md @@ -45,6 +45,13 @@ both are required. source on Linux (it installs a Swift toolchain on first run). Syntax only — SwiftUI/UIKit don't exist outside Xcode, so the full compile still needs macOS (CI does it on every pull request). +- **App icon:** `AppIcon.appiconset` carries three appearance variants — + default, dark and tinted (see `docs/UI_DESIGN.md` §5). Compiling the + dark/tinted ones needs **Xcode 16 or newer**; older Xcode ignores the + `appearances` keys (possibly with an "unassigned children" warning) and + builds an app that just uses the default icon everywhere. Regenerate all + three with `python3 assets/make-icon.py` from the repo root rather than + editing the PNGs — the generator is the source of truth for the mark. - The app stops streaming when backgrounded (iOS suspends camera capture); keep it in the foreground while live. The screen is kept awake automatically while streaming. diff --git a/ios-app/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios-app/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json index 27a4f38..8dcf27c 100644 --- a/ios-app/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios-app/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -5,6 +5,30 @@ "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "icon-1024-dark.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "filename" : "icon-1024-tinted.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" } ], "info" : { diff --git a/ios-app/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024-dark.png b/ios-app/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024-dark.png new file mode 100644 index 0000000..112b12f Binary files /dev/null and b/ios-app/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024-dark.png differ diff --git a/ios-app/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024-tinted.png b/ios-app/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024-tinted.png new file mode 100644 index 0000000..70ddaad Binary files /dev/null and b/ios-app/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024-tinted.png differ diff --git a/ios-app/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/ios-app/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024.png index bd68f3c..673d56c 100644 Binary files a/ios-app/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024.png and b/ios-app/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ