#!/usr/bin/env python3 """Every storebound logo file, made in code from one master: brand/master/logo-mark.png (1024 px, transparent). python3 tools/brand-assets.py write everything below python3 tools/brand-assets.py --sheet out.png also write a contact sheet of every size (for a look, not committed) Needs Pillow (`pip install pillow`); a build-time tool only, the CLI keeps zero runtime dependencies. The text (the small-size monogram, the og image) is set in the system UI font: SF Pro on macOS, else Inter / DejaVu Sans if found. Writes: brand/logo-mark-256.png the mark alone, for the README apps/desktop/src-tauri/icons/ 32/64/128/256 px, 128x128@2x, icon.png (512), icon.icns (iconutil, macOS only), icon.ico (16/24/32/48/64/256), Square*Logo, StoreLogo apps/desktop/src/ui/logo-mark.png the mark alone (72 px), next to the "storebound" text in the app site/favicon.ico (16/32/48), site/icon-192.png, site/icon-512.png (maskable), site/apple-touch-icon.png, site/img/logo-mark.png + logo-mark@2x.png the mark alone, next to the HTML "storebound" wordmark site/og.png 1200x630 share image: the mark plus real-font text Method: trim the master to its alpha bounding box, pad ~6 %, square it, LANCZOS down (never up). The tile behind the mark (rounded square, navy to indigo, the site's colour tokens) is drawn here and never painted into the master. Below 48 px the mark turns to mush, so that tier is a separate design: a bold "s" set in the font at the target size on the same tile (rendered directly at 16/24/32, not shrunk from a big image). Do not use `tauri icon` (it shrinks one image to every size).""" from __future__ import annotations import os import shutil import subprocess import sys import tempfile from PIL import Image, ImageChops, ImageDraw, ImageFont ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) MASTER = os.path.join(ROOT, 'brand/master/logo-mark.png') TAURI = os.path.join(ROOT, 'apps/desktop/src-tauri/icons') SITE = os.path.join(ROOT, 'site') # site/style.css tokens: dark --bg #0b1120, --bg-alt #0f172a; --accent #4f46e5 (light), indigo-700 #4338ca NAVY = (15, 23, 42) INDIGO = (67, 56, 202) WHITE = (248, 250, 252) SMALL = 48 # below this the monogram tier is used FONT_CANDIDATES = ['/System/Library/Fonts/SFNS.ttf', '/Library/Fonts/Inter.ttc', '/usr/share/fonts/truetype/inter/Inter.ttc', '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'] MONO_CANDIDATES = ['/System/Library/Fonts/SFNSMono.ttf', '/System/Library/Fonts/Menlo.ttc', '/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf'] def font(size: float, weight: int = 700, mono: bool = False) -> ImageFont.FreeTypeFont: for path in MONO_CANDIDATES if mono else FONT_CANDIDATES: if not os.path.exists(path): continue f = ImageFont.truetype(path, size) try: axes = f.get_variation_axes() except OSError: return f # not a variable font vals = [] for a in axes: name = a['name'].decode() if isinstance(a['name'], bytes) else a['name'] if name == 'Weight': vals.append(max(a['minimum'], min(a['maximum'], weight))) elif name == 'Optical Size': vals.append(max(a['minimum'], min(a['maximum'], size))) else: vals.append(a['default']) f.set_variation_by_axes(vals) return f sys.exit('brand-assets: no usable font found (looked for ' + ', '.join(MONO_CANDIDATES if mono else FONT_CANDIDATES) + ')') # ---------- the mark ---------- def load_mark() -> Image.Image: """The master, trimmed to its alpha bounding box, padded ~6 % and squared (transparent).""" im = Image.open(MASTER).convert('RGBA') box = im.getchannel('A').point(lambda a: 255 if a > 8 else 0).getbbox() if not box: sys.exit('brand-assets: the master has no visible pixels') im = im.crop(box) side = round(max(im.size) * 1.12) # ~6 % on each side sq = Image.new('RGBA', (side, side), (0, 0, 0, 0)) sq.paste(im, ((side - im.width) // 2, (side - im.height) // 2)) return sq MARK: Image.Image | None = None def mark(px: int) -> Image.Image: """The squared mark at px, LANCZOS down; refuses to upscale.""" global MARK if MARK is None: MARK = load_mark() if px > MARK.width: sys.exit(f'brand-assets: {px} px would upscale the {MARK.width} px master') return MARK if px == MARK.width else MARK.resize((px, px), Image.LANCZOS) # ---------- the tile ---------- def gradient(w: int, h: int, a=NAVY, b=INDIGO) -> Image.Image: """Diagonal gradient, top left a to bottom right b.""" v = Image.linear_gradient('L').resize((w, h), Image.BICUBIC) hz = Image.linear_gradient('L').rotate(90).resize((w, h), Image.BICUBIC) t = ImageChops.add(v, hz, scale=2.0) return Image.composite(Image.new('RGB', (w, h), b), Image.new('RGB', (w, h), a), t).convert('RGBA') def tile(size: int, inset: float, radius: float, full_bleed: bool = False) -> Image.Image: """A size px canvas with the rounded gradient square inset by inset*size, drawn 4x and shrunk for clean edges.""" if full_bleed: return gradient(size, size) ss = 4 S = size * ss x0 = round(inset * S) body = gradient(S - 2 * x0, S - 2 * x0) m = Image.new('L', body.size, 0) ImageDraw.Draw(m).rounded_rectangle((0, 0, body.width - 1, body.height - 1), radius=radius * body.width, fill=255) body.putalpha(m) out = Image.new('RGBA', (S, S), (0, 0, 0, 0)) out.paste(body, (x0, x0), body) return out.resize((size, size), Image.LANCZOS) def on_tile(size: int, inset: float, radius: float, scale: float, full_bleed: bool = False) -> Image.Image: """The mark centred on the tile; scale is the mark's share of the tile's side.""" t = tile(size, inset, radius, full_bleed) inner = size - 2 * round(inset * size) if not full_bleed else size m = mark(max(1, round(inner * scale))) t.alpha_composite(m, ((size - m.width) // 2, (size - m.height) // 2)) return t def monogram(size: int, inset: float = 0.0, radius: float = 0.22) -> Image.Image: """Small tier: a bold "s" set at the target size on the same tile.""" inset = max(inset, 0.5 / size) # keep the corners off the canvas edge t = tile(size, inset, radius) f = font(size * 0.86, 760) d = ImageDraw.Draw(t) l, tp, r, b = d.textbbox((0, 0), 's', font=f) x = (size - (r - l)) / 2 - l y = (size - (b - tp)) / 2 - tp d.text((round(x), round(y)), 's', font=f, fill=WHITE + (255,)) return t # layouts def app_icon(size: int) -> Image.Image: """macOS / Linux app icon: Apple's grid (824 of 1024, ~22 % corner) with the mark at 86 % of the tile.""" return monogram(size, 0.0625) if size < SMALL else on_tile(size, 100 / 1024, 0.2245, 0.86) def win_icon(size: int) -> Image.Image: """Windows .ico / Store tiles: nearly full canvas.""" return monogram(size) if size < SMALL else on_tile(size, 0.03, 0.2, 0.9) def save(im: Image.Image, path: str) -> None: os.makedirs(os.path.dirname(path), exist_ok=True) im.save(path, optimize=True) print(f' {os.path.relpath(path, ROOT)} {im.width}x{im.height} {os.path.getsize(path) / 1024:.1f} KB') def save_ico(path: str, sizes: list[int], make) -> None: imgs = [make(s) for s in sizes] big = imgs[-1] big.save(path, format='ICO', sizes=[(s, s) for s in sizes], append_images=imgs[:-1], bitmap_format='png') print(f' {os.path.relpath(path, ROOT)} {"/".join(map(str, sizes))} {os.path.getsize(path) / 1024:.1f} KB') def write_icns(path: str) -> None: if not shutil.which('iconutil'): print(' icon.icns skipped: iconutil is macOS only') return with tempfile.TemporaryDirectory() as tmp: d = os.path.join(tmp, 'icon.iconset') os.mkdir(d) for s in (16, 32, 128, 256, 512): app_icon(s).save(os.path.join(d, f'icon_{s}x{s}.png')) app_icon(s * 2).save(os.path.join(d, f'icon_{s}x{s}@2x.png')) subprocess.run(['iconutil', '-c', 'icns', d, '-o', path], check=True) print(f' {os.path.relpath(path, ROOT)} 16-1024 (iconutil) {os.path.getsize(path) / 1024:.1f} KB') # ---------- og image ---------- def og() -> Image.Image: W, H = 1200, 630 im = Image.new('RGBA', (W, H), (11, 17, 32, 255)) def glow(cx, cy, r, color, alpha): g = Image.radial_gradient('L').resize((r * 2, r * 2), Image.BICUBIC).point(lambda v: int((255 - v) * alpha)) layer = Image.new('RGBA', (r * 2, r * 2), color + (0,)) layer.putalpha(g) im.alpha_composite(layer, (cx - r, cy - r)) glow(W + 40, -60, 560, (79, 70, 229), 0.42) glow(-80, H + 80, 420, (190, 24, 93), 0.22) d = ImageDraw.Draw(im) m = mark(60) im.alpha_composite(m, (68, 60)) d.text((140, 90), 'storebound', font=font(30, 700), fill=WHITE, anchor='lm') h = font(72, 800) d.text((72, 206), 'Your app or game, from zero', font=h, fill=WHITE, anchor='lm') x = 72 for part, col in (('to ', WHITE), ('“Submit for review”', (165, 180, 252)), ('.', WHITE)): d.text((x, 284), part, font=h, fill=col, anchor='lm') x += d.textlength(part, font=h) p = font(22, 400) for i, line in enumerate(('On Google Play and the App Store: records, in-app products and subscriptions, listings in every', 'language, every questionnaire, privacy, icons, screenshots. One config and your AI coding agent.')): d.text((72, 381 + i * 33, ), line, font=p, fill=(203, 213, 225), anchor='lm') mono = font(17, 700, mono=True) x = 72 for label, bg, fg in (('API', (20, 83, 45), (134, 239, 172)), ('LOCAL', (12, 74, 110), (125, 211, 252)), ('UI', (69, 26, 3), (252, 211, 77)), ('YOU', (80, 7, 36), (249, 168, 212))): w = d.textlength(label, font=mono) + 34 d.rounded_rectangle((x, 528, x + w, 566), radius=19, fill=bg) d.text((x + w / 2, 547), label, font=mono, fill=fg, anchor='mm') x += w + 15 d.text((1128, 547), 'storebound.dev', font=font(24, 600, mono=True), fill=(148, 163, 184), anchor='rm') return im.convert('RGB') # ---------- contact sheet ---------- def sheet(path: str, items: list[tuple[str, Image.Image]]) -> None: pad, lab = 16, 18 cells = [(n, i if i.width <= 256 else i.resize((256, round(256 * i.height / i.width)), Image.LANCZOS)) for n, i in items] W = 1400 rows, row, x = [], [], pad for n, i in cells: if x + i.width + pad > W and row: rows.append(row); row, x = [], pad row.append((n, i)); x += max(i.width, 70) + pad rows.append(row) H = pad + sum(max(i.height for _, i in r) + lab + pad for r in rows) out = Image.new('RGB', (W, H), (128, 128, 128)) half = Image.new('RGB', (W // 2, H), (245, 245, 245)) out.paste(half, (0, 0)) d = ImageDraw.Draw(out) y = pad f = font(12, 500) for r in rows: x = pad for n, i in r: out.paste(i, (x, y), i if i.mode == 'RGBA' else None) d.text((x, y + max(j.height for _, j in r) + 3), n, font=f, fill=(20, 20, 20)) x += max(i.width, 70) + pad y += max(i.height for _, i in r) + lab + pad out.save(path) print(f' sheet {path}') def main() -> None: print('brand-assets:') sheet_items: list[tuple[str, Image.Image]] = [] def keep(name, im): sheet_items.append((name, im)); return im save(keep('README 256', mark(256)), os.path.join(ROOT, 'brand/logo-mark-256.png')) # desktop app for s in (32, 64, 128, 256): save(keep(f'app {s}', app_icon(s)), os.path.join(TAURI, f'{s}x{s}.png')) save(app_icon(256), os.path.join(TAURI, '128x128@2x.png')) save(app_icon(512), os.path.join(TAURI, 'icon.png')) write_icns(os.path.join(TAURI, 'icon.icns')) for s in (16, 24): keep(f'ico {s}', win_icon(s)) keep('ico 48', win_icon(48)) save_ico(os.path.join(TAURI, 'icon.ico'), [16, 24, 32, 48, 64, 256], win_icon) for s in (30, 44, 71, 89, 107, 142, 150, 284, 310): save(keep(f'Square{s}', win_icon(s)) if s in (30, 44, 71, 150) else win_icon(s), os.path.join(TAURI, f'Square{s}x{s}Logo.png')) save(win_icon(50), os.path.join(TAURI, 'StoreLogo.png')) save(keep('app UI 72', mark(72)), os.path.join(ROOT, 'apps/desktop/src/ui/logo-mark.png')) # site save_ico(os.path.join(SITE, 'favicon.ico'), [16, 32, 48], win_icon) keep('favicon 16', win_icon(16)); keep('favicon 32', win_icon(32)) for s in (192, 512): save(keep(f'maskable {s}', on_tile(s, 0, 0, 0.66, full_bleed=True)), os.path.join(SITE, f'icon-{s}.png')) save(keep('apple-touch 180', on_tile(180, 0, 0, 0.8, full_bleed=True)), os.path.join(SITE, 'apple-touch-icon.png')) save(keep('site 28', mark(28)), os.path.join(SITE, 'img/logo-mark.png')) save(keep('site 56', mark(56)), os.path.join(SITE, 'img/logo-mark@2x.png')) save(keep('og', og()), os.path.join(SITE, 'og.png')) if '--sheet' in sys.argv: sheet(sys.argv[sys.argv.index('--sheet') + 1], sheet_items) if __name__ == '__main__': main()