feat(brand): draw a reduced mark so the favicon reads at 16px

The full hat does not resolve below ~32px, which the header worked around
by sizing up. A browser tab cannot: it renders the favicon at 16px and
does not ask. There the mark was a blob.

Deriving a small form from the traced art does not work, and this is the
non-obvious part. Hole-filling, morphological smoothing and dropping
components were all tried; every one of them preserves the overall
silhouette, and the overall silhouette — dominated by a long diagonal
plume — is precisely what fails. The result each time was a diagonal
smear that reads as no object at all.

So the reduced form is drawn rather than derived: a strong horizontal
brim under a crown that peaks left of centre, a band slit so the two do
not fuse, and a short pointed plume. Same lean and proportions as the
full mark, detail removed instead of minified.

favicon.svg and favicon.png now use it; apple-touch, icon-512 and the
Android launcher icons keep the full art, being large enough for it. The
plume carries the accent, which measures 3.04:1 on obsidian and 5.43:1 on
the light ground — both clear of the 3:1 graphics floor.

Also corrects the accent-on-iron figure in the generator's comment from
2.80:1 to 2.70:1. The real --fs-iron is #1E2228; 2.80 came from measuring
against a value I had guessed rather than read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
This commit is contained in:
2026-09-09 13:49:31 -04:00
co-authored by Claude Opus 5
parent 5593f7ce17
commit b06a1adfe8
4 changed files with 544 additions and 278 deletions
+123 -7
View File
@@ -120,6 +120,103 @@ 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'<path d="([^"]+)"', s)),
re.search(r'<g transform="([^"]+)"', s).group(1))
hat_d, tr = one(hat_only, "rhat")
plume_d, _ = one(plume_only, "rplume")
S = SUPERSAMPLE
vb = f"{box[0]*S} {box[1]*S} {(box[2]-box[0])*S} {(box[3]-box[1])*S}"
return hat_d, plume_d, tr, vb
def svg_doc(vb, tr, hat_fill, notes_fill, hat_d, notes_d, style="", head=""):
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{vb}">\n'
f'{head}{style}'
@@ -135,7 +232,7 @@ def rasterize(svg_path, out, px, plate=None, radius_frac=0.0, art_frac=1.0):
Rasters carry their own background because a PNG cannot answer to a colour
scheme and iOS composites the touch icon onto white regardless. Obsidian
rather than the raised iron: the accent clears the 3:1 graphics floor
against obsidian (3.04:1) and fails against iron (2.80:1). Lightening the
against obsidian (3.04:1) and fails against iron (2.70:1). Lightening the
plate makes this worse, not better, because the accent is a dark colour.
"""
art = round(px * art_frac)
@@ -180,13 +277,27 @@ def main():
" Literal colours, for any consumer that cannot inline the SVG and\n"
" therefore cannot supply a currentColor. -->\n"))
(ROOT / "web/static/brand/favicon.svg").write_text(svg_doc(
vb, tr, "currentColor", ACCENT, hat_d, notes_d,
# 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=" <!-- Generated by tools/gen-brand-assets.py — do not edit by hand.\n"
" The reduced mark, literal colours. For use at or below ~24px,\n"
" where the full art collapses into a diagonal smear. -->\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.
(ROOT / "web/static/brand/favicon.svg").write_text(svg_doc(
rvb, rtr, "currentColor", ACCENT, rhat_d, rplume_d,
head=" <!-- Generated by tools/gen-brand-assets.py — do not edit by hand.\n"
" The REDUCED mark, because a tab renders this at 16px.\n"
" The hat flips with the viewer's scheme because a favicon sits on\n"
" browser chrome we don't control: parchment would vanish on a light\n"
" tab strip, obsidian on a dark one. The notes hold the accent in\n"
" both — teal reads against either. -->\n",
" tab strip, obsidian on a dark one. The plume holds the accent in\n"
" both — teal reads against either (3.04:1 on obsidian, 5.43:1 on\n"
" the light ground). -->\n",
style=" <style>\n"
f" svg {{ color: {PARCHMENT}; }}\n"
f" @media (prefers-color-scheme: light) {{ svg {{ color: {OBSIDIAN}; }} }}\n"
@@ -198,10 +309,15 @@ def main():
# Web rasters: plate radius and art scale measured off the icons these
# replace, so the new mark sits where the old one did.
for out, px in [("web/static/brand/icon-512.png", 512),
("web/static/apple-touch-icon.png", 180),
("web/static/favicon.png", 32)]:
("web/static/apple-touch-icon.png", 180)]:
rasterize(plated, ROOT / out, px, plate=OBSIDIAN, radius_frac=0.20, art_frac=0.88)
# 32px fallback favicon: reduced art, same reasoning as favicon.svg.
plated_small = ROOT / ".brand-tmp/plated-small.svg"
plated_small.write_text(svg_doc(rvb, rtr, PARCHMENT, ACCENT, rhat_d, rplume_d))
rasterize(plated_small, ROOT / "web/static/favicon.png", 32,
plate=OBSIDIAN, radius_frac=0.20, art_frac=0.90)
# Adaptive icon foreground: transparent, art inside the 66/108dp safe zone
# so no launcher mask can clip it. The plate comes from the XML instead.
res = ROOT / "android/app/src/main/res"