feat(learn,train,todo,meditate): the icons on a white tile again, coloured by the system's theme on Android and iOS (ADR 0031, D9, D10)

D10 returns every icon to D1's white tile, D8's dark one undone, the
gradients as D5 gives them (cobalt holds about 5.7 : 1 against white).

D9 adds the variants the systems colour by their theme, all from the same
glyph at the same place, drawn by tool/app_icons.py and written into the
projects by flutter_launcher_icons: Android's monochrome layer, which
themed icons tint (Android 13 and later), and iOS's dark and tinted icons
(iOS 18 and later), the dark one keeping D8's lift of 30 % towards white
on iOS's own dark background. macOS needs an Icon Composer file made on a
Mac and keeps the plain icon until then.

Whats-New: The app's icon is white again, and takes the system's colours where Android or iOS themes its icons.
Whats-New-De: Das App-Symbol ist wieder weiß und nimmt die Farben des Systems an, wo Android oder iOS seine Symbole einfärbt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-09 23:12:13 +01:00
commit 551140621b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,9 +1,8 @@
#!/usr/bin/env python3
"""Makes the apps' icons, one family (ADR 0031): each app's Lucide glyph on a dark
square (TILE, the apps' dark background; D8), fading from cobalt through the colours
APPS names, each lifted towards white (LIFT) so the glyph stands out on the dark tile,
top left to bottom right across Lucide's 24-unit box, the stops evenly spaced, at
Lucide's stroke width (2),
"""Makes the apps' icons, one family (ADR 0031): each app's Lucide glyph on a white
square (TILE; D10, back from D8's dark one), fading from cobalt through the colours
APPS names, top left to bottom right across Lucide's 24-unit box, the stops evenly
spaced, at Lucide's stroke width (2),
1024 x 1024 pixels (<app>/<flutter package>/assets/icon/<icon>); the launchers and
the launch screens round its corners themselves. Then, for each app:
@ -14,9 +13,10 @@ the launch screens round its corners themselves. Then, for each app:
Snap Store's size), the icon with rounded corners, as other apps' tiles
have (radius 142.222 of 512, about 28 %);
- through flutter_launcher_icons, configured in the app's pubspec: Android's adaptive
icon and its older square one, iOS's, macOS's (the rounded tile on Apple's grid,
824 of 1024 pixels) and Windows' (the rounded tile), from the images derived in
the app's build/icons/, which git ignores;
icon with its monochrome layer, which themed icons tint (D9), and its older square
one, iOS's with its dark and tinted variants (D9), macOS's (the rounded tile on
Apple's grid, 824 of 1024 pixels) and Windows' (the rounded tile), from the images
derived in the app's build/icons/, which git ignores;
- Windows' .ico once more, with every size Windows shows (16 to 256 pixels): the tool
writes only the 256 one and leaves Windows to scale it down for its small views;
- the iOS project file as it was: the tool sets every build setting with
@ -61,11 +61,11 @@ REEF = "#1DA583"
ENZIAN = "#5252CC"
PURPLE = "#8080FF"
# ADR 0031, D8: the tile is halkyon_ui's dark surface, and every stop is mixed this
# share towards white, so the gradient's darkest part (cobalt) keeps above 4.5 : 1
# against it.
TILE = "#131520"
LIFT = 0.3
# ADR 0031, D10: the tile is white again (D1), the gradient as APPS gives it. iOS's
# dark icon (D9) sits on iOS's own dark background, so there every stop is mixed this
# share towards white (D8's lift), keeping cobalt above 4.5 : 1 against it.
TILE = "#FFFFFF"
DARK_LIFT = 0.3
@dataclass(frozen=True)
@ -138,18 +138,17 @@ def rgb(colour: str) -> tuple[float, float, float]:
return tuple(int(colour[i : i + 2], 16) / 255 for i in (1, 3, 5))
def lifted(colour: str) -> tuple[float, float, float]:
"""[colour] mixed LIFT towards white."""
return tuple(c + (1 - c) * LIFT for c in rgb(colour))
def lifted(colour: str, lift: float) -> tuple[float, float, float]:
"""[colour] mixed [lift] towards white."""
return tuple(c + (1 - c) * lift for c in rgb(colour))
def icon(glyph: str, stops: tuple[str, ...], svg: str) -> Image.Image:
"""[glyph] on TILE in the lifted gradient of [stops], SIZE pixels square."""
def glyph_mask(glyph: str, svg: str) -> cairo.ImageSurface:
"""[glyph]'s strokes alone, SIZE pixels square, where every icon draws it."""
if f"lucide-static v{LUCIDE}" not in svg:
raise SystemExit(f"Not lucide-static {LUCIDE}'s {glyph}.")
if 'viewBox="0 0 24 24"' not in svg or 'stroke-width="2"' not in svg:
raise SystemExit(f"{glyph} isn't drawn in Lucide's 24 units at width 2.")
# The strokes alone, as a mask the gradient shows through.
handle = Rsvg.Handle.new_from_data(svg.replace("currentColor", "#000").encode())
box = SIZE * GLYPH
offset = (SIZE - box) / 2
@ -160,21 +159,60 @@ def icon(glyph: str, stops: tuple[str, ...], svg: str) -> Image.Image:
handle.render_document(cairo.Context(mask), viewport)
except GLib.Error as error:
raise SystemExit(f"librsvg couldn't draw {glyph}: {error.message}")
return mask
tile = cairo.ImageSurface(cairo.FORMAT_RGB24, SIZE, SIZE)
context = cairo.Context(tile)
context.set_source_rgb(*rgb(TILE))
context.paint()
def gradient_of(stops: tuple[str, ...], lift: float = 0) -> cairo.LinearGradient:
"""The gradient of [stops], each mixed [lift] towards white, across the glyph's
box, top left to bottom right."""
box = SIZE * GLYPH
offset = (SIZE - box) / 2
gradient = cairo.LinearGradient(offset, offset, offset + box, offset + box)
for i, colour in enumerate(stops):
gradient.add_color_stop_rgb(i / (len(stops) - 1), *lifted(colour))
context.set_source(gradient)
context.mask_surface(mask)
gradient.add_color_stop_rgb(i / (len(stops) - 1), *lifted(colour, lift))
return gradient
def drawn(
mask: cairo.ImageSurface,
source: cairo.Pattern,
background: tuple[float, float, float] | None,
) -> Image.Image:
"""The glyph's [mask] filled with [source], on [background] or, without one,
on transparency."""
surface = cairo.ImageSurface(
cairo.FORMAT_ARGB32 if background is None else cairo.FORMAT_RGB24, SIZE, SIZE
)
context = cairo.Context(surface)
if background is not None:
context.set_source_rgb(*background)
context.paint()
context.set_source(source)
context.mask_surface(mask)
png = BytesIO()
tile.write_to_png(png)
surface.write_to_png(png)
png.seek(0)
return Image.open(png).convert("RGB")
return Image.open(png).convert("RGB" if background is not None else "RGBA")
def icon(mask: cairo.ImageSurface, stops: tuple[str, ...]) -> Image.Image:
"""The glyph's [mask] on TILE in the gradient of [stops]."""
return drawn(mask, gradient_of(stops), rgb(TILE))
def themed(mask: cairo.ImageSurface, stops: tuple[str, ...]) -> dict[str, Image.Image]:
"""The variants the systems colour themselves (ADR 0031, D9), by their file
names in build/icons/: Android's monochrome layer (the glyph in white on
transparency, which the launcher tints from the wallpaper), iOS's dark icon
(the glyph in its gradient, lifted by DARK_LIFT, on transparency, which iOS sets
on its own dark background) and iOS's tinted one (the glyph in white on black, a grayscale
image iOS tints)."""
white = cairo.SolidPattern(1, 1, 1)
return {
"monochrome.png": drawn(mask, white, None),
"ios_dark.png": drawn(mask, gradient_of(stops, DARK_LIFT), None),
"ios_tinted.png": drawn(mask, white, (0, 0, 0)),
}
def rounded(source: Image.Image, size: int) -> Image.Image:
@ -191,7 +229,8 @@ def rounded(source: Image.Image, size: int) -> Image.Image:
def make(app: AppIcon, license_text: str) -> None:
package = Path(app.folder) / app.package
source = icon(app.glyph, app.stops, fetch(f"icons/{app.glyph}.svg"))
mask = glyph_mask(app.glyph, fetch(f"icons/{app.glyph}.svg"))
source = icon(mask, app.stops)
path = package / "assets/icon" / app.icon
path.parent.mkdir(parents=True, exist_ok=True)
source.save(path, optimize=True)
@ -210,6 +249,8 @@ def make(app: AppIcon, license_text: str) -> None:
margin = (MACOS_CANVAS - MACOS_TILE) // 2
macos.alpha_composite(rounded(source, MACOS_TILE), (margin, margin))
macos.save(derived / "macos.png")
for name, picture in themed(mask, app.stops).items():
picture.save(derived / name)
ios_project = package / "ios/Runner.xcodeproj/project.pbxproj"
kept = ios_project.read_bytes()