diff --git a/docs/explanations/2026-07-19-explanation-shared-app-icon-generator.html b/docs/explanations/2026-07-19-explanation-shared-app-icon-generator.html new file mode 100644 index 00000000..b675268e --- /dev/null +++ b/docs/explanations/2026-07-19-explanation-shared-app-icon-generator.html @@ -0,0 +1,843 @@ + + +
+ + +How five apps stopped shipping the stock Flutter icon — and why the +interesting part is a measurement, not a drawing.
+
+testsAndMisc 27f44ca · adoption commits in dufs-cloud 18a18a4,
+screen-locker 135545a, wake-alarm 64c25e6,
+diet-guard f6abf80, todo 0be0706
+
Five Flutter apps live across these repos: a file-server client
+(dufs_client), a gym tracker (workout_app), an alarm companion
+(wake_alarm_sync), a diet logger (diet_guard_app) and a notes app
+(todo). Each was scaffolded with flutter create, and none of them had
+ever been given an icon. That means every one of them shipped the identical stock
+Flutter artwork — five indistinguishable tiles on the same home screen.
A fresh Android project contains only legacy icons: one PNG per density
+bucket, at android/app/src/main/res/mipmap-{m,h,xh,xxh,xxxh}dpi/ic_launcher.png.
+Nothing else. In particular there was no
+mipmap-anydpi-v26/ic_launcher.xml in any of the five repos — verified before
+starting.
That file matters more than its obscure name suggests. Since Android 8, launchers
+prefer an adaptive icon: instead of one flat bitmap, you supply a
+background layer and a foreground layer, and the launcher composites them and applies
+its own mask — a circle on one device, a squircle on another, a teardrop on a third.
+Without that XML the system falls back to the legacy PNG and just crops it into a
+shape, which is why untouched Flutter apps look slightly wrong on modern launchers.
+Android 13 added a third optional layer, <monochrome>, which the system
+recolours to match the user's wallpaper-derived theme.
An adaptive icon's layers are larger than the visible result. The launcher is only +guaranteed to show roughly the inner 66% of the canvas; everything +outside that can be masked away, and how much is device-dependent. So art that looks +perfect at full size can lose a corner on a real phone.
+ +Five identical stock tiles; legacy + PNGs only; no adaptive descriptor.
+One family: shared charcoal field, + per-app accent glyph, full adaptive layers.
+ +Because "five icons" is a snapshot, and the sixth app is always coming. Hand-drawn +one-offs drift: a slightly different stroke weight here, a different background there, +and within a year there is no family left. The change therefore defines the family +once, in code, and lets each app contribute only two things: an accent colour +and a glyph.
+ +The design rule is simple to state:
+ +#1B1D21), one stroke weight (72px on a
+ 1024px canvas), one glyph per app in that app's own theme accent.
+Stating it is easy. Making it true is where the interesting problem lives, +and it is not a drawing problem — it is a measurement problem.
+ +Consider two glyphs drawn by hand on the same 1024×1024 canvas. A barbell is wide +and short. A cloud-with-arrow is narrow and tall, and the author naturally drew the +cloud near the top because that is where clouds go. Both are "roughly centred" in the +sense that the author eyeballed them — but their ink sits at different heights. +Put the finished icons in a launcher row and they visibly fail to line up.
+ +The naive fix is to nudge coordinates by hand until it looks right. That works for +five glyphs and collapses at the sixth, and it silently rots the moment anyone edits a +path.
+ +Instead, the glyph is rendered on its own on a transparent canvas, and the +bounding box of the actual non-transparent pixels is measured. The offset from that +box's centre to the canvas centre is then applied as a translation. The author never +positions anything; they only decide the shape.
+ +Two details make this work rather than merely sound clever:
+ +The widget below runs that pipeline step by step on the real glyph data from
+glyphs.py. Pick a glyph, then step through: draw it as authored, measure
+its ink box, compute the offset, apply it. Watch the safe-box verdict change as you go.
Three things are worth noticing while you play:
+ +x=268, the leftmost path coordinate, because 36px of stroke
+ half-width plus a round cap is real ink. This is precisely why the pipeline measures a
+ rasterised image instead of reading path geometry.Once a glyph is centred, three separate images are written from the same fragment. +They differ only in what is drawn behind and in what colour:
+ +diet_guard_app. The background is a
+ flat colour declared in pubspec.yaml, not an image, so it can never
+ mis-align with the foreground. The monochrome layer is the same glyph in white; the
+ system tints it.Two of the apps also build for Linux desktop, and there the whole Android mechanism
+is irrelevant — flutter_launcher_icons does not touch Linux at all. More
+surprisingly, Flutter's GTK runner never sets a window icon of its own: the generated
+my_application.cc sets a title and a default size and stops there, so the
+window inherits a generic placeholder.
GTK's idiomatic answer is not "point at a PNG file" but "name an icon and let the +theme resolve it". So the runner asks for an icon by name, and a small install +script puts PNGs under that name into the user's hicolor icon theme:
+ +| Piece | Role |
|---|---|
gtk_window_set_icon_name(window, "todo") |
+ The running window asks the theme for an icon called todo. |
linux/icons/<size>/todo.png |
+ Pre-rendered and committed, so the installer needs no image tooling. |
install_desktop_entry.sh |
+ Copies those into ~/.local/share/icons/hicolor/ and writes a
+ .desktop entry. |
StartupWMClass=dev.kuhy.todo |
+ Links the running window back to the launcher entry — see the trap below. |
StartupWMClass must match the
+ window's actual WM_CLASS, and the natural guess — the binary name — is
+ wrong. Running the built app and inspecting it with
+ xprop -id <wid> WM_CLASS returned "dev.kuhy.todo": GTK
+ derives it from the GApplication id in linux/CMakeLists.txt, not
+ from the executable. Guessing here fails silently — the launcher entry looks right and
+ the taskbar still shows a generic icon.
+The new package is python_pkg/app_icons/. Rather than walk the files
+alphabetically, here are the four concerns it separates.
style.py holds every number that makes the icons a set. Changing anything
+here restyles all five apps at once, which is the point:
CANVAS: Final[int] = 1024
+BACKGROUND: Final[str] = "#1B1D21"
+
+# Android masks an adaptive icon down to roughly the inner 66% of the canvas.
+SAFE_BOX: Final[int] = 560
+
+# Glyph negative space must stay at or above STROKE_WIDTH / 2, otherwise
+# adjacent strokes merge into a blob when scaled down to launcher size.
+STROKE_WIDTH: Final[int] = 72
+MIN_NEGATIVE_SPACE: Final[float] = STROKE_WIDTH / 2
+
+That last constant encodes a mistake made during the work. The first fork glyph had +its tines exactly 72px apart — one full stroke width — so the gaps between them closed +to zero and the shape rendered as a solid blob. The rule is now written down next to the +number that causes it.
+ +render.py is where the centring lives.
+build_svg assembles a document by wrapping the glyph fragment in a group
+that carries the shared attributes plus the computed translation:
f'<g fill="none" stroke="{accent}" stroke-width="{style.STROKE_WIDTH}" '
+f'stroke-linecap="round" stroke-linejoin="round" '
+f'transform="translate({dx:g},{dy:g})">'
+
+Because the shared attributes live on the wrapper, a glyph fragment is just paths —
+it inherits colour and weight and cannot accidentally disagree with the family. A
+fragment that genuinely needs something else (the filled cutlery silhouette) overrides
+locally and uses an {{ACCENT}} placeholder for its colour.
Then the measurement itself. Note that it renders, measures, and returns a +translation — it never touches scale:
+ +def centre_offset(glyph: Glyph, accent: str) -> tuple[float, float]:
+ probe = build_svg(glyph, accent, with_background=False)
+ with tempfile.TemporaryDirectory() as tmp:
+ png = Path(tmp) / "probe.png"
+ rasterise(probe, png, style.CANVAS)
+ width, height, x, y = _ink_bbox(png)
+ if width == 0 or height == 0:
+ raise EmptyGlyphError(glyph.name)
+ return style.CENTRE - (x + width / 2), style.CENTRE - (y + height / 2)
+
+The probe is drawn without the background — a charcoal rectangle would fill
+the canvas and every measurement would return the canvas itself. _ink_bbox
+shells out to ImageMagick's -trim and parses the resulting geometry.
The sibling function safe_box_overflow reuses the same measurement for a
+different question — not "where is the ink" but "is there too much of it":
return max(0, width - style.SAFE_BOX), max(0, height - style.SAFE_BOX)
+
+This is what turned an invisible problem into a visible one. On the first real run it +reported three overflows, the worst being the cloud at 86px too tall. None of them were +apparent at full size; all three would have been clipped on a phone.
+ +apps.py maps each app to its repo, accent and glyph. The accents are not
+invented — each is the app's own theme seed, so the launcher icon matches the app's UI:
| App | Accent | Where it comes from | Glyph |
|---|---|---|---|
| dufs_client | #5C6BC0 | colorSchemeSeed: Colors.indigo | cloud + arrow |
| workout_app | #FF4444 | its own Color(0xFFFF4444) | barbell |
| wake_alarm_sync | #FFA726 | seedColor: Colors.orange | clock |
| diet_guard_app | #26A69A | colorSchemeSeed: Colors.teal | shield + cutlery |
| todo | #7E57C2 | seedColor: Colors.deepPurple | checklist |
dufs_client and
+ workout_app both seed from Colors.indigo. The gym app already
+ defined its own crimson accent, so it took crimson and indigo stayed unique.The registry entry for dufs_client carries a warning comment, and it is
+worth explaining why:
key="dufs_client",
+# The live app: ~/dufs_client is a stale, remote-less checkout that
+# was superseded when dufs-cloud absorbed it (see testsAndMisc
+# CLAUDE.md). Same package name, so it is easy to icon the wrong one.
+repo=_HOME / "dufs-cloud" / "app",
+
+There were two checkouts with the same name:, the same
+applicationId, and the same directory layout. Only one had a git remote and
+recent commits. The first pass targeted the wrong one — and because it also built and
+installed an APK from there, the phone briefly received a five-week-old build of a live
+app. The stale checkout has since been deleted; the comment exists so the trap cannot be
+re-entered.
generate.py writes the three layers, then delegates the fiddly part:
_LAYERS = (
+ ("icon", True, None), # field + glyph
+ ("icon_foreground", False, None), # glyph only, transparent
+ ("icon_monochrome", False, style.MONOCHROME), # glyph only, white
+)
+
+Each layer is written as both SVG source and a 1024px PNG, so a build machine never
+needs rsvg-convert. flutter_launcher_icons then expands the PNGs
+into every density bucket and — the part worth outsourcing — writes the
+mipmap-anydpi-v26/ic_launcher.xml descriptor with all three layers wired up.
+Hand-rolling that for five apps is exactly the sort of repetitive work that goes wrong
+quietly.
rsvg-convert, magick, dart) is mocked in the test
+ suite, so the package reaches 100% line and branch coverage without any of them
+ installed — but that also means the tests cannot tell you whether an icon
+ looks right. That is what the preview subcommand is for: it
+ renders a contact sheet with three rows — full size, circle-masked, and downscaled to
+ 48dp — and the last word belongs to a screenshot of the actual phone launcher.
+Five questions on the reasoning behind the change. Click an option to check it. +Score: 0 / 0
+ + + + + + diff --git a/docs/explanations/index.html b/docs/explanations/index.html index b2444c40..7f29c990 100644 --- a/docs/explanations/index.html +++ b/docs/explanations/index.html @@ -30,8 +30,12 @@12 explanations · interactive walkthroughs of notable changes, newest first.
+13 explanations · interactive walkthroughs of notable changes, newest first.