feat(brand): the browser tab icon is the full logo (#5267)
release / govulncheck (push) Successful in 47s
release / web (push) Successful in 1m15s
release / go (push) Successful in 2m19s
release / integration (push) Successful in 5m34s
release / android (push) Successful in 6m10s
release / Build signed APK (releases and dev) (push) Successful in 6m23s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m30s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 47s
release / web (push) Successful in 1m15s
release / go (push) Successful in 2m19s
release / integration (push) Successful in 5m34s
release / android (push) Successful in 6m10s
release / Build signed APK (releases and dev) (push) Successful in 6m23s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m30s
release / Verify release artifacts (tag releases only) (push) Skipped
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 <noreply@anthropic.com>
This commit is contained in:
+11
-118
@@ -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'<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}'
|
||||
@@ -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=" <!-- 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.
|
||||
# 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=" <!-- 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 full mark, at tab size.\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 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",
|
||||
" 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=" <style>\n"
|
||||
f" svg {{ color: {PARCHMENT}; }}\n"
|
||||
f" @media (prefers-color-scheme: light) {{ svg {{ color: {OBSIDIAN}; }} }}\n"
|
||||
@@ -312,10 +207,8 @@ def main():
|
||||
("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,
|
||||
# 32px fallback favicon: the full mark, same reasoning as favicon.svg.
|
||||
rasterize(plated, 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
|
||||
|
||||
Reference in New Issue
Block a user