mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 18:07:06 +01:00
Also bumps the 16 meta/requirements pins the freshness gate flagged (anthropic 1.5.0, numpy 2.5.3, pytest-randomly 5, flake8-bugbear 26.9.9, ruff 0.16.7, pyright 1.1.414, selenium 4.49.0, hypothesis 6.168.0, lxml 6.1.3, pipdeptree 4.2.5, the two types-* stubs) and Flutter 3.47.3 in meta/.fvmrc. One test still expected the pre-move ~/mcp-servers path. The pre-commit hook fed .zsh files to shfmt and shellcheck, neither of which parses zsh (SC2148/SC1071), so no zsh file could be committed since the hook was tightened on 2026-08-22; those now get `zsh -n` instead. The hook's own shfmt pass also staged its reformat of 8 shell files. CI_GREEN_SKIP because main is red on exactly the dependency-freshness finding this commit fixes, which is the documented use of that flag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
844 lines
40 KiB
HTML
844 lines
40 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>A shared launcher-icon generator for the Flutter apps</title>
|
||
<style>
|
||
:root {
|
||
--bg: #ffffff;
|
||
--fg: #1b1d21;
|
||
--muted: #5a6270;
|
||
--rule: #e2e6ec;
|
||
--card: #f6f8fa;
|
||
--code-bg: #f2f4f7;
|
||
--accent: #5c6bc0;
|
||
--ok: #1c7c54;
|
||
--bad: #b3261e;
|
||
--field: #1b1d21;
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--bg: #14161a;
|
||
--fg: #e6e9ee;
|
||
--muted: #a0a8b4;
|
||
--rule: #2b3038;
|
||
--card: #1b1e24;
|
||
--code-bg: #1e222a;
|
||
--accent: #8f9bea;
|
||
--ok: #4ac48b;
|
||
--bad: #ef8279;
|
||
--field: #0e1013;
|
||
}
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0 auto;
|
||
padding: 2rem 1rem 6rem;
|
||
max-width: min(48rem, 92vw);
|
||
background: var(--bg);
|
||
color: var(--fg);
|
||
font: 16px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||
"Helvetica Neue", Arial, sans-serif;
|
||
}
|
||
h1 { font-size: 1.9rem; line-height: 1.25; margin: 0 0 .4rem; letter-spacing: -.01em; }
|
||
h2 { font-size: 1.35rem; margin: 3rem 0 .75rem; padding-top: .5rem; border-top: 1px solid var(--rule); }
|
||
h3 { font-size: 1.05rem; margin: 2rem 0 .5rem; }
|
||
p, li { color: var(--fg); }
|
||
.lede { color: var(--muted); font-size: 1.05rem; margin-top: 0; }
|
||
a { color: var(--accent); }
|
||
code {
|
||
background: var(--code-bg); padding: .12em .35em; border-radius: 4px;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
font-size: .88em;
|
||
}
|
||
pre {
|
||
background: var(--code-bg); border: 1px solid var(--rule); border-radius: 8px;
|
||
padding: .85rem 1rem; overflow-x: auto; white-space: pre-wrap; word-break: break-word;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
font-size: .84rem; line-height: 1.55;
|
||
}
|
||
pre code { background: none; padding: 0; font-size: inherit; }
|
||
nav.toc {
|
||
background: var(--card); border: 1px solid var(--rule); border-radius: 10px;
|
||
padding: .85rem 1.1rem; margin: 1.5rem 0 2rem;
|
||
}
|
||
nav.toc ol { margin: .4rem 0 0; padding-left: 1.2rem; }
|
||
nav.toc li { margin: .15rem 0; }
|
||
.note {
|
||
background: var(--card); border-left: 3px solid var(--accent);
|
||
border-radius: 0 8px 8px 0; padding: .7rem 1rem; margin: 1.2rem 0;
|
||
}
|
||
.note strong { color: var(--fg); }
|
||
figure { margin: 1.5rem 0; }
|
||
figcaption { color: var(--muted); font-size: .85rem; margin-top: .6rem; }
|
||
.scroller { overflow-x: auto; }
|
||
/* ---- icon-family strip ---- */
|
||
.strip { display: flex; gap: .7rem; flex-wrap: wrap; }
|
||
.tile { text-align: center; font-size: .72rem; color: var(--muted); }
|
||
.tile .art {
|
||
width: 84px; height: 84px; border-radius: 18px; background: var(--field);
|
||
display: grid; place-items: center; margin-bottom: .3rem;
|
||
}
|
||
.tile svg { width: 62px; height: 62px; }
|
||
/* ---- before/after ---- */
|
||
.cols { display: grid; gap: 1rem; grid-template-columns: 1fr; }
|
||
@media (min-width: 34rem) { .cols { grid-template-columns: 1fr 1fr; } }
|
||
.panel { background: var(--card); border: 1px solid var(--rule); border-radius: 10px; padding: .9rem 1rem; }
|
||
.panel h4 { margin: 0 0 .5rem; font-size: .9rem; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
|
||
/* ---- layer stack diagram ---- */
|
||
.stack { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
|
||
.layer { text-align: center; font-size: .7rem; color: var(--muted); }
|
||
.layer .box {
|
||
width: 78px; height: 78px; border-radius: 14px; display: grid; place-items: center;
|
||
border: 1px solid var(--rule); margin-bottom: .25rem;
|
||
}
|
||
.plus { font-size: 1.4rem; color: var(--muted); }
|
||
.checker {
|
||
background-image:
|
||
linear-gradient(45deg, var(--rule) 25%, transparent 25%),
|
||
linear-gradient(-45deg, var(--rule) 25%, transparent 25%),
|
||
linear-gradient(45deg, transparent 75%, var(--rule) 75%),
|
||
linear-gradient(-45deg, transparent 75%, var(--rule) 75%);
|
||
background-size: 12px 12px;
|
||
background-position: 0 0, 0 6px, 6px -6px, -6px 0;
|
||
}
|
||
/* ---- micro-world ---- */
|
||
.world { background: var(--card); border: 1px solid var(--rule); border-radius: 12px; padding: 1rem; margin: 1.5rem 0; }
|
||
.world-grid { display: grid; gap: 1rem; grid-template-columns: 1fr; align-items: start; }
|
||
@media (min-width: 40rem) { .world-grid { grid-template-columns: minmax(0,300px) 1fr; } }
|
||
.stagebox { background: var(--field); border-radius: 14px; overflow: hidden; }
|
||
.stagebox svg { display: block; width: 100%; height: auto; }
|
||
.controls { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .7rem; }
|
||
button, select {
|
||
font: inherit; font-size: .85rem; padding: .4rem .75rem; border-radius: 7px;
|
||
border: 1px solid var(--rule); background: var(--bg); color: var(--fg); cursor: pointer;
|
||
}
|
||
button:hover:not(:disabled), select:hover { border-color: var(--accent); }
|
||
button:disabled { opacity: .45; cursor: default; }
|
||
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||
.readout { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .8rem; line-height: 1.7; }
|
||
.readout .k { color: var(--muted); }
|
||
.step-label { font-size: .85rem; margin: .1rem 0 .6rem; min-height: 3.2em; }
|
||
.pill { display: inline-block; padding: .05rem .45rem; border-radius: 999px; font-size: .75rem; }
|
||
.pill.ok { background: rgba(28,124,84,.14); color: var(--ok); }
|
||
.pill.bad { background: rgba(179,38,30,.14); color: var(--bad); }
|
||
/* ---- quiz ---- */
|
||
.q { border: 1px solid var(--rule); border-radius: 10px; padding: 1rem 1.1rem; margin: 1.1rem 0; background: var(--card); }
|
||
.q p.stem { margin: 0 0 .75rem; font-weight: 600; }
|
||
.opt {
|
||
display: block; width: 100%; text-align: left; margin: .35rem 0; padding: .55rem .8rem;
|
||
border: 1px solid var(--rule); border-radius: 8px; background: var(--bg); color: var(--fg);
|
||
font-size: .92rem; line-height: 1.45; cursor: pointer;
|
||
}
|
||
.opt:hover:not(.locked) { border-color: var(--accent); }
|
||
.opt.correct { border-color: var(--ok); background: rgba(28,124,84,.10); }
|
||
.opt.wrong { border-color: var(--bad); background: rgba(179,38,30,.10); }
|
||
.opt.locked { cursor: default; }
|
||
.why { font-size: .85rem; color: var(--muted); margin: .6rem 0 0; display: none; }
|
||
.why.show { display: block; }
|
||
#score { font-weight: 600; }
|
||
table { border-collapse: collapse; width: 100%; font-size: .88rem; }
|
||
th, td { text-align: left; padding: .45rem .6rem; border-bottom: 1px solid var(--rule); vertical-align: top; }
|
||
th { color: var(--muted); font-weight: 600; }
|
||
.swatch { display: inline-block; width: .8em; height: .8em; border-radius: 3px; margin-right: .35em; vertical-align: -1px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<h1>A shared launcher-icon generator for the Flutter apps</h1>
|
||
<p class="lede">How five apps stopped shipping the stock Flutter icon — and why the
|
||
interesting part is a measurement, not a drawing.</p>
|
||
<p style="color:var(--muted);font-size:.85rem;margin-top:-.6rem">
|
||
testsAndMisc <code>27f44ca</code> · adoption commits in dufs-cloud <code>18a18a4</code>,
|
||
screen-locker <code>135545a</code>, wake-alarm <code>64c25e6</code>,
|
||
diet-guard <code>f6abf80</code>, todo <code>0be0706</code>
|
||
</p>
|
||
|
||
<nav class="toc">
|
||
<strong>Contents</strong>
|
||
<ol>
|
||
<li><a href="#background">Background — what was there before</a></li>
|
||
<li><a href="#intuition">Intuition — measure, then translate</a></li>
|
||
<li><a href="#code">Code — a walkthrough by concern</a></li>
|
||
<li><a href="#quiz">Quiz</a></li>
|
||
</ol>
|
||
</nav>
|
||
|
||
<h2 id="background">1. Background — what was there before</h2>
|
||
|
||
<p>Five Flutter apps live across these repos: a file-server client
|
||
(<code>dufs_client</code>), a gym tracker (<code>workout_app</code>), an alarm companion
|
||
(<code>wake_alarm_sync</code>), a diet logger (<code>diet_guard_app</code>) and a notes app
|
||
(<code>todo</code>). Each was scaffolded with <code>flutter create</code>, 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.</p>
|
||
|
||
<h3>What a Flutter project gives you by default</h3>
|
||
|
||
<p>A fresh Android project contains only <em>legacy</em> icons: one PNG per density
|
||
bucket, at <code>android/app/src/main/res/mipmap-{m,h,xh,xxh,xxxh}dpi/ic_launcher.png</code>.
|
||
Nothing else. In particular there was no
|
||
<code>mipmap-anydpi-v26/ic_launcher.xml</code> in any of the five repos — verified before
|
||
starting.</p>
|
||
|
||
<p>That file matters more than its obscure name suggests. Since Android 8, launchers
|
||
prefer an <strong>adaptive icon</strong>: 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, <code><monochrome></code>, which the system
|
||
recolours to match the user's wallpaper-derived theme.</p>
|
||
|
||
<h3>The constraint nobody tells you about</h3>
|
||
|
||
<p>An adaptive icon's layers are larger than the visible result. The launcher is only
|
||
guaranteed to show roughly the inner <strong>66%</strong> 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.</p>
|
||
|
||
<figure>
|
||
<div class="cols">
|
||
<div class="panel">
|
||
<h4>Before</h4>
|
||
<p style="margin:.2rem 0 .6rem;font-size:.9rem">Five identical stock tiles; legacy
|
||
PNGs only; no adaptive descriptor.</p>
|
||
<div class="strip">
|
||
<div class="tile"><div class="art" style="background:#4fc3f7">
|
||
<svg viewBox="0 0 64 64" aria-hidden="true"><circle cx="46" cy="18" r="8" fill="#ffe082"/><path d="M4 52 L26 22 L48 52 Z" fill="#81c784"/><path d="M20 52 L36 34 L54 52 Z" fill="#66bb6a"/></svg>
|
||
</div>stock ×5</div>
|
||
</div>
|
||
</div>
|
||
<div class="panel">
|
||
<h4>After</h4>
|
||
<p style="margin:.2rem 0 .6rem;font-size:.9rem">One family: shared charcoal field,
|
||
per-app accent glyph, full adaptive layers.</p>
|
||
<div class="strip" id="familyStrip"></div>
|
||
</div>
|
||
</div>
|
||
<figcaption>The five apps before and after. Each accent is that app's own theme seed
|
||
colour, so the icon matches the first screen you see when the app opens.</figcaption>
|
||
</figure>
|
||
|
||
<h3>Why not just draw five icons?</h3>
|
||
|
||
<p>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
|
||
<em>once</em>, in code, and lets each app contribute only two things: an accent colour
|
||
and a glyph.</p>
|
||
|
||
<h2 id="intuition">2. Intuition — measure, then translate</h2>
|
||
|
||
<p>The design rule is simple to state:</p>
|
||
|
||
<div class="note">
|
||
<strong>One charcoal field (<code>#1B1D21</code>), one stroke weight (72px on a
|
||
1024px canvas), one glyph per app in that app's own theme accent.</strong>
|
||
</div>
|
||
|
||
<p>Stating it is easy. Making it <em>true</em> is where the interesting problem lives,
|
||
and it is not a drawing problem — it is a measurement problem.</p>
|
||
|
||
<h3>The problem: glyphs are not the same shape</h3>
|
||
|
||
<p>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 <em>ink</em> sits at different heights.
|
||
Put the finished icons in a launcher row and they visibly fail to line up.</p>
|
||
|
||
<p>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.</p>
|
||
|
||
<h3>The fix: let the renderer find the ink</h3>
|
||
|
||
<p>Instead, the glyph is rendered <em>on its own</em> 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.</p>
|
||
|
||
<p>Two details make this work rather than merely sound clever:</p>
|
||
|
||
<ul>
|
||
<li><strong>It measures rendered ink, not path geometry.</strong> A stroked line has
|
||
no area as a path, but 72px of width once drawn, plus round caps that extend past the
|
||
endpoints. Rasterising first means all of that is included automatically.</li>
|
||
<li><strong>It only ever translates — never scales.</strong> This is the load-bearing
|
||
restriction. Scaling a fragment to "fit" would scale its stroke too, so a small glyph
|
||
scaled up would render a fatter line than its neighbours, and the single shared stroke
|
||
weight — the thing that makes the icons feel like a family — would quietly break.
|
||
Translation is the only transform that preserves it.</li>
|
||
</ul>
|
||
|
||
<h3>Play with it</h3>
|
||
|
||
<p>The widget below runs that pipeline step by step on the real glyph data from
|
||
<code>glyphs.py</code>. 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.</p>
|
||
|
||
<div class="world">
|
||
<div class="world-grid">
|
||
<div>
|
||
<div class="stagebox">
|
||
<svg id="stage" viewBox="0 0 1024 1024" role="img" aria-label="Glyph centering simulation">
|
||
<g id="safeBoxG" opacity="0">
|
||
<rect x="232" y="232" width="560" height="560" fill="none"
|
||
stroke="#7f8794" stroke-width="4" stroke-dasharray="18 14"/>
|
||
<text x="240" y="222" fill="#7f8794" font-size="30"
|
||
font-family="ui-monospace, monospace">safe box 560</text>
|
||
</g>
|
||
<g id="crossG" opacity="0" stroke="#7f8794" stroke-width="3">
|
||
<line x1="512" y1="150" x2="512" y2="874"/>
|
||
<line x1="150" y1="512" x2="874" y2="512"/>
|
||
</g>
|
||
<g id="glyphWrap">
|
||
<g id="glyphG" fill="none" stroke="#8f9bea" stroke-width="72"
|
||
stroke-linecap="round" stroke-linejoin="round"></g>
|
||
</g>
|
||
<g id="inkG" opacity="0">
|
||
<rect id="inkRect" fill="none" stroke="#ffb74d" stroke-width="5" stroke-dasharray="14 10"/>
|
||
<circle id="inkDot" r="13" fill="#ffb74d"/>
|
||
</g>
|
||
<g id="arrowG" opacity="0">
|
||
<line id="offArrow" stroke="#ffb74d" stroke-width="6"/>
|
||
<circle cx="512" cy="512" r="13" fill="#4ac48b"/>
|
||
</g>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="controls">
|
||
<label class="sr" for="glyphPick" style="align-self:center;font-size:.85rem;color:var(--muted)">Glyph</label>
|
||
<select id="glyphPick">
|
||
<option value="barbell">barbell — wide and short</option>
|
||
<option value="cloud-down">cloud-down — tall, drawn high</option>
|
||
<option value="checklist">checklist — two rows</option>
|
||
<option value="clock">clock — nearly symmetric</option>
|
||
<option value="barbell-draft">barbell — careless draft (illustrative)</option>
|
||
<option value="cloud-oversized">cloud-down (pre-fix, oversized)</option>
|
||
</select>
|
||
<button id="stepBtn" class="primary">Step ▶</button>
|
||
<button id="resetBtn">Reset</button>
|
||
</div>
|
||
<p class="step-label" id="stepLabel"></p>
|
||
<div class="readout" id="readout"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<p>Three things are worth noticing while you play:</p>
|
||
|
||
<ul>
|
||
<li><strong>The corrections on the shipped glyphs are small.</strong> Barbell and clock
|
||
need none at all; cloud-down shifts 6px and checklist 8px. That is the honest result,
|
||
and it is the kind of misalignment that is invisible in isolation, obvious in a row of
|
||
five, and impossible to hold by hand across future edits. The
|
||
<em>careless draft</em> option shows what happens with genuinely sloppy placement —
|
||
that one is illustrative, not a committed glyph.</li>
|
||
<li><strong>The ink box is bigger than the paths.</strong> Select the barbell: the box
|
||
starts left of <code>x=268</code>, 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.</li>
|
||
<li><strong>Centring cannot rescue an oversized glyph.</strong> The last option is the
|
||
cloud <em>as first drawn</em>: 646px tall against a 560px budget. It fails the safe-box
|
||
check no matter where you put it, and the real fix was to redraw it smaller. The widget
|
||
reports it as over by 32×85px; the actual tool reported 33×86px.</li>
|
||
</ul>
|
||
|
||
<h3>Three layers out, not one</h3>
|
||
|
||
<p>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:</p>
|
||
|
||
<figure>
|
||
<div class="stack">
|
||
<div class="layer"><div class="box" style="background:#1b1d21"></div>background<br>flat colour</div>
|
||
<div class="plus">+</div>
|
||
<div class="layer"><div class="box checker" id="fgBox"></div>foreground<br>glyph, transparent</div>
|
||
<div class="plus">=</div>
|
||
<div class="layer"><div class="box" style="background:#1b1d21" id="composedBox"></div>what you see<br>after masking</div>
|
||
<div class="plus" style="opacity:.5">·</div>
|
||
<div class="layer"><div class="box checker" id="monoBox"></div>monochrome<br>Android 13 themed</div>
|
||
</div>
|
||
<figcaption>The adaptive layers for <code>diet_guard_app</code>. The background is a
|
||
flat colour declared in <code>pubspec.yaml</code>, 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.</figcaption>
|
||
</figure>
|
||
|
||
<h3>And on Linux, none of this applies</h3>
|
||
|
||
<p>Two of the apps also build for Linux desktop, and there the whole Android mechanism
|
||
is irrelevant — <code>flutter_launcher_icons</code> does not touch Linux at all. More
|
||
surprisingly, Flutter's GTK runner never sets a window icon of its own: the generated
|
||
<code>my_application.cc</code> sets a title and a default size and stops there, so the
|
||
window inherits a generic placeholder.</p>
|
||
|
||
<p>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 <em>by name</em>, and a small install
|
||
script puts PNGs under that name into the user's hicolor icon theme:</p>
|
||
|
||
<figure>
|
||
<div class="scroller">
|
||
<table>
|
||
<tr><th>Piece</th><th>Role</th></tr>
|
||
<tr><td><code>gtk_window_set_icon_name(window, "todo")</code></td>
|
||
<td>The running window asks the theme for an icon called <code>todo</code>.</td></tr>
|
||
<tr><td><code>linux/icons/<size>/todo.png</code></td>
|
||
<td>Pre-rendered and committed, so the installer needs no image tooling.</td></tr>
|
||
<tr><td><code>install_desktop_entry.sh</code></td>
|
||
<td>Copies those into <code>~/.local/share/icons/hicolor/</code> and writes a
|
||
<code>.desktop</code> entry.</td></tr>
|
||
<tr><td><code>StartupWMClass=dev.kuhy.todo</code></td>
|
||
<td>Links the running window back to the launcher entry — see the trap below.</td></tr>
|
||
</table>
|
||
</div>
|
||
</figure>
|
||
|
||
<div class="note">
|
||
<strong>A trap worth remembering.</strong> <code>StartupWMClass</code> must match the
|
||
window's actual <code>WM_CLASS</code>, and the natural guess — the binary name — is
|
||
wrong. Running the built app and inspecting it with
|
||
<code>xprop -id <wid> WM_CLASS</code> returned <code>"dev.kuhy.todo"</code>: GTK
|
||
derives it from the <em>GApplication id</em> in <code>linux/CMakeLists.txt</code>, not
|
||
from the executable. Guessing here fails silently — the launcher entry looks right and
|
||
the taskbar still shows a generic icon.
|
||
</div>
|
||
|
||
<h2 id="code">3. Code — a walkthrough by concern</h2>
|
||
|
||
<p>The new package is <code>python_pkg/app_icons/</code>. Rather than walk the files
|
||
alphabetically, here are the four concerns it separates.</p>
|
||
|
||
<h3>Concern 1: the family, defined once</h3>
|
||
|
||
<p><code>style.py</code> holds every number that makes the icons a set. Changing anything
|
||
here restyles all five apps at once, which is the point:</p>
|
||
|
||
<pre><code>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</code></pre>
|
||
|
||
<p>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.</p>
|
||
|
||
<h3>Concern 2: measuring and drawing</h3>
|
||
|
||
<p><code>render.py</code> is where the centring lives.
|
||
<code>build_svg</code> assembles a document by wrapping the glyph fragment in a group
|
||
that carries the shared attributes plus the computed translation:</p>
|
||
|
||
<pre><code>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})">'</code></pre>
|
||
|
||
<p>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 <code>{{ACCENT}}</code> placeholder for its colour.</p>
|
||
|
||
<p>Then the measurement itself. Note that it renders, measures, and returns a
|
||
translation — it never touches scale:</p>
|
||
|
||
<pre><code>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)</code></pre>
|
||
|
||
<p>The probe is drawn <em>without</em> the background — a charcoal rectangle would fill
|
||
the canvas and every measurement would return the canvas itself. <code>_ink_bbox</code>
|
||
shells out to ImageMagick's <code>-trim</code> and parses the resulting geometry.</p>
|
||
|
||
<p>The sibling function <code>safe_box_overflow</code> reuses the same measurement for a
|
||
different question — not "where is the ink" but "is there too much of it":</p>
|
||
|
||
<pre><code>return max(0, width - style.SAFE_BOX), max(0, height - style.SAFE_BOX)</code></pre>
|
||
|
||
<p>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.</p>
|
||
|
||
<h3>Concern 3: the registry, and a checkout that lied</h3>
|
||
|
||
<p><code>apps.py</code> 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:</p>
|
||
|
||
<figure>
|
||
<div class="scroller">
|
||
<table>
|
||
<tr><th>App</th><th>Accent</th><th>Where it comes from</th><th>Glyph</th></tr>
|
||
<tr><td>dufs_client</td><td><span class="swatch" style="background:#5C6BC0"></span>#5C6BC0</td><td><code>colorSchemeSeed: Colors.indigo</code></td><td>cloud + arrow</td></tr>
|
||
<tr><td>workout_app</td><td><span class="swatch" style="background:#FF4444"></span>#FF4444</td><td>its own <code>Color(0xFFFF4444)</code></td><td>barbell</td></tr>
|
||
<tr><td>wake_alarm_sync</td><td><span class="swatch" style="background:#FFA726"></span>#FFA726</td><td><code>seedColor: Colors.orange</code></td><td>clock</td></tr>
|
||
<tr><td>diet_guard_app</td><td><span class="swatch" style="background:#26A69A"></span>#26A69A</td><td><code>colorSchemeSeed: Colors.teal</code></td><td>shield + cutlery</td></tr>
|
||
<tr><td>todo</td><td><span class="swatch" style="background:#7E57C2"></span>#7E57C2</td><td><code>seedColor: Colors.deepPurple</code></td><td>checklist</td></tr>
|
||
</table>
|
||
</div>
|
||
<figcaption>One collision had to be resolved: <code>dufs_client</code> and
|
||
<code>workout_app</code> both seed from <code>Colors.indigo</code>. The gym app already
|
||
defined its own crimson accent, so it took crimson and indigo stayed unique.</figcaption>
|
||
</figure>
|
||
|
||
<p>The registry entry for <code>dufs_client</code> carries a warning comment, and it is
|
||
worth explaining why:</p>
|
||
|
||
<pre><code>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 / "src/dufs-cloud" / "app",</code></pre>
|
||
|
||
<p>There were two checkouts with the same <code>name:</code>, the same
|
||
<code>applicationId</code>, 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.</p>
|
||
|
||
<h3>Concern 4: handing off to the platform</h3>
|
||
|
||
<p><code>generate.py</code> writes the three layers, then delegates the fiddly part:</p>
|
||
|
||
<pre><code>_LAYERS = (
|
||
("icon", True, None), # field + glyph
|
||
("icon_foreground", False, None), # glyph only, transparent
|
||
("icon_monochrome", False, style.MONOCHROME), # glyph only, white
|
||
)</code></pre>
|
||
|
||
<p>Each layer is written as both SVG source and a 1024px PNG, so a build machine never
|
||
needs <code>rsvg-convert</code>. <code>flutter_launcher_icons</code> then expands the PNGs
|
||
into every density bucket and — the part worth outsourcing — writes the
|
||
<code>mipmap-anydpi-v26/ic_launcher.xml</code> descriptor with all three layers wired up.
|
||
Hand-rolling that for five apps is exactly the sort of repetitive work that goes wrong
|
||
quietly.</p>
|
||
|
||
<div class="note">
|
||
<strong>A verification note.</strong> Every external tool
|
||
(<code>rsvg-convert</code>, <code>magick</code>, <code>dart</code>) 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
|
||
<em>looks</em> right. That is what the <code>preview</code> 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.
|
||
</div>
|
||
|
||
<h2 id="quiz">4. Quiz</h2>
|
||
<p>Five questions on the reasoning behind the change. Click an option to check it.
|
||
Score: <span id="score">0 / 0</span></p>
|
||
<div id="quiz"></div>
|
||
|
||
<script>
|
||
(function () {
|
||
"use strict";
|
||
|
||
// ---- Real glyph fragments, transcribed from python_pkg/app_icons/glyphs.py ----
|
||
var GLYPHS = {
|
||
"barbell": {
|
||
note: "Wide and short; authored close to the canvas centre already.",
|
||
body: '<path d="M 268 512 L 756 512"/>' +
|
||
'<path d="M 364 356 L 364 668"/>' +
|
||
'<path d="M 660 356 L 660 668"/>' +
|
||
'<path d="M 268 432 L 268 592"/>' +
|
||
'<path d="M 756 432 L 756 592"/>'
|
||
},
|
||
"cloud-down": {
|
||
note: "Cloud drawn high with the arrow beneath; ink sits above centre.",
|
||
body: '<g transform="translate(296,196) scale(18)" stroke-width="4">' +
|
||
'<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 ' +
|
||
'2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 ' +
|
||
'0-2.64-2.05-4.78-4.65-4.96z"/></g>' +
|
||
'<path d="M 512 628 L 512 744"/>' +
|
||
'<path d="M 444 676 L 512 744 L 580 676"/>'
|
||
},
|
||
"checklist": {
|
||
note: "Two ticked rows; the widest glyph in the family.",
|
||
body: '<path d="M 272 372 L 346 446 L 478 314"/>' +
|
||
'<path d="M 594 400 L 752 400"/>' +
|
||
'<path d="M 272 620 L 346 694 L 478 562"/>' +
|
||
'<path d="M 594 648 L 752 648"/>'
|
||
},
|
||
"clock": {
|
||
note: "A circle plus hands; already near-symmetric about the centre.",
|
||
body: '<circle cx="512" cy="512" r="244"/>' +
|
||
'<path d="M 512 512 L 512 350"/>' +
|
||
'<path d="M 512 512 L 636 512"/>'
|
||
},
|
||
"barbell-draft": {
|
||
note: "NOT a committed glyph - the barbell shifted up and left, to show what the pipeline does with genuinely careless placement.",
|
||
body: '<path d="M 208 392 L 696 392"/>' +
|
||
'<path d="M 304 236 L 304 548"/>' +
|
||
'<path d="M 600 236 L 600 548"/>' +
|
||
'<path d="M 208 312 L 208 472"/>' +
|
||
'<path d="M 696 312 L 696 472"/>'
|
||
},
|
||
"cloud-oversized": {
|
||
note: "The cloud as FIRST drawn, before the safe-box warning forced a redraw.",
|
||
body: '<g transform="translate(252,116) scale(21.67)" stroke-width="3.32">' +
|
||
'<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 ' +
|
||
'2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 ' +
|
||
'0-2.64-2.05-4.78-4.65-4.96z"/></g>' +
|
||
'<path d="M 512 620 L 512 776"/>' +
|
||
'<path d="M 436 700 L 512 776 L 588 700"/>'
|
||
}
|
||
};
|
||
|
||
var CANVAS = 1024, CENTRE = 512, SAFE_BOX = 560, STROKE = 72;
|
||
|
||
// ---- Decorative family strip (Background section) ----
|
||
var FAMILY = [
|
||
{ name: "dufs_client", accent: "#5C6BC0", g: "cloud-down" },
|
||
{ name: "workout_app", accent: "#FF4444", g: "barbell" },
|
||
{ name: "wake_alarm", accent: "#FFA726", g: "clock" },
|
||
{ name: "diet_guard", accent: "#26A69A", g: "checklist" },
|
||
{ name: "todo", accent: "#7E57C2", g: "checklist" }
|
||
];
|
||
|
||
function glyphSvg(bodyHtml, accent, translate) {
|
||
var t = translate ? ' transform="translate(' + translate[0] + ',' + translate[1] + ')"' : "";
|
||
return '<svg viewBox="0 0 1024 1024" aria-hidden="true">' +
|
||
'<g fill="none" stroke="' + accent + '" stroke-width="' + STROKE +
|
||
'" stroke-linecap="round" stroke-linejoin="round"' + t + '>' +
|
||
bodyHtml + '</g></svg>';
|
||
}
|
||
|
||
var strip = document.getElementById("familyStrip");
|
||
if (strip) {
|
||
FAMILY.forEach(function (app) {
|
||
var d = document.createElement("div");
|
||
d.className = "tile";
|
||
d.innerHTML = '<div class="art">' + glyphSvg(GLYPHS[app.g].body, app.accent) + "</div>" +
|
||
app.name;
|
||
strip.appendChild(d);
|
||
});
|
||
}
|
||
|
||
// Layer-stack diagram thumbnails
|
||
var teal = "#26A69A";
|
||
var fgBox = document.getElementById("fgBox");
|
||
if (fgBox) fgBox.innerHTML = glyphSvg(GLYPHS.checklist.body, teal).replace("<svg", '<svg style="width:64px;height:64px"');
|
||
var composed = document.getElementById("composedBox");
|
||
if (composed) composed.innerHTML = glyphSvg(GLYPHS.checklist.body, teal).replace("<svg", '<svg style="width:64px;height:64px"');
|
||
var mono = document.getElementById("monoBox");
|
||
if (mono) mono.innerHTML = glyphSvg(GLYPHS.checklist.body, "#ffffff").replace("<svg", '<svg style="width:64px;height:64px"');
|
||
|
||
// ---- Micro-world: the centring pipeline ----
|
||
var glyphG = document.getElementById("glyphG");
|
||
var inkG = document.getElementById("inkG");
|
||
var inkRect = document.getElementById("inkRect");
|
||
var inkDot = document.getElementById("inkDot");
|
||
var arrowG = document.getElementById("arrowG");
|
||
var offArrow = document.getElementById("offArrow");
|
||
var crossG = document.getElementById("crossG");
|
||
var safeBoxG = document.getElementById("safeBoxG");
|
||
var label = document.getElementById("stepLabel");
|
||
var readout = document.getElementById("readout");
|
||
var stepBtn = document.getElementById("stepBtn");
|
||
var resetBtn = document.getElementById("resetBtn");
|
||
var picker = document.getElementById("glyphPick");
|
||
|
||
var step = 0, measured = null;
|
||
|
||
// Approximates what the Python does: rasterise, then trim to non-transparent
|
||
// pixels. Here we take the geometric box and inflate by half a stroke width
|
||
// (plus the round cap), which is what the stroke actually adds to the ink.
|
||
function measureInk() {
|
||
var b = glyphG.getBBox();
|
||
var pad = STROKE / 2;
|
||
return {
|
||
x: Math.round(b.x - pad),
|
||
y: Math.round(b.y - pad),
|
||
w: Math.round(b.width + STROKE),
|
||
h: Math.round(b.height + STROKE)
|
||
};
|
||
}
|
||
|
||
function loadGlyph() {
|
||
glyphG.setAttribute("transform", "translate(0,0)");
|
||
glyphG.innerHTML = GLYPHS[picker.value].body;
|
||
measured = measureInk();
|
||
step = 0;
|
||
render();
|
||
}
|
||
|
||
function render() {
|
||
inkG.setAttribute("opacity", step >= 1 ? "1" : "0");
|
||
crossG.setAttribute("opacity", step >= 2 ? "1" : "0");
|
||
arrowG.setAttribute("opacity", step === 2 ? "1" : "0");
|
||
safeBoxG.setAttribute("opacity", step >= 3 ? "1" : "0");
|
||
|
||
var m = measured;
|
||
var dx = CENTRE - (m.x + m.w / 2);
|
||
var dy = CENTRE - (m.y + m.h / 2);
|
||
|
||
// Steps 0-2 show the glyph as authored; step 3 applies the translation.
|
||
var applied = step >= 3;
|
||
glyphG.setAttribute("transform", applied ? "translate(" + dx + "," + dy + ")" : "translate(0,0)");
|
||
|
||
var bx = applied ? m.x + dx : m.x;
|
||
var by = applied ? m.y + dy : m.y;
|
||
inkRect.setAttribute("x", bx); inkRect.setAttribute("y", by);
|
||
inkRect.setAttribute("width", m.w); inkRect.setAttribute("height", m.h);
|
||
inkDot.setAttribute("cx", bx + m.w / 2); inkDot.setAttribute("cy", by + m.h / 2);
|
||
offArrow.setAttribute("x1", m.x + m.w / 2); offArrow.setAttribute("y1", m.y + m.h / 2);
|
||
offArrow.setAttribute("x2", CENTRE); offArrow.setAttribute("y2", CENTRE);
|
||
|
||
var overW = Math.max(0, m.w - SAFE_BOX), overH = Math.max(0, m.h - SAFE_BOX);
|
||
var fits = (overW === 0 && overH === 0);
|
||
|
||
var LABELS = [
|
||
"<strong>Step 0 — as authored.</strong> The glyph fragment exactly as it appears in <code>glyphs.py</code>. Nobody positioned it carefully; the author only chose the shape.",
|
||
"<strong>Step 1 — measure the ink.</strong> Rendered alone on a transparent canvas, then trimmed to the non-transparent pixels. Note the box is bigger than the paths: stroke width and round caps count as ink.",
|
||
"<strong>Step 2 — compute the offset.</strong> The vector from the ink box's centre (orange) to the canvas centre (green). This is the whole of <code>centre_offset</code>'s return value.",
|
||
"<strong>Step 3 — translate.</strong> Applied as <code>transform=\"translate(dx,dy)\"</code>. The stroke width is untouched, which is the point: a scale here would break the family's shared weight."
|
||
];
|
||
label.innerHTML = LABELS[step];
|
||
|
||
var rows =
|
||
'<div><span class="k">ink box </span> ' + m.w + " × " + m.h +
|
||
' at (' + m.x + ", " + m.y + ")</div>" +
|
||
'<div><span class="k">ink centre</span> (' + (m.x + m.w / 2) + ", " + (m.y + m.h / 2) + ")</div>" +
|
||
'<div><span class="k">canvas ctr</span> (512, 512)</div>' +
|
||
'<div><span class="k">offset </span> dx=' + dx + " dy=" + dy + "</div>" +
|
||
'<div><span class="k">safe box </span> 560 → ' +
|
||
(fits ? '<span class="pill ok">fits</span>'
|
||
: '<span class="pill bad">over by ' + overW + "×" + overH + "px</span>") +
|
||
"</div>" +
|
||
'<div style="margin-top:.5rem;color:var(--muted)">' + GLYPHS[picker.value].note + "</div>";
|
||
readout.innerHTML = rows;
|
||
|
||
stepBtn.disabled = step >= 3;
|
||
stepBtn.textContent = step >= 3 ? "Done" : "Step ▶";
|
||
}
|
||
|
||
if (stepBtn) {
|
||
stepBtn.addEventListener("click", function () { if (step < 3) { step++; render(); } });
|
||
resetBtn.addEventListener("click", function () { step = 0; render(); });
|
||
picker.addEventListener("change", loadGlyph);
|
||
loadGlyph();
|
||
}
|
||
|
||
// ---- Quiz ----
|
||
// Correct-answer positions below are 2, 0, 3, 1, 2 — spread across four
|
||
// distinct indices. Option lengths are kept within a narrow band and the
|
||
// correct option is never the longest in its question.
|
||
var QUESTIONS = [
|
||
{
|
||
stem: "Why does the centring step translate the glyph rather than scale it to fit?",
|
||
options: [
|
||
"Because an SVG group that already carries its own transform cannot be given a second one",
|
||
"Because scaling would push the glyph outside the safe box that the launcher mask guarantees",
|
||
"Because scaling changes the rendered stroke width, so icons would stop sharing one weight",
|
||
"Because scaling forces the rasteriser to resample, which visibly softens the glyph's edges"
|
||
],
|
||
correct: 2,
|
||
why: "Stroke width scales with the fragment. The single shared stroke weight is what makes the five icons read as a family, so only translation is safe."
|
||
},
|
||
{
|
||
stem: "The probe used for measurement is rendered without the charcoal background. Why does that matter?",
|
||
options: [
|
||
"The background fills the whole canvas, so the measured box would just be the canvas",
|
||
"The background is a flat colour and rasterising it would noticeably slow every run",
|
||
"The background is declared in pubspec.yaml, so rendering it here would duplicate it",
|
||
"The background would tint the glyph's edge pixels and shift the measured box slightly"
|
||
],
|
||
correct: 0,
|
||
why: "Trimming looks for non-transparent pixels. A full-canvas rectangle is non-transparent everywhere, so every glyph would measure 1024x1024 and no offset would ever be computed."
|
||
},
|
||
{
|
||
stem: "What problem does MIN_NEGATIVE_SPACE (half the stroke width) exist to prevent?",
|
||
options: [
|
||
"Glyph ink spilling past the safe box and being cropped by the launcher's mask",
|
||
"The monochrome layer losing contrast once the system recolours it for themed icons",
|
||
"Two apps in the same launcher row ending up with accents that are hard to tell apart",
|
||
"Nearby strokes closing the gap between them and merging into a blob when scaled down"
|
||
],
|
||
correct: 3,
|
||
why: "It came from a real failure: a fork whose tines sat exactly one stroke width apart had zero gap left and rendered as a solid shape."
|
||
},
|
||
{
|
||
stem: "Why did the Linux desktop path require editing my_application.cc, when Android needed no runner changes?",
|
||
options: [
|
||
"Because the GTK runner loads its icon from a file path that differs per distribution",
|
||
"Because Flutter's GTK runner never sets a window icon at all, unlike the Android manifest",
|
||
"Because Linux icons must be embedded into the binary rather than read from a theme file",
|
||
"Because flutter_launcher_icons emits a Linux icon that the runner then has to register itself"
|
||
],
|
||
correct: 1,
|
||
why: "The generated runner only sets a title and default size. Android reads the icon from resources declared in the manifest; GTK has to be told explicitly."
|
||
},
|
||
{
|
||
stem: "StartupWMClass was set to the GApplication id instead of the binary name. What made that necessary?",
|
||
options: [
|
||
"The .desktop specification requires a reverse-DNS style identifier in that particular field",
|
||
"The binary name collides with another package already installed in the hicolor theme",
|
||
"GTK derives the window's WM_CLASS from the application id, as xprop confirmed at runtime",
|
||
"The Flutter build renames the executable, so its name is not stable between releases"
|
||
],
|
||
correct: 2,
|
||
why: "Checking the running window with xprop returned WM_CLASS = \"dev.kuhy.todo\". Guessing the binary name would fail silently: the entry looks fine but the taskbar icon stays generic."
|
||
}
|
||
];
|
||
|
||
var quizEl = document.getElementById("quiz");
|
||
var answered = 0, right = 0;
|
||
var scoreEl = document.getElementById("score");
|
||
|
||
QUESTIONS.forEach(function (q, qi) {
|
||
var card = document.createElement("div");
|
||
card.className = "q";
|
||
var stem = document.createElement("p");
|
||
stem.className = "stem";
|
||
stem.textContent = (qi + 1) + ". " + q.stem;
|
||
card.appendChild(stem);
|
||
|
||
var why = document.createElement("p");
|
||
why.className = "why";
|
||
|
||
q.options.forEach(function (text, oi) {
|
||
var b = document.createElement("button");
|
||
b.className = "opt";
|
||
b.type = "button";
|
||
b.textContent = text;
|
||
b.addEventListener("click", function () {
|
||
if (card.dataset.done) return;
|
||
card.dataset.done = "1";
|
||
answered++;
|
||
if (oi === q.correct) right++;
|
||
Array.prototype.forEach.call(card.querySelectorAll(".opt"), function (o, i) {
|
||
o.classList.add("locked");
|
||
if (i === q.correct) o.classList.add("correct");
|
||
else if (i === oi) o.classList.add("wrong");
|
||
});
|
||
why.textContent = (oi === q.correct ? "Correct. " : "Not quite. ") + q.why;
|
||
why.classList.add("show");
|
||
scoreEl.textContent = right + " / " + answered;
|
||
});
|
||
card.appendChild(b);
|
||
});
|
||
|
||
card.appendChild(why);
|
||
quizEl.appendChild(card);
|
||
});
|
||
})();
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|