Files
inkwell/packaging/icons.py
T
bvandeusenandClaude Opus 5.5 256fba3610
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 14s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Successful in 43s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m13s
Desktop (Tauri) / Update manifest (push) Successful in 8s
Android / Kotlin + Rust (APK) (push) Canceled after 11m42s
icon: Inkwell's mark is a black inkpot and quill on the brand yellow
Step 5 of milestone 481. Replaces the linked-notes constellation, which had been
stale since note links were dropped (alembic 0024). The colour scheme stays.

packaging/icons.py draws the mark once and renders every variant from it: the
rounded tile (web, desktop), the maskable full-bleed web icon, and the Android
adaptive foreground. The detail (shaft, vane splits, glint) is cut out of the ink
with a mask rather than painted on in yellow, because the Android foreground is
now transparent and its alpha is also the themed-icon silhouette. The old
foreground was the opaque maskable tile, which a themed icon would have drawn as
a solid square.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:30:51 -04:00

129 lines
5.2 KiB
Python

"""Every icon Inkwell ships, from one drawing.
The mark is a black inkpot with a quill standing in it, on the brand yellow
(Scribe note 5071). It is drawn ONCE below, in a 512 box, and each variant places it
with its own scale, because each platform crops differently:
- icon.svg the rounded tile: web favicon/PWA "any", desktop app icon
- icon-maskable.svg full bleed, mark inside the central 80% (PWA "maskable")
- Android foreground mark alone on transparency inside the 66dp safe circle of a
108dp adaptive canvas; the yellow is the background layer, and
the transparent mark is also what themed (monochrome) icons need
Run from the repo root after changing the drawing; needs rsvg-convert (librsvg).
Generated output is committed, like any other generated file.
python3 packaging/icons.py
"""
import subprocess
from pathlib import Path
INK = "#18181b"
GOLD = "#F5C518"
# The quill is drawn upright at the pot mouth (0,0), then rotated into place.
QUILL_AT = "translate(256 232) rotate(34)"
QUILL_INK = f'''
<g transform="{QUILL_AT}">
<path d="M0 6 L0 -46" stroke="{INK}" stroke-width="12" stroke-linecap="round"/>
<path d="M0 -34 C 46 -78 58 -176 4 -252 C -34 -196 -40 -104 0 -34 Z" fill="{INK}"/>
</g>'''
POT_INK = f'''
<path fill="{INK}" d="M222 262 C 166 268 140 300 140 338 L140 374 C140 396 156 410 178 410
L334 410 C356 410 372 396 372 374 L372 338 C372 300 346 268 290 262 Z"/>
<rect x="214" y="236" width="84" height="34" rx="9" fill="{INK}"/>
<rect x="200" y="226" width="112" height="22" rx="11" fill="{INK}"/>'''
# The detail — the quill's shaft, two splits in its vane, the glint on the pot — is
# CUT OUT of the ink rather than painted over it in yellow. On the tile the two look
# the same; on Android's transparent foreground only a cut-out is right, because a
# themed icon draws the foreground's alpha as a silhouette, and painted detail
# would come out as solid, with the vane splits sticking out past its edge.
CUTS = '''
<g transform="{at}">
<path d="M1 -52 L3 -232" stroke-width="6" stroke-linecap="round"/>
<path d="M62 -128 L30 -108 M64 -176 L32 -152" stroke-width="8"/>
</g>
<path d="M170 344 C 172 318 188 300 214 292" stroke-width="10" stroke-linecap="round"/>'''.format(at=QUILL_AT)
GLYPH = f'''
<defs>
<mask id="inkwell-cuts" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512">
<rect width="512" height="512" fill="#fff"/>
<g stroke="#000" fill="none">{CUTS}
</g>
</mask>
</defs>
<g mask="url(#inkwell-cuts)">{QUILL_INK}{POT_INK}
</g>'''
# Glyph bounds, measured from a render: centre the mark, then scale per variant.
CX, CY = 262, 226
def place(scale, cy=256):
return f'translate(256 {cy}) scale({scale}) translate({-CX} {-CY})'
def svg(body, label=True):
aria = ' role="img" aria-label="Inkwell"' if label else ''
return f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"{aria}>\n{body}\n</svg>\n'
def tile(scale=0.92):
return svg(f''' <!-- Rounded-tile app icon: brand yellow, and the inkwell — a black inkpot with a
quill standing in it. Source for every raster icon; see step 5 of milestone 481. -->
<rect width="512" height="512" rx="112" fill="{GOLD}"/>
<g transform="{place(scale)}">{GLYPH}
</g>''')
def maskable(scale=0.8):
return svg(f''' <!-- Maskable variant: full-bleed background so platform masks (circle, squircle,
rounded-square) never clip the mark, which sits inside the central 80% safe
zone. Same mark as icon.svg. -->
<rect width="512" height="512" fill="{GOLD}"/>
<g transform="{place(scale)}">{GLYPH}
</g>''')
def foreground(scale=0.64):
# Android adaptive foreground: the mark alone on transparency, inside the 66dp
# safe circle of a 108dp canvas. The yellow lives in the background layer.
return svg(f''' <g transform="{place(scale)}">{GLYPH}
</g>''', label=False)
ROOT = Path(__file__).resolve().parent.parent
WEB = ROOT / "frontend" / "public"
DESKTOP = ROOT / "desktop" / "src-tauri"
ANDROID = ROOT / "android" / "app" / "src" / "main" / "res" / "mipmap-xxxhdpi"
def render(svg_text: str, size: int, out: Path, background: str | None = None) -> None:
cmd = ["rsvg-convert", "-w", str(size), "-h", str(size), "-o", str(out)]
if background:
cmd += ["-b", background]
subprocess.run(cmd, input=svg_text.encode(), check=True)
def main() -> None:
icon, mask, fg = tile(), maskable(), foreground()
(WEB / "icon.svg").write_text(icon)
(WEB / "icon-maskable.svg").write_text(mask)
render(icon, 32, WEB / "favicon-32.png")
render(icon, 192, WEB / "icon-192.png")
render(icon, 512, WEB / "icon-512.png")
render(mask, 512, WEB / "icon-maskable-512.png")
# iOS rounds the corners itself and shows transparency as black.
render(mask, 180, WEB / "apple-touch-icon.png")
render(icon, 1024, DESKTOP / "app-icon.png")
render(icon, 32, DESKTOP / "icons" / "32x32.png")
render(icon, 128, DESKTOP / "icons" / "128x128.png")
render(icon, 256, DESKTOP / "icons" / "128x128@2x.png")
render(icon, 512, DESKTOP / "icons" / "icon.png")
render(fg, 512, ANDROID / "ic_launcher_foreground.png")
if __name__ == "__main__":
main()