The icon is now a container with its right wall opened, so the enclosure itself is the letter C, holding a >_ prompt: the two things the app is, in one closed shape. It carries no type, so nothing goes illegible when the shell draws it small, and it uses the app's own accent tokens rather than a saturated orange field that fights the chrome behind it. icon.ico contained a single 16x16 image, which Windows was upscaling into the taskbar and every other slot — the likely cause of the artefact in screenshot_for_fix/. It now carries 16, 24, 32, 48, 64, 128 and 256, each rendered from vector rather than downsampled from one bitmap, and the entries at 32 and below come from a separate optical source: at that size the cursor bar closes up against the chevron, so the small variant drops it, widens the mouth and thickens the strokes. A test asserts the .ico keeps its small sizes so this cannot regress silently. Also adds the icon.icns that macOS bundles have been building without, points the favicon at our own mark instead of the missing /vite.svg, and puts the SVG sources, the lockups and the regeneration script in branding/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
123 lines
4.1 KiB
Python
123 lines
4.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Render every packaged icon from the SVG sources in this directory.
|
|
|
|
pip install cairosvg pillow
|
|
python3 branding/build-icons.py
|
|
|
|
The SVGs are the source of truth; everything under app/src-tauri/icons/ and the
|
|
favicon are generated. Two sources, not one, on purpose:
|
|
|
|
triple-c-icon.svg used for 48 px and up
|
|
triple-c-icon-small.svg used for 32 px and down
|
|
|
|
At 16-32 px the cursor bar closes up against the chevron and the frame's 12 px
|
|
stroke resamples to a grey smear, so the small source drops the cursor, widens
|
|
the mouth and draws the mark larger in the tile. Windows picks the 16 and 24 px
|
|
entries out of the .ico for the taskbar and window corner, which is exactly
|
|
where the old single-size .ico was falling over.
|
|
"""
|
|
|
|
import io
|
|
import struct
|
|
from pathlib import Path
|
|
|
|
import cairosvg
|
|
from PIL import Image
|
|
|
|
BRANDING = Path(__file__).resolve().parent
|
|
REPO = BRANDING.parent
|
|
ICONS = REPO / "app" / "src-tauri" / "icons"
|
|
PUBLIC = REPO / "app" / "public"
|
|
|
|
FULL_SRC = BRANDING / "triple-c-icon.svg"
|
|
SMALL_SRC = BRANDING / "triple-c-icon-small.svg"
|
|
|
|
# Below this, render from the small source.
|
|
SMALL_MAX = 32
|
|
|
|
# .ico entries. Windows uses 16 in the window corner and 24/32 in the taskbar,
|
|
# 48 in list views and 256 in the "extra large icons" view.
|
|
ICO_SIZES = [16, 24, 32, 48, 64, 128, 256]
|
|
|
|
# .icns entries: (chunk type, pixel size). PNG-backed chunks, macOS 10.7+.
|
|
ICNS_ENTRIES = [
|
|
(b"ic11", 32), # 16pt @2x
|
|
(b"ic12", 64), # 32pt @2x
|
|
(b"ic07", 128), # 128pt
|
|
(b"ic13", 256), # 128pt @2x
|
|
(b"ic08", 256), # 256pt
|
|
(b"ic14", 512), # 256pt @2x
|
|
(b"ic09", 512), # 512pt
|
|
(b"ic10", 1024), # 512pt @2x
|
|
]
|
|
|
|
|
|
def render(size: int) -> Image.Image:
|
|
"""Rasterise the right source at `size` px square."""
|
|
src = SMALL_SRC if size <= SMALL_MAX else FULL_SRC
|
|
png = cairosvg.svg2png(url=str(src), output_width=size, output_height=size)
|
|
return Image.open(io.BytesIO(png)).convert("RGBA")
|
|
|
|
|
|
def write_png(size: int, path: Path) -> None:
|
|
render(size).save(path, "PNG", optimize=True)
|
|
print(f" {path.relative_to(REPO)} {size}x{size}")
|
|
|
|
|
|
def write_ico(path: Path) -> None:
|
|
"""Hand-assemble the .ico so each entry can come from its own source.
|
|
|
|
Pillow's save(sizes=...) downsamples one image, which would put the
|
|
12 px-stroke artwork into the 16 px entry — the thing this avoids.
|
|
"""
|
|
images = [render(s) for s in ICO_SIZES]
|
|
payloads = []
|
|
for img in images:
|
|
buf = io.BytesIO()
|
|
img.save(buf, "PNG", optimize=True) # PNG-compressed entries, Vista+
|
|
payloads.append(buf.getvalue())
|
|
|
|
offset = 6 + 16 * len(images)
|
|
header = struct.pack("<HHH", 0, 1, len(images))
|
|
entries, blob = b"", b""
|
|
for img, data in zip(images, payloads):
|
|
w = 0 if img.width >= 256 else img.width
|
|
h = 0 if img.height >= 256 else img.height
|
|
entries += struct.pack("<BBBBHHII", w, h, 0, 0, 1, 32, len(data), offset)
|
|
blob += data
|
|
offset += len(data)
|
|
path.write_bytes(header + entries + blob)
|
|
print(f" {path.relative_to(REPO)} {', '.join(str(s) for s in ICO_SIZES)}")
|
|
|
|
|
|
def write_icns(path: Path) -> None:
|
|
chunks = b""
|
|
for kind, size in ICNS_ENTRIES:
|
|
buf = io.BytesIO()
|
|
render(size).save(buf, "PNG", optimize=True)
|
|
data = buf.getvalue()
|
|
chunks += kind + struct.pack(">I", len(data) + 8) + data
|
|
path.write_bytes(b"icns" + struct.pack(">I", len(chunks) + 8) + chunks)
|
|
print(f" {path.relative_to(REPO)} {', '.join(str(s) for _, s in ICNS_ENTRIES)}")
|
|
|
|
|
|
def main() -> None:
|
|
print("branding/")
|
|
write_png(1024, BRANDING / "triple-c-icon-1024.png")
|
|
|
|
print("app/src-tauri/icons/")
|
|
write_png(32, ICONS / "32x32.png")
|
|
write_png(128, ICONS / "128x128.png")
|
|
write_png(256, ICONS / "128x128@2x.png")
|
|
write_png(512, ICONS / "icon.png")
|
|
write_ico(ICONS / "icon.ico")
|
|
write_icns(ICONS / "icon.icns")
|
|
|
|
print("app/public/")
|
|
(PUBLIC / "favicon.svg").write_text(SMALL_SRC.read_text())
|
|
print(f" {(PUBLIC / 'favicon.svg').relative_to(REPO)} (copy of {SMALL_SRC.name})")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|