New brand mark, and both clients stop fetching their fonts at runtime #128
Merged
bvandeusen
merged 5 commits from 2026-09-09 14:36:16 -04:00
dev into main
5
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c27f9d484a |
test(android): guard that the typefaces stay bundled
android / Build + lint + test (push) Successful in 5m17s
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 |
||
|
|
b52a00df66 |
fix(android): bundle the typefaces instead of fetching them at runtime
android / Build + lint + test (push) Successful in 4m19s
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 |
||
|
|
16005054eb |
fix(web): vendor the web fonts instead of loading them from Google
test-web / test (push) Successful in 42s
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 |
||
|
|
b06a1adfe8 |
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 |
||
|
|
5593f7ce17 |
feat(brand): replace the M mark with the traced bard-hat logo
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 |