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:
+123
-7
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user