Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/icon-1024-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/icon-1024-tinted.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/icon-1024.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
82 changes: 68 additions & 14 deletions assets/make-icon.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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))
Expand All @@ -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))
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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"))
Expand Down Expand Up @@ -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__":
Expand Down
Binary file modified assets/social-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 23 additions & 0 deletions docs/UI_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions ios-app/BUILDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions ios-app/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified ios-app/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading