feat(brand): replace the M mark with the traced bard-hat logo
test-web / test (push) Successful in 44s
android / Build + lint + test (push) Successful in 5m9s

The mark is now a feathered hat with an arc of eighth notes, traced from
the operator's reference artwork at 99.74% IoU. The hat takes the text
colour and the note arc holds the accent — the same construction the M
used, and for the same reason: parchment on a light surface is invisible,
so the silhouette has to flip with its background while the accent stays
constant.

This reverses the subject-neutrality argument recorded in Minstrel's
design system, which held that depicting a bard would tell a new user the
app is for renaissance-faire music and had twice rejected a hat. The
operator commissioned this artwork and chose it with that objection on the
table; the record is updated rather than silently contradicted.

Both accent-filled alternatives were measured and rejected: #4A6B5C is
3.04:1 on obsidian and 2.80:1 on the raised iron, so an accent hat drops
under the 3:1 graphics floor as soon as it sits on a card.

tools/gen-brand-assets.py is the single source for the four copies, which
cannot share a file because each needs a different colour mechanism —
currentColor inlined, a prefers-color-scheme swap in the favicon, literal
fills in mark.svg, flat pixels in the rasters. Hand-copying 20KB of path
data four ways is how a silhouette change lands in three of them.

Two notes on the trace, both non-obvious: it runs on the original
antialiased greyscale rather than a binary mask, because tracing a
supersampled mask scores ~100% IoU by reproducing the pixel staircase
exactly — a perfect number for jagged art at 120KB of path, versus 99.74%
at 20KB. And potrace reads PBM where bit 1 is black, so the ink mask is
inverted going in; backwards, it traces the background and still emits a
plausible-looking SVG.

The header lockup moves 20px → 28px: the hat carries far more detail than
the M and does not resolve below ~32px. The 16px browser-tab favicon is
still a blob at that size and is not addressed here.

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 12:52:57 -04:00
co-authored by Claude Opus 5
parent 0103953953
commit 5593f7ce17
20 changed files with 1090 additions and 94 deletions
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""Trace the Minstrel logo and emit every brand asset that derives from it.
Run by hand after the source artwork changes, never in CI — it needs potrace
and Pillow, which the build images don't carry, and its outputs are committed
(the app must render itself with no outbound network).
python3 tools/gen-brand-assets.py
Why a generator at all: the mark ships in four places that cannot share one
file, because each needs a different colour mechanism — currentColor in the
component, literal fills in mark.svg, a prefers-color-scheme swap in
favicon.svg, and flattened pixels in the rasters. Hand-copying ~20KB of path
data four ways is how a silhouette change lands in three of them and not the
fourth. The paths have one source here instead.
The trace pipeline, and why each step is what it is:
* Components are separated first (connected-component labelling) so the hat
and the note arc can take different fills. Grouping is by component id
against the source raster, which is stable as long as SOURCE doesn't move.
* Tracing runs on the ORIGINAL antialiased greyscale, upsampled and blurred
— not on the binary mask. Tracing the mask supersampled hits 100% IoU by
reproducing the pixel staircase exactly: a perfect score for jagged art
and a 120KB path. Smoothing first finds a sub-pixel boundary instead,
which is 99.74% IoU at 20KB.
* potrace reads PBM, where bit 1 is BLACK, so the ink mask is inverted on
the way in. Getting this backwards traces the background and still
produces a plausible-looking SVG.
"""
import re, shutil, subprocess, sys
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
ROOT = Path(__file__).resolve().parent.parent
SOURCE = ROOT / "docs/brand/minstrel-logo-source.png"
PARCHMENT = "#E8E4D8"
ACCENT = "#4A6B5C" # --fs-accent; the one row Minstrel overrides
OBSIDIAN = "#14171A" # plate for rasters — see the contrast note in README
# Component ids from the source raster: the hat body, brim, feather, band and
# jewel take the text colour; the noteheads and the arc swashes take the accent.
HAT = [396, 58, 251, 497, 442]
NOTES = [31, 207, 30, 247, 317, 135, 78, 161, 186, 107, 215]
FRAME = [1] # the source's rounded-rect border — deliberately NOT traced: an
# adaptive icon gets masked to the launcher's own shape, so a
# baked border would be clipped raggedly, and at header size it
# is noise around a mark that is already busy.
SUPERSAMPLE, BLUR = 4, 1.5
POTRACE = ["-s", "--flat", "-a", "1.334", "-O", "0.2", "-u", "4", "-t", "20"]
def components(gray):
"""Label 8-connected runs of ink. Two-pass union-find; no scipy here."""
ink = gray > 128
H, W = ink.shape
parent, labels, nxt = {}, np.zeros((H, W), np.int32), 1
def find(x):
r = x
while parent[r] != r:
r = parent[r]
while parent[x] != r:
parent[x], x = r, parent[x]
return r
for y in range(H):
if not ink[y].any():
continue
prev = labels[y - 1] if y else None
for x in np.nonzero(ink[y])[0]:
nb = [labels[y, x - 1]] if x and labels[y, x - 1] else []
if y:
nb += [prev[xx] for xx in (x - 1, x, x + 1) if 0 <= xx < W and prev[xx]]
if nb:
m = min(nb)
labels[y, x] = m
for n in nb:
rm, rn = find(m), find(n)
if rm != rn:
parent[max(rm, rn)] = min(rm, rn)
else:
parent[nxt] = nxt
labels[y, x] = nxt
nxt += 1
flat = np.array([0] + [find(i) for i in range(1, nxt)], np.int32)
return flat[labels]
def trace(labels, gray, ids, tmp):
keep = np.isin(labels, ids)
# Dilate the group mask so the component's own antialiased fringe survives;
# thresholding a hard-cut mask would shave a half-pixel off every edge.
grown = Image.fromarray((keep * 255).astype(np.uint8)).filter(ImageFilter.MaxFilter(5))
img = Image.fromarray(np.where(np.asarray(grown), gray, 0.0).astype(np.uint8))
H, W = labels.shape
img = img.resize((W * SUPERSAMPLE, H * 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 / "g.pbm")
subprocess.run(["potrace", *POTRACE, str(tmp / "g.pbm"), "-o", str(tmp / "g.svg")], check=True)
s = (tmp / "g.svg").read_text()
return (" ".join(re.findall(r'<path d="([^"]+)"', s)),
re.search(r'<g transform="([^"]+)"', s).group(1))
def wrap(d, width=78, indent=" " * 6):
"""Re-wrap path data so diffs stay reviewable instead of one endless line."""
out, line = [], ""
for tok in d.split():
if len(line) + len(tok) + 1 > width:
out.append(line)
line = tok
else:
line = f"{line} {tok}".strip()
out.append(line)
return ("\n" + indent).join(out)
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}'
f' <g transform="{tr}" fill-rule="evenodd">\n'
f' <path fill="{hat_fill}" d="{wrap(hat_d)}"/>\n'
f' <path fill="{notes_fill}" d="{wrap(notes_d)}"/>\n'
f' </g>\n</svg>\n')
def rasterize(svg_path, out, px, plate=None, radius_frac=0.0, art_frac=1.0):
"""Render the mark to a PNG, optionally on a rounded plate.
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
plate makes this worse, not better, because the accent is a dark colour.
"""
art = round(px * art_frac)
subprocess.run(["rsvg-convert", "-w", str(art), "-o", str(out), str(svg_path)], check=True)
mark = Image.open(out).convert("RGBA")
canvas = Image.new("RGBA", (px, px), (0, 0, 0, 0))
if plate:
r = round(px * radius_frac)
layer = Image.new("RGBA", (px, px), (0, 0, 0, 0))
ImageDraw.Draw(layer).rounded_rectangle([0, 0, px - 1, px - 1], radius=r, fill=plate)
canvas.alpha_composite(layer)
canvas.alpha_composite(mark, ((px - mark.width) // 2, (px - mark.height) // 2))
canvas.save(out)
def main():
tmp = ROOT / ".brand-tmp"
tmp.mkdir(exist_ok=True)
if not SOURCE.exists():
sys.exit(f"source artwork missing: {SOURCE}")
a = np.asarray(Image.open(SOURCE).convert("RGB")).astype(np.float32)
gray = 0.299 * a[:, :, 0] + 0.587 * a[:, :, 1] + 0.114 * a[:, :, 2]
labels = components(gray)
hat_d, tr = trace(labels, gray, HAT, tmp)
notes_d, _ = trace(labels, gray, NOTES, tmp)
# Tight viewBox around the traced art (frame excluded), in potrace's user
# space, which is source pixels x SUPERSAMPLE.
ys, xs = np.nonzero(np.isin(labels, HAT + NOTES))
pad = 6
x0, x1 = xs.min() - pad, xs.max() + pad
y0, y1 = ys.min() - pad, ys.max() + pad
S = SUPERSAMPLE
vb = f"{x0*S} {y0*S} {(x1-x0)*S} {(y1-y0)*S}"
w, h = (x1 - x0) * S, (y1 - y0) * S
(ROOT / "web/static/brand/mark.svg").write_text(svg_doc(
vb, tr, PARCHMENT, ACCENT, hat_d, notes_d,
head=" <!-- Generated by tools/gen-brand-assets.py — do not edit by hand.\n"
" 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,
head=" <!-- Generated by tools/gen-brand-assets.py — do not edit by hand.\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",
style=" <style>\n"
f" svg {{ color: {PARCHMENT}; }}\n"
f" @media (prefers-color-scheme: light) {{ svg {{ color: {OBSIDIAN}; }} }}\n"
" </style>\n"))
plated = ROOT / ".brand-tmp/plated.svg"
plated.write_text(svg_doc(vb, tr, PARCHMENT, ACCENT, hat_d, notes_d))
# 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)]:
rasterize(plated, ROOT / out, px, plate=OBSIDIAN, radius_frac=0.20, art_frac=0.88)
# 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"
for d, fg, legacy in [("mdpi", 108, 48), ("hdpi", 162, 72), ("xhdpi", 216, 96),
("xxhdpi", 324, 144), ("xxxhdpi", 432, 192)]:
rasterize(plated, res / f"mipmap-{d}/ic_launcher_foreground.png", fg, art_frac=0.58)
rasterize(plated, res / f"mipmap-{d}/ic_launcher.png", legacy,
plate=OBSIDIAN, radius_frac=0.20, art_frac=0.88)
(ROOT / "web/src/lib/components/MinstrelMark.svelte").write_text(
COMPONENT.format(vb=vb, tr=tr, w=w, h=h,
hat=wrap(hat_d, indent=" " * 6), notes=wrap(notes_d, indent=" " * 6),
accent=ACCENT))
print(f"viewBox {vb} aspect {w/h:.4f} hat {len(hat_d)/1024:.1f}KB notes {len(notes_d)/1024:.1f}KB")
shutil.rmtree(tmp)
COMPONENT = '''<script lang="ts">
// The Minstrel mark: a feathered bard's hat with an arc of notes overhead.
//
// Generated by tools/gen-brand-assets.py — do not edit the paths by hand.
// The same silhouette ships in web/static/brand/{{mark,favicon}}.svg and the
// Android launcher icons; regenerate all of them together.
//
// Inlined rather than <img src="mark.svg"> on purpose — an <img> cannot
// inherit currentColor, and inheriting it is the whole point: the hat takes
// the surrounding text colour, so it reads on both the dark and light
// palettes without a second asset. Parchment-on-parchment is invisible,
// which is exactly the bug a fixed fill would reintroduce.
//
// The notes keep the accent in both modes — one of the places the design
// system sanctions the accent (the wordmark).
//
// aria-hidden: every current use sits directly beside the words "Minstrel",
// so labelling it would make a screen reader announce the name twice. A
// STANDALONE use would need its own label.
let {{ size = 28, class: klass = '' }}: {{ size?: number; class?: string }} = $props();
</script>
<svg
viewBox="{vb}"
width={{size * {w} / {h}}}
height={{size}}
class={{klass}}
aria-hidden="true"
focusable="false"
>
<g transform="{tr}" fill-rule="evenodd">
<path fill="currentColor" d="{hat}"/>
<path fill="{accent}" d="{notes}"/>
</g>
</svg>
'''
if __name__ == "__main__":
main()