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 @@ - - + + - - - + + + diff --git a/web/static/brand/icon-512.png b/web/static/brand/icon-512.png index a0935f4a..57609907 100644 Binary files a/web/static/brand/icon-512.png and b/web/static/brand/icon-512.png differ diff --git a/web/static/brand/mark-small.svg b/web/static/brand/mark-small.svg new file mode 100644 index 00000000..c5a4c380 --- /dev/null +++ b/web/static/brand/mark-small.svg @@ -0,0 +1,211 @@ + + + + + + + diff --git a/web/static/brand/mark.svg b/web/static/brand/mark.svg index fcd6845d..c5daac0e 100644 --- a/web/static/brand/mark.svg +++ b/web/static/brand/mark.svg @@ -1,27 +1,274 @@ - - - - + + + + + diff --git a/web/static/favicon.png b/web/static/favicon.png index ac8e7c56..d606be8c 100644 Binary files a/web/static/favicon.png and b/web/static/favicon.png differ diff --git a/web/static/fonts/Fraunces-400-latin-ext.woff2 b/web/static/fonts/Fraunces-400-latin-ext.woff2 new file mode 100644 index 00000000..b6e3e3a5 Binary files /dev/null and b/web/static/fonts/Fraunces-400-latin-ext.woff2 differ diff --git a/web/static/fonts/Fraunces-400-latin.woff2 b/web/static/fonts/Fraunces-400-latin.woff2 new file mode 100644 index 00000000..cb295bf0 Binary files /dev/null and b/web/static/fonts/Fraunces-400-latin.woff2 differ diff --git a/web/static/fonts/Fraunces-400-vietnamese.woff2 b/web/static/fonts/Fraunces-400-vietnamese.woff2 new file mode 100644 index 00000000..43126c27 Binary files /dev/null and b/web/static/fonts/Fraunces-400-vietnamese.woff2 differ diff --git a/web/static/fonts/Fraunces-500-latin-ext.woff2 b/web/static/fonts/Fraunces-500-latin-ext.woff2 new file mode 100644 index 00000000..b6e3e3a5 Binary files /dev/null and b/web/static/fonts/Fraunces-500-latin-ext.woff2 differ diff --git a/web/static/fonts/Fraunces-500-latin.woff2 b/web/static/fonts/Fraunces-500-latin.woff2 new file mode 100644 index 00000000..cb295bf0 Binary files /dev/null and b/web/static/fonts/Fraunces-500-latin.woff2 differ diff --git a/web/static/fonts/Fraunces-500-vietnamese.woff2 b/web/static/fonts/Fraunces-500-vietnamese.woff2 new file mode 100644 index 00000000..43126c27 Binary files /dev/null and b/web/static/fonts/Fraunces-500-vietnamese.woff2 differ diff --git a/web/static/fonts/Inter-400-cyrillic-ext.woff2 b/web/static/fonts/Inter-400-cyrillic-ext.woff2 new file mode 100644 index 00000000..de83a9c7 Binary files /dev/null and b/web/static/fonts/Inter-400-cyrillic-ext.woff2 differ diff --git a/web/static/fonts/Inter-400-cyrillic.woff2 b/web/static/fonts/Inter-400-cyrillic.woff2 new file mode 100644 index 00000000..d7509147 Binary files /dev/null and b/web/static/fonts/Inter-400-cyrillic.woff2 differ diff --git a/web/static/fonts/Inter-400-greek-ext.woff2 b/web/static/fonts/Inter-400-greek-ext.woff2 new file mode 100644 index 00000000..6e7141f8 Binary files /dev/null and b/web/static/fonts/Inter-400-greek-ext.woff2 differ diff --git a/web/static/fonts/Inter-400-greek.woff2 b/web/static/fonts/Inter-400-greek.woff2 new file mode 100644 index 00000000..024f0770 Binary files /dev/null and b/web/static/fonts/Inter-400-greek.woff2 differ diff --git a/web/static/fonts/Inter-400-latin-ext.woff2 b/web/static/fonts/Inter-400-latin-ext.woff2 new file mode 100644 index 00000000..479d010d Binary files /dev/null and b/web/static/fonts/Inter-400-latin-ext.woff2 differ diff --git a/web/static/fonts/Inter-400-latin.woff2 b/web/static/fonts/Inter-400-latin.woff2 new file mode 100644 index 00000000..d15208de Binary files /dev/null and b/web/static/fonts/Inter-400-latin.woff2 differ diff --git a/web/static/fonts/Inter-400-vietnamese.woff2 b/web/static/fonts/Inter-400-vietnamese.woff2 new file mode 100644 index 00000000..a40c4699 Binary files /dev/null and b/web/static/fonts/Inter-400-vietnamese.woff2 differ diff --git a/web/static/fonts/Inter-500-cyrillic-ext.woff2 b/web/static/fonts/Inter-500-cyrillic-ext.woff2 new file mode 100644 index 00000000..de83a9c7 Binary files /dev/null and b/web/static/fonts/Inter-500-cyrillic-ext.woff2 differ diff --git a/web/static/fonts/Inter-500-cyrillic.woff2 b/web/static/fonts/Inter-500-cyrillic.woff2 new file mode 100644 index 00000000..d7509147 Binary files /dev/null and b/web/static/fonts/Inter-500-cyrillic.woff2 differ diff --git a/web/static/fonts/Inter-500-greek-ext.woff2 b/web/static/fonts/Inter-500-greek-ext.woff2 new file mode 100644 index 00000000..6e7141f8 Binary files /dev/null and b/web/static/fonts/Inter-500-greek-ext.woff2 differ diff --git a/web/static/fonts/Inter-500-greek.woff2 b/web/static/fonts/Inter-500-greek.woff2 new file mode 100644 index 00000000..024f0770 Binary files /dev/null and b/web/static/fonts/Inter-500-greek.woff2 differ diff --git a/web/static/fonts/Inter-500-latin-ext.woff2 b/web/static/fonts/Inter-500-latin-ext.woff2 new file mode 100644 index 00000000..479d010d Binary files /dev/null and b/web/static/fonts/Inter-500-latin-ext.woff2 differ diff --git a/web/static/fonts/Inter-500-latin.woff2 b/web/static/fonts/Inter-500-latin.woff2 new file mode 100644 index 00000000..d15208de Binary files /dev/null and b/web/static/fonts/Inter-500-latin.woff2 differ diff --git a/web/static/fonts/Inter-500-vietnamese.woff2 b/web/static/fonts/Inter-500-vietnamese.woff2 new file mode 100644 index 00000000..a40c4699 Binary files /dev/null and b/web/static/fonts/Inter-500-vietnamese.woff2 differ diff --git a/web/static/fonts/JetBrainsMono-400-cyrillic-ext.woff2 b/web/static/fonts/JetBrainsMono-400-cyrillic-ext.woff2 new file mode 100644 index 00000000..289de931 Binary files /dev/null and b/web/static/fonts/JetBrainsMono-400-cyrillic-ext.woff2 differ diff --git a/web/static/fonts/JetBrainsMono-400-cyrillic.woff2 b/web/static/fonts/JetBrainsMono-400-cyrillic.woff2 new file mode 100644 index 00000000..de39acbc Binary files /dev/null and b/web/static/fonts/JetBrainsMono-400-cyrillic.woff2 differ diff --git a/web/static/fonts/JetBrainsMono-400-greek.woff2 b/web/static/fonts/JetBrainsMono-400-greek.woff2 new file mode 100644 index 00000000..a53d52d6 Binary files /dev/null and b/web/static/fonts/JetBrainsMono-400-greek.woff2 differ diff --git a/web/static/fonts/JetBrainsMono-400-latin-ext.woff2 b/web/static/fonts/JetBrainsMono-400-latin-ext.woff2 new file mode 100644 index 00000000..82f96681 Binary files /dev/null and b/web/static/fonts/JetBrainsMono-400-latin-ext.woff2 differ diff --git a/web/static/fonts/JetBrainsMono-400-latin.woff2 b/web/static/fonts/JetBrainsMono-400-latin.woff2 new file mode 100644 index 00000000..4d09cda4 Binary files /dev/null and b/web/static/fonts/JetBrainsMono-400-latin.woff2 differ diff --git a/web/static/fonts/JetBrainsMono-400-vietnamese.woff2 b/web/static/fonts/JetBrainsMono-400-vietnamese.woff2 new file mode 100644 index 00000000..e38f5538 Binary files /dev/null and b/web/static/fonts/JetBrainsMono-400-vietnamese.woff2 differ diff --git a/web/static/fonts/JetBrainsMono-500-cyrillic-ext.woff2 b/web/static/fonts/JetBrainsMono-500-cyrillic-ext.woff2 new file mode 100644 index 00000000..289de931 Binary files /dev/null and b/web/static/fonts/JetBrainsMono-500-cyrillic-ext.woff2 differ diff --git a/web/static/fonts/JetBrainsMono-500-cyrillic.woff2 b/web/static/fonts/JetBrainsMono-500-cyrillic.woff2 new file mode 100644 index 00000000..de39acbc Binary files /dev/null and b/web/static/fonts/JetBrainsMono-500-cyrillic.woff2 differ diff --git a/web/static/fonts/JetBrainsMono-500-greek.woff2 b/web/static/fonts/JetBrainsMono-500-greek.woff2 new file mode 100644 index 00000000..a53d52d6 Binary files /dev/null and b/web/static/fonts/JetBrainsMono-500-greek.woff2 differ diff --git a/web/static/fonts/JetBrainsMono-500-latin-ext.woff2 b/web/static/fonts/JetBrainsMono-500-latin-ext.woff2 new file mode 100644 index 00000000..82f96681 Binary files /dev/null and b/web/static/fonts/JetBrainsMono-500-latin-ext.woff2 differ diff --git a/web/static/fonts/JetBrainsMono-500-latin.woff2 b/web/static/fonts/JetBrainsMono-500-latin.woff2 new file mode 100644 index 00000000..4d09cda4 Binary files /dev/null and b/web/static/fonts/JetBrainsMono-500-latin.woff2 differ diff --git a/web/static/fonts/JetBrainsMono-500-vietnamese.woff2 b/web/static/fonts/JetBrainsMono-500-vietnamese.woff2 new file mode 100644 index 00000000..e38f5538 Binary files /dev/null and b/web/static/fonts/JetBrainsMono-500-vietnamese.woff2 differ diff --git a/web/static/fonts/fonts.css b/web/static/fonts/fonts.css new file mode 100644 index 00000000..172a244d --- /dev/null +++ b/web/static/fonts/fonts.css @@ -0,0 +1,296 @@ +/* Generated by tools/vendor-fonts.py — do not edit by hand. + * + * Vendored from Google Fonts and served from our own origin: the app + * has to render with no outbound network, so a font provider link is + * not an option. Urls are relative so this keeps working under a base + * path. unicode-range is preserved, so the browser still fetches only + * the subsets a page needs. + */ +/* vietnamese */ +@font-face { + font-family: 'Fraunces'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./Fraunces-400-vietnamese.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Fraunces'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./Fraunces-400-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Fraunces'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./Fraunces-400-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* vietnamese */ +@font-face { + font-family: 'Fraunces'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./Fraunces-500-vietnamese.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Fraunces'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./Fraunces-500-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Fraunces'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./Fraunces-500-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./Inter-400-cyrillic-ext.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./Inter-400-cyrillic.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./Inter-400-greek-ext.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./Inter-400-greek.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* vietnamese */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./Inter-400-vietnamese.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./Inter-400-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./Inter-400-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./Inter-500-cyrillic-ext.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./Inter-500-cyrillic.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./Inter-500-greek-ext.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./Inter-500-greek.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* vietnamese */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./Inter-500-vietnamese.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./Inter-500-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./Inter-500-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./JetBrainsMono-400-cyrillic-ext.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./JetBrainsMono-400-cyrillic.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./JetBrainsMono-400-greek.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* vietnamese */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./JetBrainsMono-400-vietnamese.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./JetBrainsMono-400-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./JetBrainsMono-400-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./JetBrainsMono-500-cyrillic-ext.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./JetBrainsMono-500-cyrillic.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./JetBrainsMono-500-greek.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* vietnamese */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./JetBrainsMono-500-vietnamese.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./JetBrainsMono-500-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./JetBrainsMono-500-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +}