cmux/scripts/generate_dark_icon.py
Lawrence Chen 8378bbeaa2
Add dark mode app icon for macOS Sequoia (#702)
* Add dark mode app icon variant for macOS Sequoia

Adds dark appearance entries to the AppIcon asset catalog so macOS 15+
automatically shows a dark-background icon when the system is in dark
mode. The chevron gradient and glow are preserved by recompositing the
foreground over a dark background (#1C1C1E).

Includes a generation script (scripts/generate_dark_icon.py) that
derives the dark PNGs from the light originals.

* Add icon picker in Settings and fix dark icon quality

Use the Figma chevron layer (design/cmux-icon-chevron.png) composited
over a dark background for pixel-perfect results, no white halo or
darkened gradient. Falls back to mathematical recomposition if the Figma
layer is missing.

Add an "App Icon" picker to Settings (under Theme) with three visual
options: Automatic (follows system appearance via asset catalog dark
variants on macOS 15+), Light, and Dark. The selection persists via
UserDefaults and is applied on launch in AppDelegate.ensureApplicationIcon.

* Fix dark icon chevron scale to match light icon

The Figma export was ~25% larger than the repo icon. Scale the Figma
chevron layer by 0.80x before compositing so the chevron size matches
exactly between light and dark variants.

* Use enhanced glow for dark icon

Add a soft blue bloom around the chevron on the dark background using
two Gaussian blur passes (wide at r=25 and tight at r=12) composited
at reduced opacity beneath the sharp chevron. Makes the icon pop more
against the dark squircle.
2026-03-01 03:57:09 -08:00

229 lines
7.4 KiB
Python
Executable file

#!/usr/bin/env python3
"""Generate dark mode app icon variants.
Composites the Figma chevron layer (on transparent background) over a dark
squircle background derived from the light icon's alpha channel. This
preserves the exact chevron colors and glow without any halo artifacts.
Requires the Figma export at: design/cmux-icon-chevron.png
Falls back to mathematical recomposition if the Figma layer is missing.
"""
import json
import os
import sys
from PIL import Image, ImageFilter
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Apple systemBackground dark
DARK_BG = (28, 28, 30)
# Figma chevron layer (exported from Figma at native resolution)
FIGMA_CHEVRON = os.path.join(REPO, "design", "cmux-icon-chevron.png")
# The Figma export is ~25% larger than the repo icon. Scale and offset
# computed by matching the solid chevron (sat>0.5) bounding box center
# between the repo light icon and the scaled Figma chevron layer.
FIGMA_SCALE = 0.7996
FIGMA_OFFSET = (290, 187)
SIZES = [
("16.png", 16),
("16@2x.png", 32),
("32.png", 32),
("32@2x.png", 64),
("128.png", 128),
("128@2x.png", 256),
("256.png", 256),
("256@2x.png", 512),
("512.png", 512),
("512@2x.png", 1024),
]
def make_dark_from_figma(light_1024: Image.Image, chevron: Image.Image) -> Image.Image:
"""Create dark icon by compositing Figma chevron over dark background.
Uses the light icon's alpha channel for the squircle shape mask,
fills it with the dark background color, then composites the
chevron layer on top at the precomputed offset.
"""
size = 1024
light = light_1024.convert("RGBA")
if light.size != (size, size):
light = light.resize((size, size), Image.LANCZOS)
# Create dark background with the squircle shape from the light icon
dark_bg = Image.new("RGBA", (size, size), (0, 0, 0, 0))
light_px = light.load()
dark_px = dark_bg.load()
for y in range(size):
for x in range(size):
_, _, _, a = light_px[x, y]
if a > 0:
dark_px[x, y] = (DARK_BG[0], DARK_BG[1], DARK_BG[2], a)
# Scale chevron
chev = chevron.convert("RGBA")
cw, ch = chev.size
scaled_w = int(cw * FIGMA_SCALE)
scaled_h = int(ch * FIGMA_SCALE)
chev = chev.resize((scaled_w, scaled_h), Image.LANCZOS)
ox, oy = FIGMA_OFFSET
# Build enhanced glow: brighten the chevron, blur at two radii
glow_src = chev.copy()
glow_px = glow_src.load()
for y in range(scaled_h):
for x in range(scaled_w):
r, g, b, a = glow_px[x, y]
if a > 0:
glow_px[x, y] = (
min(255, int(r * 1.2)),
min(255, int(g * 1.2)),
min(255, int(b * 1.2)),
min(255, int(a * 1.1)),
)
glow_canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
glow_canvas.paste(glow_src, (ox, oy), glow_src)
# Wide soft glow + tighter glow
glow_wide = glow_canvas.filter(ImageFilter.GaussianBlur(radius=25))
glow_tight = glow_canvas.filter(ImageFilter.GaussianBlur(radius=12))
# Reduce glow opacity
for glow, factor in [(glow_wide, 0.45), (glow_tight, 0.35)]:
gpx = glow.load()
for y in range(size):
for x in range(size):
r, g, b, a = gpx[x, y]
gpx[x, y] = (r, g, b, int(a * factor))
# Composite: dark bg -> wide glow -> tight glow -> sharp chevron
result = Image.alpha_composite(dark_bg, glow_wide)
result = Image.alpha_composite(result, glow_tight)
chev_canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
chev_canvas.paste(chev, (ox, oy), chev)
result = Image.alpha_composite(result, chev_canvas)
return result
def make_dark_fallback(img: Image.Image) -> Image.Image:
"""Fallback: mathematical recomposition when Figma layer is unavailable."""
img = img.convert("RGBA")
w, h = img.size
pixels = img.load()
for y in range(h):
for x in range(w):
r, g, b, a = pixels[x, y]
if a == 0:
continue
max_dev = max(255 - r, 255 - g, 255 - b)
fg_alpha = min(max_dev / 60.0, 1.0)
bg_factor = 1.0 - fg_alpha
nr = max(0, int(r - bg_factor * (255 - DARK_BG[0])))
ng = max(0, int(g - bg_factor * (255 - DARK_BG[1])))
nb = max(0, int(b - bg_factor * (255 - DARK_BG[2])))
pixels[x, y] = (nr, ng, nb, a)
return img
def update_contents_json(icon_dir: str) -> None:
"""Add dark appearance entries to Contents.json."""
contents_path = os.path.join(icon_dir, "Contents.json")
with open(contents_path) as f:
contents = json.load(f)
# Remove any existing dark entries to avoid duplicates
images = [
img for img in contents["images"]
if not any(
ap.get("value") == "dark"
for ap in img.get("appearances", [])
)
]
dark_images = []
for img in images:
filename = img.get("filename", "")
if not filename:
continue
base, ext = os.path.splitext(filename)
dark_entry = {
"appearances": [
{"appearance": "luminosity", "value": "dark"}
],
"filename": f"{base}_dark{ext}",
"idiom": img["idiom"],
"scale": img["scale"],
"size": img["size"],
}
dark_images.append(dark_entry)
# Interleave: light, dark, light, dark, ...
merged = []
for i, img in enumerate(images):
merged.append(img)
if i < len(dark_images):
merged.append(dark_images[i])
contents["images"] = merged
with open(contents_path, "w") as f:
json.dump(contents, f, indent=2)
f.write("\n")
print(f" Updated {contents_path}")
def generate_dark_icons(icon_set: str) -> None:
"""Generate dark variants for an icon set."""
src_dir = os.path.join(REPO, "Assets.xcassets", f"{icon_set}.appiconset")
if not os.path.isdir(src_dir):
print(f"SKIP {icon_set} (not found)")
return
use_figma = os.path.exists(FIGMA_CHEVRON)
if use_figma:
print(f"\n{icon_set} (using Figma chevron layer):")
chevron = Image.open(FIGMA_CHEVRON)
light_1024_path = os.path.join(src_dir, "512@2x.png")
light_1024 = Image.open(light_1024_path)
dark_1024 = make_dark_from_figma(light_1024, chevron)
else:
print(f"\n{icon_set} (fallback: mathematical recomposition):")
dark_1024 = None
for filename, pixel_size in SIZES:
src_path = os.path.join(src_dir, filename)
if not os.path.exists(src_path):
print(f" SKIP {filename} (not found)")
continue
base, ext = os.path.splitext(filename)
dst_path = os.path.join(src_dir, f"{base}_dark{ext}")
if use_figma:
# Downscale the 1024x1024 Figma composite
dark_img = dark_1024.resize((pixel_size, pixel_size), Image.LANCZOS)
else:
img = Image.open(src_path)
if img.size != (pixel_size, pixel_size):
img = img.resize((pixel_size, pixel_size), Image.LANCZOS)
dark_img = make_dark_fallback(img)
dark_img.save(dst_path, "PNG")
print(f" {base}_dark{ext} ({pixel_size}x{pixel_size})")
update_contents_json(src_dir)
def main():
generate_dark_icons("AppIcon")
if __name__ == "__main__":
main()