diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 192ed4be..4ab52a4b 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -150,7 +150,6 @@ dependencies {
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.material3)
- implementation(libs.compose.ui.text.google.fonts)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.tooling.preview)
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/theme/Typography.kt b/android/app/src/main/java/com/fabledsword/minstrel/theme/Typography.kt
index 9eafcfba..373e275d 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/theme/Typography.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/theme/Typography.kt
@@ -2,72 +2,45 @@ package com.fabledsword.minstrel.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.googlefonts.Font
-import androidx.compose.ui.text.googlefonts.GoogleFont
import androidx.compose.ui.unit.sp
import com.fabledsword.minstrel.R
/**
- * Google Fonts provider — fetches font files via Play Services Fonts at
- * runtime, caches them across launches. Matches the Flutter client's
- * `google_fonts` package behaviour (no bundled .ttf files in either tree).
+ * Bundled typefaces, vendored into res/font by tools/vendor-fonts.py.
+ *
+ * These were fetched at runtime through the Play Services font provider until
+ * 2026-09-09. That is a network dependency for rendering, and a deployed
+ * instance is not guaranteed one — the provider is also absent entirely on
+ * devices without Play Services, where the app silently fell back to the
+ * platform default and stopped looking like Minstrel. Bundling costs ~0.86 MB
+ * of APK and removes both failure modes.
*
* Per FabledSword design system:
* - Fraunces — display + headline (mythic serif)
* - Inter — body + label (clean sans for UI text)
* - JetBrains Mono — technical / monospace
* Weights are restricted to 400 (regular) and 500 (medium) only.
+ *
+ * Each res/font entry is a single static instance, not a variable font: the
+ * weight declared beside it here must match the file's own OS/2
+ * usWeightClass, which the vendoring script asserts on download.
*/
-private val GoogleFontProvider = GoogleFont.Provider(
- providerAuthority = "com.google.android.gms.fonts",
- providerPackage = "com.google.android.gms",
- certificates = R.array.com_google_android_gms_fonts_certs,
-)
-
-private val FrauncesFont = GoogleFont("Fraunces")
-private val InterFont = GoogleFont("Inter")
-private val JetBrainsMonoFont = GoogleFont("JetBrains Mono")
-
private val Fraunces = FontFamily(
- Font(
- googleFont = FrauncesFont,
- fontProvider = GoogleFontProvider,
- weight = FontWeight.W400,
- style = FontStyle.Normal,
- ),
- Font(
- googleFont = FrauncesFont,
- fontProvider = GoogleFontProvider,
- weight = FontWeight.W500,
- style = FontStyle.Normal,
- ),
+ Font(R.font.fraunces_regular, FontWeight.W400, FontStyle.Normal),
+ Font(R.font.fraunces_medium, FontWeight.W500, FontStyle.Normal),
)
private val Inter = FontFamily(
- Font(
- googleFont = InterFont,
- fontProvider = GoogleFontProvider,
- weight = FontWeight.W400,
- style = FontStyle.Normal,
- ),
- Font(
- googleFont = InterFont,
- fontProvider = GoogleFontProvider,
- weight = FontWeight.W500,
- style = FontStyle.Normal,
- ),
+ Font(R.font.inter_regular, FontWeight.W400, FontStyle.Normal),
+ Font(R.font.inter_medium, FontWeight.W500, FontStyle.Normal),
)
private val JetBrainsMono = FontFamily(
- Font(
- googleFont = JetBrainsMonoFont,
- fontProvider = GoogleFontProvider,
- weight = FontWeight.W400,
- style = FontStyle.Normal,
- ),
+ Font(R.font.jetbrains_mono_regular, FontWeight.W400, FontStyle.Normal),
)
/**
diff --git a/android/app/src/main/res/font/fraunces_medium.ttf b/android/app/src/main/res/font/fraunces_medium.ttf
new file mode 100644
index 00000000..f8479d66
Binary files /dev/null and b/android/app/src/main/res/font/fraunces_medium.ttf differ
diff --git a/android/app/src/main/res/font/fraunces_regular.ttf b/android/app/src/main/res/font/fraunces_regular.ttf
new file mode 100644
index 00000000..f4e33e48
Binary files /dev/null and b/android/app/src/main/res/font/fraunces_regular.ttf differ
diff --git a/android/app/src/main/res/font/inter_medium.ttf b/android/app/src/main/res/font/inter_medium.ttf
new file mode 100644
index 00000000..f371b8bd
Binary files /dev/null and b/android/app/src/main/res/font/inter_medium.ttf differ
diff --git a/android/app/src/main/res/font/inter_regular.ttf b/android/app/src/main/res/font/inter_regular.ttf
new file mode 100644
index 00000000..9401a27d
Binary files /dev/null and b/android/app/src/main/res/font/inter_regular.ttf differ
diff --git a/android/app/src/main/res/font/jetbrains_mono_regular.ttf b/android/app/src/main/res/font/jetbrains_mono_regular.ttf
new file mode 100644
index 00000000..c19a0808
Binary files /dev/null and b/android/app/src/main/res/font/jetbrains_mono_regular.ttf differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
index 565ce21b..d2c239c0 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
index f656fe84..47a95d43 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
index cc53b560..03b80b12 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
index fc58b108..0b623401 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index d13d1dde..44bbce3d 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
index c0e47545..98d96710 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index a1c33daa..a0578767 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
index a0ce97e3..f2c3a546 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index ad01d424..616ef5fd 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
index 9a46f34a..65453186 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/values/font_certs.xml b/android/app/src/main/res/values/font_certs.xml
deleted file mode 100644
index 66434243..00000000
--- a/android/app/src/main/res/values/font_certs.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
- @array/com_google_android_gms_fonts_certs_dev
- @array/com_google_android_gms_fonts_certs_prod
-
-
- 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
-
-
diff --git a/android/app/src/test/java/com/fabledsword/minstrel/theme/BundledFontsTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/theme/BundledFontsTest.kt
new file mode 100644
index 00000000..e061ba24
--- /dev/null
+++ b/android/app/src/test/java/com/fabledsword/minstrel/theme/BundledFontsTest.kt
@@ -0,0 +1,125 @@
+package com.fabledsword.minstrel.theme
+
+import org.junit.jupiter.api.Test
+import java.io.File
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * Guards that the typefaces ship inside the APK instead of being fetched at
+ * runtime.
+ *
+ * Until 2026-09-09 these were resolved through the Play Services font
+ * provider. That needs a network the deployed app is not guaranteed, and a
+ * provider that devices without Play Services do not have at all. Both
+ * failures are silent — text just renders in the platform default, which
+ * reads as a styling regression rather than a missing dependency.
+ *
+ * Expectations are read out of Typography.kt itself rather than hardcoded, so
+ * this cannot drift away from what the app actually declares: adding a face
+ * without vendoring its file fails here, and so does changing a declared
+ * weight without refetching the matching static instance.
+ */
+class BundledFontsTest {
+ @Test
+ fun `typography builds its families from bundled resources`() {
+ val source = typographySource()
+ assertTrue(
+ source.contains("R.font."),
+ "Typography.kt should build its families from res/font resources",
+ )
+ FORBIDDEN.forEach { symbol ->
+ assertTrue(
+ !source.contains(symbol),
+ "Typography.kt must not reference $symbol — fonts are bundled, not fetched",
+ )
+ }
+ }
+
+ @Test
+ fun `every declared face is vendored as TrueType at its declared weight`() {
+ val declared = FACE_PATTERN.findAll(typographySource()).toList()
+ assertTrue(
+ declared.isNotEmpty(),
+ "no Font(R.font.…, FontWeight.W…) declarations found — the guard would pass vacuously",
+ )
+
+ declared.forEach { match ->
+ val (name, weight) = match.destructured
+ val file = File(appDir(), "src/main/res/font/$name.ttf")
+ assertTrue(file.isFile, "res/font/$name.ttf is missing — run tools/vendor-fonts.py")
+
+ val bytes = file.readBytes()
+ assertTrue(
+ bytes.copyOfRange(0, TTF_MAGIC.size).contentEquals(TTF_MAGIC),
+ "$name.ttf is not TrueType — res/font cannot load a woff2 or an eot",
+ )
+ // The decisive check. Google's css2 endpoint silently collapses a
+ // multi-weight request to 400 for legacy clients, so Medium can
+ // come back as Regular: a valid TrueType file that renders at the
+ // wrong weight everywhere. usWeightClass is the only field that
+ // tells the two apart.
+ assertEquals(
+ weight.toInt(),
+ weightClass(bytes),
+ "$name.ttf carries a different OS/2 usWeightClass than the FontWeight declared beside it",
+ )
+ }
+ }
+
+ /** Typography.kt with comments removed, so prose naming the forbidden
+ * symbols cannot satisfy — or trip — the absence check above. */
+ private fun typographySource(): String =
+ File(appDir(), TYPOGRAPHY)
+ .readText()
+ .replace(BLOCK_COMMENT, "")
+ .replace(LINE_COMMENT, "")
+
+ /** Gradle's working directory for tests is the module dir, but don't rely
+ * on it: walk up until the module is found, and say so if it isn't. */
+ private fun appDir(): File {
+ var dir: File? = File("").absoluteFile
+ while (dir != null) {
+ if (File(dir, TYPOGRAPHY).isFile) return dir
+ if (File(dir, "app/$TYPOGRAPHY").isFile) return File(dir, "app")
+ dir = dir.parentFile
+ }
+ error("could not locate the app module from ${File("").absolutePath}")
+ }
+
+ private fun weightClass(bytes: ByteArray): Int {
+ val tables = readU16(bytes, NUM_TABLES)
+ for (i in 0 until tables) {
+ val record = TABLE_DIRECTORY + i * TABLE_RECORD
+ if (String(bytes, record, TAG_LENGTH, Charsets.US_ASCII) == "OS/2") {
+ return readU16(bytes, readU32(bytes, record + OFFSET_FIELD) + WEIGHT_FIELD)
+ }
+ }
+ error("no OS/2 table in the font")
+ }
+
+ private fun readU16(bytes: ByteArray, at: Int): Int =
+ ((bytes[at].toInt() and BYTE_MASK) shl Byte.SIZE_BITS) or (bytes[at + 1].toInt() and BYTE_MASK)
+
+ private fun readU32(bytes: ByteArray, at: Int): Int =
+ (readU16(bytes, at) shl Short.SIZE_BITS) or readU16(bytes, at + 2)
+
+ private companion object {
+ const val TYPOGRAPHY = "src/main/java/com/fabledsword/minstrel/theme/Typography.kt"
+
+ val FORBIDDEN = listOf("GoogleFont", "googlefonts")
+ val FACE_PATTERN = Regex("""R\.font\.(\w+)\s*,\s*FontWeight\.W(\d+)""")
+ val BLOCK_COMMENT = Regex("""/\*[\s\S]*?\*/""")
+ val LINE_COMMENT = Regex("""//.*""")
+ val TTF_MAGIC = byteArrayOf(0x00, 0x01, 0x00, 0x00)
+
+ // Offsets into the TrueType table directory, per the OpenType spec.
+ const val NUM_TABLES = 4
+ const val TABLE_DIRECTORY = 12
+ const val TABLE_RECORD = 16
+ const val TAG_LENGTH = 4
+ const val OFFSET_FIELD = 8
+ const val WEIGHT_FIELD = 4
+ const val BYTE_MASK = 0xFF
+ }
+}
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index 5165ea49..176c8c31 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -53,7 +53,6 @@ compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-material3 = { module = "androidx.compose.material3:material3" }
-compose-ui-text-google-fonts = { module = "androidx.compose.ui:ui-text-google-fonts" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
diff --git a/docs/brand/minstrel-logo-source.png b/docs/brand/minstrel-logo-source.png
new file mode 100644
index 00000000..4ae840fb
Binary files /dev/null and b/docs/brand/minstrel-logo-source.png differ
diff --git a/tools/gen-brand-assets.py b/tools/gen-brand-assets.py
new file mode 100644
index 00000000..6f5b72c6
--- /dev/null
+++ b/tools/gen-brand-assets.py
@@ -0,0 +1,377 @@
+#!/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' width:
+ out.append(line)
+ line = tok
+ else:
+ line = f"{line} {tok}".strip()
+ out.append(line)
+ 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}'
+ f' \n'
+ f' \n'
+ f' \n'
+ f' \n\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.70: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=" \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=" \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",
+ 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)]:
+ 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,
+ plate=OBSIDIAN, radius_frac=0.20, art_frac=0.90)
+
+ # 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 = '''
+
+
+'''
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/vendor-fonts.py b/tools/vendor-fonts.py
new file mode 100644
index 00000000..310ef760
--- /dev/null
+++ b/tools/vendor-fonts.py
@@ -0,0 +1,172 @@
+#!/usr/bin/env python3
+"""Download the fonts from Google Fonts and vendor them into the repo.
+
+Covers BOTH clients — the web bundle (woff2, subsetted) and the Android app
+(static ttf, whole-font) — because they draw from the same three families and
+letting them drift is how one of them quietly stops matching the other.
+
+Run by hand when the font set changes, never at build or run time:
+
+ python3 tools/vendor-fonts.py
+
+The app must render itself with no outbound network, so nothing here may
+happen while it is running. This script fetches once, writes the woff2 files
+and a stylesheet that points at them by RELATIVE url, and everything is
+committed. `web/static/` is copied verbatim into the SvelteKit build, which
+Go then embeds — so the fonts travel inside the binary.
+
+Relative urls in the stylesheet (`./Inter-latin.woff2`, not `/fonts/...`)
+are deliberate: a CSS url() resolves against the stylesheet's own address, so
+the whole directory keeps working when the app is served under a base path.
+
+Subsets are kept as Google slices them, with their `unicode-range` intact.
+That is not a size compromise — the browser downloads only the ranges a page
+actually uses, so vendoring every subset costs repository bytes rather than
+request bytes, and a library full of non-English artist names renders instead
+of falling back mid-list.
+"""
+import re
+import struct
+import sys
+import urllib.request
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+OUT = ROOT / "web/static/fonts"
+ANDROID_OUT = ROOT / "android/app/src/main/res/font"
+
+# Matches the faces the design system actually permits: two weights only, 400
+# and 500 (never 600/700), and Fraunces' optical-size axis across its range.
+FAMILIES = (
+ "family=Fraunces:opsz,wght@9..144,400;9..144,500"
+ "&family=Inter:wght@400;500"
+ "&family=JetBrains+Mono:wght@400;500"
+)
+CSS_URL = f"https://fonts.googleapis.com/css2?{FAMILIES}&display=swap"
+
+# The UA is load-bearing, not cosmetic: Google Fonts serves a different FORMAT
+# per client, and there is no parameter to ask for one directly.
+# modern Chrome -> woff2 (what the browser wants)
+# old Android -> ttf (what Android's res/font requires)
+# MSIE 6 -> eot (an IE-only format; the obvious "old UA" choice
+# and completely useless here — it downloads and
+# looks plausible until you check the magic bytes)
+UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
+UA_TTF = ("Mozilla/5.0 (Linux; U; Android 4.4.2; en-us) AppleWebKit/534.30 "
+ "(KHTML, like Gecko) Version/4.0 Mobile Safari/534.30")
+
+# Android res/font resource names: lowercase, digits and underscore only.
+# Each entry is one static weight — Compose selects by the FontWeight declared
+# alongside it in Typography.kt, so the file must genuinely be that instance.
+# css2 collapses a multi-weight request to 400 for legacy clients, so each
+# weight is fetched on its own URL.
+ANDROID_FACES = [
+ ("fraunces_regular", "Fraunces:opsz,wght@9..144,400", 400),
+ ("fraunces_medium", "Fraunces:opsz,wght@9..144,500", 500),
+ ("inter_regular", "Inter:wght@400", 400),
+ ("inter_medium", "Inter:wght@500", 500),
+ ("jetbrains_mono_regular", "JetBrains+Mono:wght@400", 400),
+]
+
+TTF_MAGIC = (b"\x00\x01\x00\x00", b"true", b"OTTO")
+
+
+def weight_class(data):
+ """Read OS/2 usWeightClass out of a TrueType file.
+
+ Worth the twenty lines: css2 silently collapses a multi-weight request to
+ 400 for legacy clients, so asking for Medium and getting Regular is a real
+ and quiet failure. The file downloads, has valid TrueType magic, and
+ renders — just at the wrong weight, everywhere, forever. This is the only
+ field that actually distinguishes them.
+ """
+ count = struct.unpack(">H", data[4:6])[0]
+ for i in range(count):
+ off = 12 + i * 16
+ if data[off:off + 4] == b"OS/2":
+ table = struct.unpack(">I", data[off + 8:off + 12])[0]
+ return struct.unpack(">H", data[table + 4:table + 6])[0]
+ return None
+
+
+def fetch(url, timeout=30, ttf=False):
+ req = urllib.request.Request(url, headers={"User-Agent": UA_TTF if ttf else UA})
+ with urllib.request.urlopen(req, timeout=timeout) as r:
+ return r.read()
+
+
+def vendor_android():
+ """Fetch static TTFs for the Android client's res/font."""
+ ANDROID_OUT.mkdir(parents=True, exist_ok=True)
+ for old in ANDROID_OUT.glob("*.ttf"):
+ old.unlink()
+ total = 0
+ for name, spec, want_weight in ANDROID_FACES:
+ css = fetch(f"https://fonts.googleapis.com/css2?family={spec}", ttf=True).decode()
+ url = re.search(r"url\((https://[^)]+)\)", css)
+ if not url:
+ sys.exit(f"no font url for {spec}")
+ data = fetch(url.group(1), ttf=True)
+ # Assert the FORMAT, because every wrong one still downloads happily
+ # and only fails later, on a device, as a silently missing typeface.
+ if not data.startswith(TTF_MAGIC):
+ sys.exit(f"{name}: expected TrueType, got magic {data[:4].hex()} "
+ f"(eot/woff means the UA negotiation broke)")
+ got = weight_class(data)
+ if got != want_weight:
+ sys.exit(f"{name}: wanted weight {want_weight}, file reports {got} "
+ f"(css2 collapsed the request to a single weight)")
+ (ANDROID_OUT / f"{name}.ttf").write_bytes(data)
+ total += len(data)
+ print(f" {name + '.ttf':<30} {len(data)/1024:7.1f} KB weight {got}")
+ print(f"{len(ANDROID_FACES)} ttf, {total/1024/1024:.2f} MB total -> "
+ f"{ANDROID_OUT.relative_to(ROOT)}")
+
+
+def main():
+ css = fetch(CSS_URL).decode()
+
+ # Google emits a comment naming each subset before its @font-face block.
+ blocks = re.split(r"(?=/\*\s*[a-z0-9-]+\s*\*/)", css)
+ OUT.mkdir(parents=True, exist_ok=True)
+ for old in OUT.glob("*.woff2"):
+ old.unlink()
+
+ out_css, seen, total = [], {}, 0
+ for block in blocks:
+ subset = re.search(r"/\*\s*([a-z0-9-]+)\s*\*/", block)
+ family = re.search(r"font-family:\s*'([^']+)'", block)
+ weight = re.search(r"font-weight:\s*([^;]+);", block)
+ url = re.search(r"url\((https://[^)]+\.woff2)\)", block)
+ if not (subset and family and url):
+ continue
+ w = weight.group(1).strip().replace(" ", "-") if weight else "400"
+ name = f"{family.group(1).replace(' ', '')}-{w}-{subset.group(1)}.woff2"
+ if name not in seen:
+ data = fetch(url.group(1))
+ (OUT / name).write_bytes(data)
+ seen[name] = len(data)
+ total += len(data)
+ out_css.append(block.replace(url.group(1), f"./{name}"))
+
+ header = (
+ "/* Generated by tools/vendor-fonts.py — do not edit by hand.\n"
+ " *\n"
+ " * Vendored from Google Fonts and served from our own origin: the app\n"
+ " * has to render with no outbound network, so a font provider link is\n"
+ " * not an option. Urls are relative so this keeps working under a base\n"
+ " * path. unicode-range is preserved, so the browser still fetches only\n"
+ " * the subsets a page needs.\n"
+ " */\n"
+ )
+ (OUT / "fonts.css").write_text(header + "".join(out_css))
+ for n, size in sorted(seen.items()):
+ print(f" {n:<44} {size/1024:7.1f} KB")
+ print(f"{len(seen)} files, {total/1024/1024:.2f} MB total -> {OUT.relative_to(ROOT)}")
+ print()
+ vendor_android()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/web/src/app.html b/web/src/app.html
index 91cf4ffa..1a1de560 100644
--- a/web/src/app.html
+++ b/web/src/app.html
@@ -3,7 +3,7 @@
+
%sveltekit.head%
diff --git a/web/src/lib/components/MinstrelMark.svelte b/web/src/lib/components/MinstrelMark.svelte
index 84b2ff3b..e7abbb4f 100644
--- a/web/src/lib/components/MinstrelMark.svelte
+++ b/web/src/lib/components/MinstrelMark.svelte
@@ -1,57 +1,300 @@
diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte
index f7e248d1..b2ac1e39 100644
--- a/web/src/lib/components/Shell.svelte
+++ b/web/src/lib/components/Shell.svelte
@@ -71,7 +71,7 @@
href="/"
class="flex items-center gap-2 font-semibold text-sm md:text-base whitespace-nowrap justify-self-start"
>
-
+
{appName()}
diff --git a/web/src/lib/no-external-assets.test.ts b/web/src/lib/no-external-assets.test.ts
new file mode 100644
index 00000000..9cb71f54
--- /dev/null
+++ b/web/src/lib/no-external-assets.test.ts
@@ -0,0 +1,80 @@
+import { readFileSync, readdirSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, test } from 'vitest';
+
+// A deployed instance has no outbound network, so every asset the app draws
+// itself with has to come from our own origin. This regressed once already
+// (the font stylesheet was linked straight from Google), and the failure is
+// invisible in development — the dev machine HAS internet, so the fonts load
+// fine and only a real deployment renders in fallback faces.
+//
+// The assertion is on the PROPERTY, not on a vendor: any absolute URL in a
+// resource-loading attribute fails, whoever is hosting it. Naming
+// `fonts.googleapis.com` would pass the day someone reached for a different
+// CDN, which is the same bug.
+
+// fileURLToPath rather than import.meta.dirname: the latter needs Node 20.11+
+// and this repo's toolchain is still on 18, where it is silently undefined.
+const WEB = join(dirname(fileURLToPath(import.meta.url)), '../..');
+
+/** Strip comments before asserting an ABSENCE — otherwise prose describing
+ * the forbidden thing satisfies the check that forbids it. */
+const stripComments = (html: string) => html.replace(//g, '');
+
+/** href/src on anything that makes the browser fetch a subresource. */
+const RESOURCE_URL = /<(?:link|script|img|source|iframe)\b[^>]*?\b(?:href|src)\s*=\s*["']([^"']+)["']/gi;
+
+const externalUrlsIn = (html: string) =>
+ [...stripComments(html).matchAll(RESOURCE_URL)]
+ .map((m) => m[1])
+ .filter((u) => /^(?:https?:)?\/\//i.test(u));
+
+describe('the app ships every asset it draws itself with', () => {
+ test('app.html loads no subresource from a third-party origin', () => {
+ const html = readFileSync(join(WEB, 'src/app.html'), 'utf8');
+ expect(externalUrlsIn(html)).toEqual([]);
+ });
+
+ test('app.html does not preconnect or dns-prefetch to any host', () => {
+ // preconnect carries no href-fetch of its own, so the check above misses
+ // it — but it is the fingerprint of a third-party asset about to be added.
+ const html = stripComments(readFileSync(join(WEB, 'src/app.html'), 'utf8'));
+ const hints = [...html.matchAll(/]*\brel\s*=\s*["']([^"']+)["'][^>]*>/gi)]
+ .filter((m) => /\b(?:preconnect|dns-prefetch)\b/i.test(m[1]));
+ expect(hints.map((m) => m[0])).toEqual([]);
+ });
+
+ test('the vendored font stylesheet exists and points only at local files', () => {
+ const dir = join(WEB, 'static/fonts');
+ const css = readFileSync(join(dir, 'fonts.css'), 'utf8');
+
+ const urls = [...css.matchAll(/url\(\s*['"]?([^'")]+)['"]?\s*\)/gi)].map((m) => m[1]);
+ expect(urls.length).toBeGreaterThan(0);
+ expect(urls.filter((u) => /^(?:https?:)?\/\//i.test(u))).toEqual([]);
+
+ // Every referenced file is actually present — a stylesheet pointing at a
+ // woff2 nobody committed fails exactly like a CDN link would, offline.
+ const present = new Set(readdirSync(dir));
+ const missing = urls
+ .map((u) => u.replace(/^\.\//, ''))
+ .filter((f) => !present.has(f));
+ expect(missing).toEqual([]);
+ });
+
+ test('every font family the tokens name is actually vendored', () => {
+ // Pins the families to the TOKENS rather than to a hardcoded list, so
+ // swapping a typeface in tokens.json fails here until it is vendored.
+ const tokens = JSON.parse(
+ readFileSync(join(WEB, 'src/lib/styles/tokens.json'), 'utf8')
+ ) as { fonts: Record };
+ const files = readdirSync(join(WEB, 'static/fonts'));
+ for (const family of Object.values(tokens.fonts)) {
+ const prefix = `${family.replace(/\s+/g, '')}-`;
+ expect(
+ files.some((f) => f.startsWith(prefix) && f.endsWith('.woff2')),
+ `no vendored woff2 for "${family}"`
+ ).toBe(true);
+ }
+ });
+});
diff --git a/web/static/apple-touch-icon.png b/web/static/apple-touch-icon.png
index 535bfe0b..17ee081c 100644
Binary files a/web/static/apple-touch-icon.png and b/web/static/apple-touch-icon.png differ
diff --git a/web/static/brand/favicon.svg b/web/static/brand/favicon.svg
index 844e84d9..ba5cd3a3 100644
--- a/web/static/brand/favicon.svg
+++ b/web/static/brand/favicon.svg
@@ -1,35 +1,219 @@
-