From b06a1adfe814d8b81e0cb1c07bf62387690314cf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 9 Sep 2026 13:49:31 -0400 Subject: [PATCH] feat(brand): draw a reduced mark so the favicon reads at 16px MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH --- tools/gen-brand-assets.py | 130 ++++++++- web/static/brand/favicon.svg | 481 ++++++++++++++------------------ web/static/brand/mark-small.svg | 211 ++++++++++++++ web/static/favicon.png | Bin 1801 -> 908 bytes 4 files changed, 544 insertions(+), 278 deletions(-) create mode 100644 web/static/brand/mark-small.svg diff --git a/tools/gen-brand-assets.py b/tools/gen-brand-assets.py index 2c20b0a0..6f5b72c6 100644 --- a/tools/gen-brand-assets.py +++ b/tools/gen-brand-assets.py @@ -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'\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=" \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=" \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=" - - - + + + diff --git a/web/static/brand/mark-small.svg b/web/static/brand/mark-small.svg new file mode 100644 index 00000000..c5a4c380 --- /dev/null +++ b/web/static/brand/mark-small.svg @@ -0,0 +1,211 @@ + + + + + + + diff --git a/web/static/favicon.png b/web/static/favicon.png index fc98152af5a47f9e7c81b4ba4b2f0d7d23483df7..d606be8cfd19e7363fedba16536d1a715a17d067 100644 GIT binary patch delta 886 zcmV-+1Bv{J4vYtoBYy)^NklzU1(Eh7{`C_IgyjJN&1yGX-?Mfbk@cX{AerA z32IRg2D=(~KEG1Ir?GJgm$(~ z(xlDjE{a*xCe1-K75!hG^E}V{{Qu{D-}8UZ5iEkmYELbWy?;(j_`PD`y;Ml@Y#dUq zM4?1Tg(hDtLaYS&LP+^b-XQ@>v4Ij>Md1ZVlEBg0z`8x{$gAXxobFAND@Fs(b{9ou zCgwsBCOzYs;Y7($>$46P?+qZSFer-!e%xEd4p=}L9%v*kJ zF3QtiACinGUR^+QQ+?LB5Q49}4x!g80LVHW&JGunEPtiRpY*z!3rBMH)+E4EX~~&$ zHr8|Oo1^G-GKqMcfjj;DJM3d_Cc;3ko7rG^+1`0Kp)AYX@4J;(<{W|NVMiZUFk z9aXJPZ+}qG=mWp?{)EY7%$l1Chnbw5CJ+b`nh6sO1u>gUtadh1Y&0TCV#%0O-94N> zdmhMR#Z{FRY*_Ci8jJILpPNUI2YB{u7|)mwkJrbG7gNMyF#v4VDpr5kNJra7wzh4i zsK}7BuPpz69xR!GH7(8T+q;YH+uEqFt3e3C$$##1ob9=QL$&dN!$ED0ifXr_+N)9R zHfn1e>9t=Du~?jom#^}}iPLG}!eX_jBuS!k*T;N*@BmE>wMzz0O$RXO6%<9uOOg!( zgMV}V=FeQaaT~us^Dxo@?Emc3oVE({wyPD1M1rBAQJxGwfK@JO+ppYUKa!I)og&+s`4`2otZkECz9smFU M07*qoM6N<$f|=B_rT_o{ delta 1786 zcmVD(TSCHfb7L$J&Rj6VowM;|v|6F=aB*natGMnrIXs^%)TYM8Q`?9)j{% zU_oG)-FtWU_78O9$`VPZ!|$(q&pqe&{hV`t=l8ou;1P>7$A74gw*5b?tD{wT_+1=J zaeo@(NFT-uLj1ns`ap@8w4`C}!BL?Ig5YPwcS8uH71v2Rl4qr3FiJQE>@-&2ATZbn zfZ~t|F-IVX&{5xt;&A$ml_Ud8@)oe**&LD*C$RaQzjEPX6#$yi66=f-)|L)B8(RsF zi$<|K=ouIUKz|Sf3_*doWCc(O3^C#uaNu#fDae10x8E!xBqSJ76xp?BuUC9j0D6;z zF)Rq#=D^-(LvNA@i-|(EJJ3pc+=`3v31;+0iTd)Z=nQ)7_XZF}k)pzttozw2>gpRY z7z{{~fivgIeEg3HAO-|r3=2XG5V5vch)gh}I258&6Mry8hEQK|6;&PDJ=LS4IAj!u z6PK)z^h6>{bLX>a+mP*p=#3J6mL9sB+eczWuh#=mur!Z3IX?uT@%9~bIvoN6XU<(@#`Fy0 z<6<5UAP54%<}f@SmG+x=aJ$_j3QtW-VP8oxUw@rH%JpUV%@QxQdxTlI~Y zqoYYqO2A_2!qVAATS*?+Kpo%h@LanZDOSeTkJ3Afu#^PN@_6DCk~ zsg|0{*Ql(n^(#IMK=pVYJo$7A3kzlczFjz+GUvW5XT$n+K7~i4(J=F=45}|(AwE8q zsi~9E>9jOA-(~mT_ae*khyXstC^#yNzRn)PW23O%?eunfV{IX?uU>(};lyCj`+s#* zR$j%eyCGGKmf~^NS{Jc;mgdLIUTJ|gVoy0PglRf z`i)!s133Eahyfys%|Tpj3^S&u<8rxi$})PL4uIZ!ecY^TpsKot^B2mgxpD(lRhgZg zNl0iAf86vIudn?zzx~b6`O)$v-dMTSaGM>)A9DJvZ#5kDLlPbyM&WBO(|_L4#esug z@a~^Ca{6o;ef|9?_mA0~yKUU6Z$hWjk-Oj-3YHFS(VC)Pk(M@zx8EotD|0#k9*>6) z_I$$bkM`nn4bQ4R04b9v@{?Cz;=T7jz-sLwDl(i?rTYQsw)S%H^CKKMcm$Kt#PWhX z^7H0nG8s8|sFdQJB`AuEZGT%gkvZc@?`mz>w3YK0%17KnFFwCp? zclV4SJ!IY(ShH#c?-zgMldt1q#}gVH#EUO1B_lo6=j^-PZhp6U8-Jf2K1OhmiS64q zla)E0s_I()wyT7S%1hrVJPaUpaxz7QuhMPpB{?ac^eL$%CdBz2G%#S}tqq&GSYA!; z!nv&fWf8Tt*IBvd7kqu`%BX}Nn0E^n<<8?TTQ=U$-~LTiRZg5d%cuK4BO^VHX&F_p?B1s0Hj&}PX7b~i1xzkE#XBTJAm($qP{6C5M z0Ko8ziAEE^IJ22?<|wpU4K|ydyKU`{4XE#HT^+68M%7qe%^rxuL;nMh-de{(+y`K+ cARg8DFPUSzNQisAGynhq07*qoM6N<$f>)z=fB*mh