icon: Inkwell's mark is a black inkpot and quill on the brand yellow
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

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>
This commit is contained in:
2026-10-06 14:30:51 -04:00
co-authored by Claude Opus 5.5
parent fe6f0746b2
commit 256fba3610
16 changed files with 185 additions and 44 deletions
@@ -3,10 +3,12 @@
Adaptive icon. minSdk is 26, so this is the ONLY icon Android will ask for —
no legacy raster fallback is needed.
The foreground is the shared maskable asset the web app already ships
(frontend/public/icon-maskable-512.png), which is drawn with the safe-zone
padding adaptive icons require. Reusing it means the phone, the web app and the
desktop all wear the same face rather than three near-misses.
The foreground is the inkwell mark alone on transparency, inside the 66dp safe
circle; the yellow is the background colour. It is rendered by
packaging/icons.py from the same drawing as the web and desktop icons, so all
three wear one face. Transparent rather than a full-bleed tile because the
monochrome layer below reuses it: a themed icon draws its alpha as a silhouette,
and an opaque foreground would come out as a solid square.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

+5 -4
View File
@@ -45,13 +45,14 @@ cd desktop/src-tauri && cargo tauri dev
## Build (Linux)
```sh
cargo tauri icon "$(git rev-parse --show-toplevel)/frontend/public/icon.svg"
cd desktop/src-tauri && cargo tauri build # -> .deb + .AppImage
```
App icons are **generated** from the frontend's `icon.svg` via `cargo tauri icon`
(not committed; CI does this before `cargo tauri build`). Bundle targets: `deb`,
`appimage` (Linux-first; Windows/macOS later, no code changes expected).
App icons are **generated and committed**: `python3 packaging/icons.py` draws the
inkwell mark once and renders every web, desktop and Android icon from it,
including `app-icon.png` and `icons/*.png` here. The Windows lane derives its
`.ico` from `app-icon.png` with `cargo tauri icon` at build time. Bundle targets:
`deb`, `appimage` (Linux-first; Windows/macOS later, no code changes expected).
## Status
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 829 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 781 B

After

Width:  |  Height:  |  Size: 829 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+23 -17
View File
@@ -1,24 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Inkwell">
<!-- Maskable variant: full-bleed background so platform masks (circle, squircle,
rounded-square) never clip the glyph. The graph sits inside the central 80%
safe zone. Same mark as icon.svg. -->
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="#F5C518"/>
<g stroke="#18181b" stroke-linecap="round" fill="none">
<g stroke-width="14" opacity="0.26">
<path d="M256 150 L166 336"/>
<path d="M166 336 L346 336"/>
<path d="M346 336 L256 150"/>
</g>
<g stroke-width="22">
<path d="M256 268 L256 150"/>
<path d="M256 268 L166 336"/>
<path d="M256 268 L346 336"/>
<g transform="translate(256 256) scale(0.8) translate(-262 -226)">
<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">
<g transform="translate(256 232) rotate(34)">
<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"/>
</g>
</mask>
</defs>
<g mask="url(#inkwell-cuts)">
<g transform="translate(256 232) rotate(34)">
<path d="M0 6 L0 -46" stroke="#18181b" 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="#18181b"/>
</g>
<path fill="#18181b" 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="#18181b"/>
<rect x="200" y="226" width="112" height="22" rx="11" fill="#18181b"/>
</g>
<g fill="#18181b">
<circle cx="256" cy="150" r="34"/>
<circle cx="166" cy="336" r="34"/>
<circle cx="346" cy="336" r="34"/>
<circle cx="256" cy="268" r="26"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 914 B

After

Width:  |  Height:  |  Size: 1.5 KiB

+23 -19
View File
@@ -1,25 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Inkwell">
<!-- Rounded-tile app icon: brand-yellow background + a small knowledge-graph
constellation (a hub node linked to three thoughts) evoking linked notes. -->
<!-- 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="#F5C518"/>
<g stroke="#18181b" stroke-linecap="round" fill="none">
<!-- faint outer web -->
<g stroke-width="14" opacity="0.26">
<path d="M256 150 L166 336"/>
<path d="M166 336 L346 336"/>
<path d="M346 336 L256 150"/>
</g>
<!-- hub-and-spoke links -->
<g stroke-width="22">
<path d="M256 268 L256 150"/>
<path d="M256 268 L166 336"/>
<path d="M256 268 L346 336"/>
<g transform="translate(256 256) scale(0.92) translate(-262 -226)">
<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">
<g transform="translate(256 232) rotate(34)">
<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"/>
</g>
</mask>
</defs>
<g mask="url(#inkwell-cuts)">
<g transform="translate(256 232) rotate(34)">
<path d="M0 6 L0 -46" stroke="#18181b" 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="#18181b"/>
</g>
<path fill="#18181b" 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="#18181b"/>
<rect x="200" y="226" width="112" height="22" rx="11" fill="#18181b"/>
</g>
<g fill="#18181b">
<circle cx="256" cy="150" r="34"/>
<circle cx="166" cy="336" r="34"/>
<circle cx="346" cy="336" r="34"/>
<circle cx="256" cy="268" r="26"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 938 B

After

Width:  |  Height:  |  Size: 1.5 KiB

+128
View File
@@ -0,0 +1,128 @@
"""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()