From 0766349397a15313c76cb7dcdcd787b17c31d573 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 15:19:10 -0400 Subject: [PATCH] feat(brand): the browser tab icon is the full logo (#5267) The tab icon was a hand-drawn reduced hat, made because the traced art loses detail at 16px. A redrawing reads as a different logo. The tab icon now uses the same traced mark as the header: a high-resolution screen draws a tab icon from 32px, where it holds, and at 16px it keeps the logo's shape. The drawn reduced mark had no other consumer, so it leaves the generator, and mark-small.svg (referenced nowhere) is removed. Regenerating changed only favicon.svg and favicon.png; every other brand asset is byte-identical. Co-Authored-By: Claude Opus 5.5 --- tools/gen-brand-assets.py | 129 +-------- web/static/brand/favicon.svg | 482 ++++++++++++++++++-------------- web/static/brand/mark-small.svg | 211 -------------- web/static/favicon.png | Bin 908 -> 1854 bytes 4 files changed, 283 insertions(+), 539 deletions(-) delete mode 100644 web/static/brand/mark-small.svg diff --git a/tools/gen-brand-assets.py b/tools/gen-brand-assets.py index 6f5b72c6..8a5c3c3b 100644 --- a/tools/gen-brand-assets.py +++ b/tools/gen-brand-assets.py @@ -120,103 +120,6 @@ def wrap(d, width=78, indent=" " * 6): return ("\n" + indent).join(out) -# --------------------------------------------------------------------------- -# The reduced mark, for small sizes. -# -# NOT a simplification of the traced art — that was tried and does not work. -# The source composition is dominated by a long diagonal plume, so every -# mechanical reduction of it (hole-filling, morphological smoothing, dropping -# components) collapses at 16px into a diagonal smear that reads as no object -# at all. What reads at that size is a strong horizontal brim under a leaning -# crown, which has to be DRAWN rather than derived. -# -# So this is hand-authored geometry, tuned against the real thing: the crown -# peaks left of centre with a long right flank, the brim rides up at the right -# tip, and a band slit keeps crown and brim from fusing into one lump. It is a -# family member of the full mark, not a copy of it. -# --------------------------------------------------------------------------- - -RN = 640 # design-space square for the reduced mark - - -def _bez(pts, n=60): - """Sample a chain of cubic beziers given as [P0, C1,C2,P1, C1,C2,P2, ...].""" - out = [pts[0]] - for i in range(1, len(pts), 3): - p0, (c1, c2, p1) = out[-1], pts[i:i + 3] - for t in np.linspace(0, 1, n)[1:]: - u = 1 - t - out.append((u**3 * p0[0] + 3*u*u*t * c1[0] + 3*u*t*t * c2[0] + t**3 * p1[0], - u**3 * p0[1] + 3*u*u*t * c1[1] + 3*u*t*t * c2[1] + t**3 * p1[1])) - return out - - -def _reduced(plume): - """The reduced hat. plume=False gives crown+brim only; the plume is taken - as the difference between the two so it can be filled separately.""" - im = Image.new("L", (RN, RN), 0) - d = ImageDraw.Draw(im) - by = 336 - # Brim: the single strongest horizontal in the mark, and the shape that - # says "hat" at 16px, so it stays thick and unbroken end to end. - rt = by - 74 - d.polygon(_bez([(38, by + 10), - (150, by - 58), (430, by - 72), (590, rt), - (614, rt + 10), (610, rt + 40), (586, rt + 54), - (450, by + 56), (150, by + 58), (38, by + 30), - (22, by + 26), (22, by + 16), (38, by + 10)]), fill=255) - # Crown: peak pushed left of centre with a long right flank — the lean is - # what keeps this recognisably the same hat as the traced one. - px, top = 260, by - 256 - d.polygon(_bez([(190, by + 16), - (182, by - 96), (px - 92, top + 66), (px - 40, top + 10), - (px - 6, top - 14), (px + 44, top + 10), (px + 62, top + 56), - (px + 108, top + 150), (422, by - 120), (440, by - 34), - (448, by - 4), (424, by + 18), (190, by + 16)]), fill=255) - if plume: - # A pointed leaf, deliberately short: a long thin plume is exactly what - # turns the whole mark into a diagonal bar at small sizes. - d.polygon(_bez([(404, by - 158), - (466, by - 240), (546, by - 296), (586, by - 310), - (580, by - 270), (532, by - 196), (458, by - 132)]), fill=255) - a = np.asarray(im) > 128 - # Band slit: across the crown base only. Never across the brim — breaking - # the brim costs more legibility than the band gap buys. - g = Image.new("L", (RN, RN), 0) - ImageDraw.Draw(g).polygon(_bez([(192, by - 30), - (256, by - 50), (376, by - 54), (438, by - 58), - (446, by - 32), (300, by - 26), (192, by - 6)]), fill=255) - return a & ~(np.asarray(g) > 128) - - -def trace_reduced(tmp): - """Trace the reduced hat and plume as two fills, on one shared viewBox.""" - full, hat_only = _reduced(True), _reduced(False) - plume_only = full & ~hat_only - ys, xs = np.nonzero(full) - pad = 8 - box = (xs.min() - pad, ys.min() - pad, xs.max() + pad, ys.max() + pad) - - def one(mask, name): - # Ink BRIGHT here, matching what trace() feeds potrace: the point() - # below is what inverts to PBM's ink-is-black. Passing an already-dark - # mask double-inverts and silently traces the background instead. - img = Image.fromarray((mask * 255).astype(np.uint8)) - img = img.resize((RN * SUPERSAMPLE, RN * SUPERSAMPLE), Image.BICUBIC) - img = img.filter(ImageFilter.GaussianBlur(BLUR)) - img.point(lambda p: 0 if p > 128 else 255).convert("1", dither=Image.NONE).save(tmp / f"{name}.pbm") - subprocess.run(["potrace", *POTRACE, str(tmp / f"{name}.pbm"), "-o", str(tmp / f"{name}.svg")], check=True) - s = (tmp / f"{name}.svg").read_text() - return (" ".join(re.findall(r'\n' f'{head}{style}' @@ -277,27 +180,19 @@ def main(): " Literal colours, for any consumer that cannot inline the SVG and\n" " therefore cannot supply a currentColor. -->\n")) - # The reduced mark, for anywhere the full art cannot resolve. - rhat_d, rplume_d, rtr, rvb = trace_reduced(tmp) - - (ROOT / "web/static/brand/mark-small.svg").write_text(svg_doc( - rvb, rtr, PARCHMENT, ACCENT, rhat_d, rplume_d, - head=" \n")) - - # Favicon uses the REDUCED mark: a browser tab renders it at 16px, and the - # full art is unreadable there. This is the one consumer whose size is not - # ours to choose, so it takes the form drawn for that size. + # The tab icon is the full mark too (#5267). A hand-drawn reduced hat used + # to stand in at tab size because the art loses detail at 16px, but a + # redrawing reads as a different logo. The full mark keeps the logo's own + # shape, and a tab on a high-resolution screen draws it from 32px, where it + # holds. Do not swap a redrawn form back in here. (ROOT / "web/static/brand/favicon.svg").write_text(svg_doc( - rvb, rtr, "currentColor", ACCENT, rhat_d, rplume_d, + vb, tr, "currentColor", ACCENT, hat_d, notes_d, head=" \n", + " tab strip, obsidian on a dark one. The notes hold the accent in\n" + " both (3.04:1 on obsidian, 5.43:1 on the light ground). -->\n", style=" - - - + + + diff --git a/web/static/brand/mark-small.svg b/web/static/brand/mark-small.svg deleted file mode 100644 index c5a4c380..00000000 --- a/web/static/brand/mark-small.svg +++ /dev/null @@ -1,211 +0,0 @@ - - - - - - - diff --git a/web/static/favicon.png b/web/static/favicon.png index d606be8cfd19e7363fedba16536d1a715a17d067..e605c6355e3f0e24daf43f20afd23d917c18b3c1 100644 GIT binary patch delta 1840 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 M07*qoM6N<$f>BYk+W-In