Minstrel's mark is now the feathered bard's hat, traced from the operator's reference artwork at 99.74% IoU. The hat takes the surrounding text colour and the note arc carries the accent — the silhouette has to flip with its background, because parchment on a light surface is invisible.
This reverses the subject-neutrality argument recorded in Minstrel's design system, which had twice rejected a hat on the grounds that it would tell a new user the app is for renaissance-faire music. Adopted deliberately with that objection on the table; both design-system records were updated rather than left contradicting what ships.
The full art does not resolve below ~32px, so the header moved 20px → 28px and the favicon uses a separate drawn reduced form. Deriving one from the traced art does not work — hole-filling, smoothing and dropping components all preserve the overall silhouette, and the silhouette is what fails.
Fonts
Both clients fetched their typefaces at runtime: the web from fonts.googleapis.com, Android through the Play Services font provider. A deployed instance has no outbound network, so those requests never arrive and the UI renders in fallback faces. Android had a second failure mode — the provider is absent entirely on devices without Play Services.
Invisible in development, which is why it survived: dev machines have internet.
tools/vendor-fonts.py now produces both — 32 subsetted woff2 for web (0.82 MB, embedded in the Go binary) and 5 static TTF for Android (0.86 MB of APK). The runtime path is removed, not kept as a fallback: the ui-text-google-fonts dependency, its catalog entry and font_certs.xml are all gone.
Two traps, both of which fail by succeeding, and both now asserted in the script:
Format is chosen by User-Agent, with no parameter to override it. A modern UA gets woff2, which res/font cannot load; the obvious "use an old UA" fix gets EOT, an IE-only format that downloads happily and is useless here.
css2 collapses a multi-weight request to 400 for legacy clients, so asking for Medium returns Regular — a valid TrueType file rendering at the wrong weight everywhere. OS/2 usWeightClass is the only field that distinguishes them.
Guards
web/src/lib/no-external-assets.test.ts and android/.../BundledFontsTest.kt. Both pin the property rather than a vendor name, and both were falsified against the pre-change sources before committing.
The Android one reads its expectations out of Typography.kt itself — every Font(R.font.X, FontWeight.WN) declaration must have a vendored X.ttf that is really TrueType and really reports N — so it cannot drift from what the app declares.
What this merge does and does not ship
release.yml moves :latest on a main push, so the web changes go live on merge. The Android changes do not: no APK is built here, and :latest rebundles the most recent release's signed APK. The bundled typefaces and new launcher icons reach devices only on a vYYYY.MM.DD tag.
Five commits. CI green on `c27f9d48`.
## The mark
Minstrel's mark is now the feathered bard's hat, traced from the operator's reference artwork at 99.74% IoU. The hat takes the surrounding text colour and the note arc carries the accent — the silhouette has to flip with its background, because parchment on a light surface is invisible.
This reverses the subject-neutrality argument recorded in Minstrel's design system, which had twice rejected a hat on the grounds that it would tell a new user the app is for renaissance-faire music. Adopted deliberately with that objection on the table; both design-system records were updated rather than left contradicting what ships.
The full art does not resolve below ~32px, so the header moved 20px → 28px and the favicon uses a separate **drawn** reduced form. Deriving one from the traced art does not work — hole-filling, smoothing and dropping components all preserve the overall silhouette, and the silhouette is what fails.
## Fonts
Both clients fetched their typefaces at runtime: the web from `fonts.googleapis.com`, Android through the Play Services font provider. A deployed instance has no outbound network, so those requests never arrive and the UI renders in fallback faces. Android had a second failure mode — the provider is absent entirely on devices without Play Services.
Invisible in development, which is why it survived: dev machines have internet.
`tools/vendor-fonts.py` now produces both — 32 subsetted woff2 for web (0.82 MB, embedded in the Go binary) and 5 static TTF for Android (0.86 MB of APK). The runtime path is removed, not kept as a fallback: the `ui-text-google-fonts` dependency, its catalog entry and `font_certs.xml` are all gone.
Two traps, both of which fail by succeeding, and both now asserted in the script:
- **Format is chosen by User-Agent**, with no parameter to override it. A modern UA gets woff2, which `res/font` cannot load; the obvious "use an old UA" fix gets **EOT**, an IE-only format that downloads happily and is useless here.
- **`css2` collapses a multi-weight request to 400** for legacy clients, so asking for Medium returns Regular — a valid TrueType file rendering at the wrong weight everywhere. `OS/2 usWeightClass` is the only field that distinguishes them.
## Guards
`web/src/lib/no-external-assets.test.ts` and `android/.../BundledFontsTest.kt`. Both pin the property rather than a vendor name, and both were falsified against the pre-change sources before committing.
The Android one reads its expectations out of `Typography.kt` itself — every `Font(R.font.X, FontWeight.WN)` declaration must have a vendored `X.ttf` that is really TrueType and really reports `N` — so it cannot drift from what the app declares.
## What this merge does and does not ship
`release.yml` moves `:latest` on a `main` push, so the **web** changes go live on merge. The **Android** changes do not: no APK is built here, and `:latest` rebundles the most recent release's signed APK. The bundled typefaces and new launcher icons reach devices only on a `vYYYY.MM.DD` tag.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
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
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
app.html linked its stylesheet straight from fonts.googleapis.com, with
preconnects to that host and fonts.gstatic.com. A deployed instance has
no outbound network, so those requests never arrive and the whole UI
renders in fallback faces — Georgia for the display face, whatever the
system has for Inter and JetBrains Mono.
This is invisible in development, which is why it survived: the dev
machine has internet, so the fonts load and everything looks right. Only
a real deployment shows the failure.
tools/vendor-fonts.py fetches the three families once and writes them
under web/static/fonts with a generated stylesheet. static/ is copied
into the SvelteKit build, which Go embeds, so the faces travel inside the
binary. 32 woff2 files, 912K.
Two details that matter for correctness rather than size:
Urls in the generated CSS are relative (./Inter-400-latin.woff2), not
absolute. A url() resolves against the stylesheet's own address, so the
directory keeps working when the app is served under a base path;
/fonts/... would not.
Every subset Google slices is kept, with unicode-range intact. The
browser still fetches only the ranges a page uses, so this costs
repository bytes rather than request bytes — and a library full of
Cyrillic or Greek artist names renders instead of falling back mid-list.
The guard asserts the property, not the vendor: any absolute url in a
resource-loading attribute fails, whoever hosts it, since naming Google
would pass the day someone reached for a different CDN. It also checks
preconnect separately (those carry no fetch of their own, so the url
check misses them), strips HTML comments before asserting an absence so
prose describing the forbidden thing cannot satisfy the check, and pins
the font families to tokens.json rather than a hardcoded list.
Falsified against the pre-change app.html: it trips both the external-url
and preconnect assertions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
Typography.kt resolved Fraunces, Inter and JetBrains Mono through the Play
Services font provider, which fetches them over the network on first use.
Same rule-164 problem the web client had, with a second failure mode on
top: the provider is absent entirely on devices without Play Services, so
the app fell back to the platform default and stopped looking like
Minstrel — quietly, with no error.
The five static instances now live in res/font, vendored by the same
tools/vendor-fonts.py that produces the web bundle. Both clients draw
from one list of faces so they cannot drift apart. Cost is ~0.86 MB of
APK; the runtime path is removed rather than kept as a fallback — the
ui-text-google-fonts dependency, its version-catalog entry and the
provider certificate hashes in font_certs.xml are all gone.
Two things about fetching TTFs that are worth writing down, because both
fail by succeeding:
Google Fonts picks the format from the User-Agent, and there is no
parameter to ask for one. A modern UA gets woff2, which res/font cannot
load. The obvious "use an old UA" fix gets EOT — an IE-only format that
downloads happily, has a plausible size, and is entirely useless here. An
Android 4.4 UA is what actually yields TrueType.
css2 also collapses a multi-weight request to 400 for legacy clients, so
asking for Medium silently returns Regular: a valid TrueType file that
renders at the wrong weight everywhere. Each weight is therefore fetched
on its own URL, and the script now asserts OS/2 usWeightClass on every
download — that field is the only thing distinguishing the two files.
Verified before wiring: all five carry TrueType magic, the 400/500 pairs
differ, and their usWeightClass reads 400/400/500/500/400 as declared
beside them in the FontFamily.
Not covered: there is no guard for this on the Android side. The web
equivalent is asserted by no-external-assets.test.ts, but the Android
tree has no source-inspection test pattern to follow and no way to
falsify one without a local Gradle run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
The web side has no-external-assets.test.ts; Android had nothing, so the
font provider could come back with no test noticing. This is the Android
half.
Expectations are read out of Typography.kt rather than hardcoded, which
is what makes it a structural pin instead of a list that rots: the guard
extracts every Font(R.font.X, FontWeight.WN) declaration and checks that
X.ttf exists, is really TrueType, and reports N as its OS/2
usWeightClass. Add a face without vendoring it and this fails; change a
declared weight without refetching the matching static instance and it
fails too.
usWeightClass is the check worth having. css2 silently collapses a
multi-weight request to 400 for legacy clients, so Medium comes back as
Regular — a valid TrueType file that renders at the wrong weight
everywhere, and the only field that distinguishes it.
Comments are stripped before the absence check, so the KDoc explaining
why there is no GoogleFont reference cannot satisfy the assertion that
forbids it.
Falsified by mirroring every predicate and byte offset against the real
files: it passes on what is committed, and trips on HEAD~1's
Typography.kt via both the forbidden-symbol check and the
no-declarations-found check. A 400 file asserted against a declared 500
fails, so the weight comparison is not vacuous.
Compilation itself is unverified locally — no Gradle run here — so CI is
the first thing to actually build this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Five commits. CI green on
c27f9d48.The mark
Minstrel's mark is now the feathered bard's hat, traced from the operator's reference artwork at 99.74% IoU. The hat takes the surrounding text colour and the note arc carries the accent — the silhouette has to flip with its background, because parchment on a light surface is invisible.
This reverses the subject-neutrality argument recorded in Minstrel's design system, which had twice rejected a hat on the grounds that it would tell a new user the app is for renaissance-faire music. Adopted deliberately with that objection on the table; both design-system records were updated rather than left contradicting what ships.
The full art does not resolve below ~32px, so the header moved 20px → 28px and the favicon uses a separate drawn reduced form. Deriving one from the traced art does not work — hole-filling, smoothing and dropping components all preserve the overall silhouette, and the silhouette is what fails.
Fonts
Both clients fetched their typefaces at runtime: the web from
fonts.googleapis.com, Android through the Play Services font provider. A deployed instance has no outbound network, so those requests never arrive and the UI renders in fallback faces. Android had a second failure mode — the provider is absent entirely on devices without Play Services.Invisible in development, which is why it survived: dev machines have internet.
tools/vendor-fonts.pynow produces both — 32 subsetted woff2 for web (0.82 MB, embedded in the Go binary) and 5 static TTF for Android (0.86 MB of APK). The runtime path is removed, not kept as a fallback: theui-text-google-fontsdependency, its catalog entry andfont_certs.xmlare all gone.Two traps, both of which fail by succeeding, and both now asserted in the script:
res/fontcannot load; the obvious "use an old UA" fix gets EOT, an IE-only format that downloads happily and is useless here.css2collapses a multi-weight request to 400 for legacy clients, so asking for Medium returns Regular — a valid TrueType file rendering at the wrong weight everywhere.OS/2 usWeightClassis the only field that distinguishes them.Guards
web/src/lib/no-external-assets.test.tsandandroid/.../BundledFontsTest.kt. Both pin the property rather than a vendor name, and both were falsified against the pre-change sources before committing.The Android one reads its expectations out of
Typography.ktitself — everyFont(R.font.X, FontWeight.WN)declaration must have a vendoredX.ttfthat is really TrueType and really reportsN— so it cannot drift from what the app declares.What this merge does and does not ship
release.ymlmoves:lateston amainpush, so the web changes go live on merge. The Android changes do not: no APK is built here, and:latestrebundles the most recent release's signed APK. The bundled typefaces and new launcher icons reach devices only on avYYYY.MM.DDtag.🤖 Generated with Claude Code
https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH