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.
This commit is contained in:
Lawrence Chen 2026-03-01 03:57:09 -08:00 committed by GitHub
parent bc1b6fd9eb
commit 8378bbeaa2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 565 additions and 46 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View file

@ -6,59 +6,179 @@
"scale": "1x",
"size": "16x16"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "16_dark.png",
"idiom": "mac",
"scale": "1x",
"size": "16x16"
},
{
"filename": "16@2x.png",
"idiom": "mac",
"scale": "2x",
"size": "16x16"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "16@2x_dark.png",
"idiom": "mac",
"scale": "2x",
"size": "16x16"
},
{
"filename": "32.png",
"idiom": "mac",
"scale": "1x",
"size": "32x32"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "32_dark.png",
"idiom": "mac",
"scale": "1x",
"size": "32x32"
},
{
"filename": "32@2x.png",
"idiom": "mac",
"scale": "2x",
"size": "32x32"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "32@2x_dark.png",
"idiom": "mac",
"scale": "2x",
"size": "32x32"
},
{
"filename": "128.png",
"idiom": "mac",
"scale": "1x",
"size": "128x128"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "128_dark.png",
"idiom": "mac",
"scale": "1x",
"size": "128x128"
},
{
"filename": "128@2x.png",
"idiom": "mac",
"scale": "2x",
"size": "128x128"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "128@2x_dark.png",
"idiom": "mac",
"scale": "2x",
"size": "128x128"
},
{
"filename": "256.png",
"idiom": "mac",
"scale": "1x",
"size": "256x256"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "256_dark.png",
"idiom": "mac",
"scale": "1x",
"size": "256x256"
},
{
"filename": "256@2x.png",
"idiom": "mac",
"scale": "2x",
"size": "256x256"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "256@2x_dark.png",
"idiom": "mac",
"scale": "2x",
"size": "256x256"
},
{
"filename": "512.png",
"idiom": "mac",
"scale": "1x",
"size": "512x512"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "512_dark.png",
"idiom": "mac",
"scale": "1x",
"size": "512x512"
},
{
"filename": "512@2x.png",
"idiom": "mac",
"scale": "2x",
"size": "512x512"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "512@2x_dark.png",
"idiom": "mac",
"scale": "2x",
"size": "512x512"
}
],
"info": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "AppIconDark.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "AppIconLight.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -6249,9 +6249,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
private func ensureApplicationIcon() {
let mode = AppIconSettings.resolvedMode()
if mode == .automatic {
// Let the asset catalog handle appearance-based icon selection.
if let icon = NSImage(named: NSImage.applicationIconName) {
NSApplication.shared.applicationIconImage = icon
}
} else {
AppIconSettings.applyIcon(mode)
}
}
private func scheduleLaunchServicesBundleRegistration(

View file

@ -2601,6 +2601,60 @@ enum AppearanceSettings {
}
}
enum AppIconMode: String, CaseIterable, Identifiable {
case automatic
case light
case dark
var id: String { rawValue }
var displayName: String {
switch self {
case .automatic: return "Automatic"
case .light: return "Light"
case .dark: return "Dark"
}
}
var imageName: String? {
switch self {
case .automatic: return nil
case .light: return "AppIconLight"
case .dark: return "AppIconDark"
}
}
}
enum AppIconSettings {
static let modeKey = "appIconMode"
static let defaultMode: AppIconMode = .automatic
static func resolvedMode(defaults: UserDefaults = .standard) -> AppIconMode {
guard let raw = defaults.string(forKey: modeKey),
let mode = AppIconMode(rawValue: raw) else {
return defaultMode
}
return mode
}
static func applyIcon(_ mode: AppIconMode) {
switch mode {
case .automatic:
// Let the asset catalog handle appearance-based icon selection (macOS 15+).
// Reset to the default bundle icon.
NSApplication.shared.applicationIconImage = nil
case .light:
if let icon = NSImage(named: "AppIconLight") {
NSApplication.shared.applicationIconImage = icon
}
case .dark:
if let icon = NSImage(named: "AppIconDark") {
NSApplication.shared.applicationIconImage = icon
}
}
}
}
enum QuitWarningSettings {
static let warnBeforeQuitKey = "warnBeforeQuitShortcut"
static let defaultWarnBeforeQuit = true
@ -2661,6 +2715,7 @@ struct SettingsView: View {
private let pickerColumnWidth: CGFloat = 196
@AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue
@AppStorage(AppIconSettings.modeKey) private var appIconMode = AppIconSettings.defaultMode.rawValue
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
@AppStorage(ClaudeCodeIntegrationSettings.hooksEnabledKey)
private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled
@ -2834,6 +2889,16 @@ struct SettingsView: View {
SettingsCardDivider()
AppIconPickerRow(
selectedMode: appIconMode,
onSelect: { mode in
appIconMode = mode.rawValue
AppIconSettings.applyIcon(mode)
}
)
SettingsCardDivider()
SettingsCardRow(
"New Workspace Placement",
subtitle: selectedWorkspacePlacement.description,
@ -3526,6 +3591,8 @@ struct SettingsView: View {
private func resetAllSettings() {
appearanceMode = AppearanceSettings.defaultMode.rawValue
appIconMode = AppIconSettings.defaultMode.rawValue
AppIconSettings.applyIcon(.automatic)
socketControlMode = SocketControlSettings.defaultMode.rawValue
claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled
sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry
@ -3740,6 +3807,79 @@ private struct SettingsCardNote: View {
}
}
private struct AppIconPickerRow: View {
let selectedMode: String
let onSelect: (AppIconMode) -> Void
private let iconSize: CGFloat = 48
private let autoIconSize: CGFloat = 36
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("App Icon")
.font(.system(size: 13, weight: .medium))
HStack(spacing: 12) {
ForEach(AppIconMode.allCases) { mode in
let isSelected = selectedMode == mode.rawValue
Button {
onSelect(mode)
} label: {
VStack(spacing: 6) {
Group {
if mode == .automatic {
// Show both icons overlapping
ZStack {
Image("AppIconLight")
.resizable()
.interpolation(.high)
.frame(width: autoIconSize, height: autoIconSize)
.clipShape(RoundedRectangle(cornerRadius: autoIconSize * 0.22, style: .continuous))
.offset(x: -10)
Image("AppIconDark")
.resizable()
.interpolation(.high)
.frame(width: autoIconSize, height: autoIconSize)
.clipShape(RoundedRectangle(cornerRadius: autoIconSize * 0.22, style: .continuous))
.offset(x: 10)
}
.frame(width: iconSize, height: iconSize)
} else {
Image(mode.imageName ?? "AppIconLight")
.resizable()
.interpolation(.high)
.frame(width: iconSize, height: iconSize)
.clipShape(RoundedRectangle(cornerRadius: iconSize * 0.22, style: .continuous))
}
}
Text(mode.displayName)
.font(.system(size: 11))
.foregroundColor(isSelected ? .primary : .secondary)
}
.padding(.vertical, 8)
.padding(.horizontal, 12)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(isSelected
? Color.accentColor.opacity(0.12)
: Color.clear)
)
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
)
}
.buttonStyle(.plain)
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct ShortcutSettingRow: View {
let action: KeyboardShortcutSettings.Action
@State private var shortcut: StoredShortcut

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

229
scripts/generate_dark_icon.py Executable file
View file

@ -0,0 +1,229 @@
#!/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()