Compare commits

..
Author SHA1 Message Date
bvandeusen 439c8625d5 Fix the rebundle step: a non-matching grep must not kill it (#130)
test-go / test (push) Successful in 59s
test-go / integration (push) Successful in 3m1s
release / Build signed APK (releases and dev) (push) Successful in 4m41s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-09-10 10:49:40 -04:00
bvandeusenandClaude Opus 5 88508b536b fix(release): a non-matching grep must not kill the rebundle step
test-go / test (push) Successful in 1m39s
release / Build signed APK (releases and dev) (push) Successful in 6m11s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
test-go / integration (push) Successful in 5m59s
The first `main` build after the version rework failed, and the bug was
mine. :latest was never moved — "Build and push" was skipped — so nothing
reached production, but every subsequent main push would have failed the
same way.

The runner invokes `shell: bash` as `bash -e -o pipefail`. Under pipefail a
command substitution reports the FIRST non-zero status in its pipeline, not
the last, so

  VAR="$(printf ... | grep -oP ... | grep -E '\.apk\.version$' | head -1)"

exits non-zero when that grep matches nothing, even though `head` succeeded.
With -e the step dies AT THE ASSIGNMENT — before reaching the `if` written
to handle exactly the empty case.

Which is what happened: v2026.09.09 predates sidecar assets, so its
`.apk.version` grep matched nothing and the step aborted instead of falling
through to the name-only branch I added in 9f3e0b8c for precisely that
release. The transition case was described correctly in that commit message
and then not handled in code.

The other two assignments carried the same latent hazard and had simply
never fired, because a release always has a tag_name and an .apk asset. So
the step's documented promise — "degrades to an empty client/ (404 update
channel) — never a wrong version — if no release or APK asset can be
resolved" — was never actually reachable under pipefail. All three now
carry `|| true`.

Reproduced under the runner's exact shell before fixing: without `|| true`
the script exits 1 with no output at all, proving it never reaches the
branch; with it, the fallback runs and emits the name-only sidecar.

Guarded, since the graceful degradation depends on this and the failure is
invisible until the one release that triggers it: the new test asserts every
command-substitution grep in that step ends with `|| true`, and was
falsified by removing it from the sidecar line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-10 08:37:23 -04:00
bvandeusen 1d67c160b2 Versioning rework, a dev channel, and the miniplayer gap (#129)
release / Build signed APK (releases and dev) (push) Skipped
android / Build + lint + test (push) Failing after 4s
release / Build + push container image (push) Failing after 5s
release / Verify release artifacts (tag releases only) (push) Skipped
test-go / test (push) Successful in 1m24s
test-go / integration (push) Successful in 6m25s
2026-09-10 08:33:26 -04:00
bvandeusenandClaude Opus 5 90bb3538c6 feat(release): build a dev channel so testing stops requiring a release
test-go / test (push) Successful in 1m11s
release / Build signed APK (releases and dev) (push) Successful in 5m0s
test-go / integration (push) Successful in 5m55s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped
There was no test channel at all. release.yml ran only on main and tags, so
no :dev image existed and no APK was produced outside a release — the only
way to get a build onto a phone was to ship one, which made `main` the
staging area by default.

A push to dev now builds a signed APK, bundles it, and publishes :dev.

Signed with the SAME key as release builds, deliberately. A differently
signed APK cannot install over the stable app, so anyone moving between
channels would have to uninstall and lose their local data. Same key means
both directions work.

:dev is published ALONE, with no per-commit tag. A rolling channel is
rolling by definition; a commit-addressable image for it would be a rollback
target nobody ever pulls, kept forever. Recovery on dev is to fix forward,
and that is a deliberate trade rather than an omission.

The channel is derived from the REF, not the commit, which is why it is
computed in the workflow and not in ci/version.sh. The same commit built on
dev and on main reports the same version NAME and differs only in the
channel field — that separation is the entire point of keeping the three
values apart.

What this repo deliberately does NOT get: a cross-repo dispatch to refresh
the channel when its bundled APK is rebuilt. That mechanism exists elsewhere
in the family because the app and server live in separate repos, and a
channel that can only be refreshed by an unrelated commit is not a channel.
Minstrel is a monorepo — one push builds the APK and the image in the same
run from the same commit, so the channel cannot go stale against its own
artifact. The requirement is met structurally; copying the mechanism would
add a moving part to fix a problem that does not exist here.

Two guards, for the two ways this wiring can fail quietly:

A dev push must never move :latest. That would ship untested code to every
stable operator on their next pull, with the build green and the image
perfectly valid — just the wrong audience. Nothing else in the suite would
notice.

The two bundling paths must stay mutually exclusive. The rebundle step is
now gated to main specifically, not to "not a tag": under the looser
condition a dev push would run BOTH steps, staging its fresh APK and then
overwriting it with the previous release's. The image still builds, the
sidecar still parses, and the channel whose whole job is being current
quietly serves stale art.

Both falsified against the regressions they name before committing.

Scribe task #3819, milestone #390.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 22:47:42 -04:00
bvandeusenandClaude Opus 5 a687ef439c fix(release): refuse an ordering key that overflows versionCode
test-go / test (push) Successful in 1m31s
test-go / integration (push) Successful in 5m48s
The script asserted the key was positive but never that it fits. Android's
versionCode is a signed 32-bit int and the platform rejects an APK above it,
so a build machine with a badly wrong clock would emit a code the script
happily hands on and the install then refuses.

Worse than a rejected build: an over-ceiling code is also unreachably high,
so every correct build afterwards would fail to outrank it and the update
channel would be permanently stuck. Cheaper to refuse at the source than to
diagnose it from a phone that will not update.

The Go guard already asserted this, but only against a pinned value. The
script is what actually runs at build time, so the check belongs here too.

Falsified at the boundary rather than by eye — exactly at the ceiling exits
0, one minute past exits 1. My first probe used a year-6000 clock and did
NOT fire, which turned out to be the probe being wrong rather than the
check: that epoch still lands under the ceiling. The ceiling is reached in
6103, roughly 4079 years out, so this only ever catches a misconfigured
clock.

This commit deliberately touches ci/version.sh alone, to verify the path
filters added in eaf4654c actually fire the Go lane for release-machinery
changes. That run proved nothing about them, because it also touched
internal/** and would have run regardless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 22:17:07 -04:00
bvandeusenandClaude Opus 5 eaf4654c0a test(release): make the version derivation executable, and guard it on dev
test-go / test (push) Successful in 1m10s
test-go / integration (push) Canceled after 5m10s
Steps 1 and 2 of this milestone shipped with no CI coverage at all, and the
reason generalises: release.yml triggers only on main and tags, so nothing
inside it is exercised until a release is already running. That is the worst
place in the repo to be unguarded, because the failure mode is silence — a
version nobody can compare looks exactly like being up to date, and nobody
reports an update they were never offered.

The fix is not a test that reads YAML. The derivation moved into
ci/version.sh, so it can be RUN, and internal/server/release_version_test.go
runs it on every push. release.yml now calls the same script, so the thing
that ships and the thing under test are one artifact rather than two copies
that agree until they don't.

test-go.yml gains 'ci/**' and '.gitea/workflows/release.yml' in its paths.
Without that the guard exists but never fires on the changes it protects,
which is the same nothing it replaces.

What is pinned, and why each one:

  - HHMM is zero-padded. A build at 00:42 must emit "0042"; a stripped
    leading zero shifts the segment two orders of magnitude and reverses
    comparisons against every other build that day. It only bites for a
    tenth of the day, so it will not be found by chance.
  - The name derives from the COMMIT and the code from the BUILD. Asserted
    by holding one clock and moving the other: the name must not move, the
    code must.
  - The code clears 1895, the highest versionCode the retired commit-count
    scheme shipped. Below that Android refuses the upgrade as a downgrade
    and the channel becomes a one-way door.
  - The tag is the name with a `v`, never chosen.
  - release.yml still calls the script, and does not derive a commit count
    again. This pins the WIRING: without it every other assertion keeps
    passing while the shipped path silently drifts out of coverage.

The script rejects unusable clocks rather than emitting something plausible,
and those rejections are tested — a guard that cannot fail is worse than
none, because it reads as coverage.

Falsified before committing rather than after: ran the script against good
and broken inputs and watched all three failure paths fire; verified every
asserted value by executing it rather than by reading it; and checked the
two workflow predicates catch their regressions while staying immune to a
comment that merely names the old formula.

Step 5 of 5 — Scribe task #3812, milestone #390.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 22:11:51 -04:00
bvandeusenandClaude Opus 5 ca1c18bbbb fix(android): miniplayer content sat at the top of its bar, not centred
android / Build + lint + test (push) Successful in 4m9s
Reported with a screenshot: the bar drew at full height but the cover,
title and transport row hugged its top edge, leaving an empty strip of
surface above the gesture area.

The Surface is a fixed 80dp. Inside it a plain Column stacked a 4dp
progress fill and then MiniRow at its INTRINSIC height — 48dp, set by the
cover and the icon buttons. A Column stacks from the top and nothing
claimed the remainder, so 80 - 4 - 48 = 28dp collected at the bottom.

Measured off the screenshot rather than eyeballed, and the bands agree
exactly: progress fill 14px (4dp at 3.5x), surface 280px (80dp), cover
167px (48dp), empty below 99px (28.3dp). That the arithmetic lands on the
measurement is what makes this the whole cause rather than one contributor.

MiniRow was already centring its content correctly — inside a box that was
only ever 48dp tall. Giving it weight(1f) lets it take what the progress
fill leaves, so it measures 76dp and centres 48dp of content: 14dp above
and below. The fill stays pinned to the top edge, which is where a
progress indicator belongs.

Not the same bug as issue #2681. That was a dead strip ABOVE the
miniplayer from an unclaimed navigation-bar inset, fixed in v2026.08.18.
This is inside the bar, pure layout, no insets — the surface already
stopped correctly above the gesture area.

CI cannot see this one. There are no Compose UI tests in the repo; the
Android lane is ktlint, detekt and JVM unit tests, and a layout bug needs
an instrumented test to catch. Compilation and lint are all this commit
gets from CI — the visual check is on a device, and the APK only builds on
a tagged release.

Scribe issue #3826.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 22:07:23 -04:00
bvandeusenandClaude Opus 5 68136c64c0 fix(android): decide updates on the ordering key, not the version name
android / Build + lint + test (push) Successful in 3m55s
The app compared NAMES while Android installs by versionCode, with nothing
keeping the two orderings consistent. So it could offer a build the platform
then refused as a downgrade, or stay silent about one it would have
accepted. The offer and the install were asking different questions.

Both consumers — the shell banner and the About card — now route through
one isUpdateAvailable(): decide on the ordering key whenever the server
reports one, since that is the same value the package installer compares,
so an offer implies an install that will actually be accepted. Name
comparison survives only as the fallback for a server predating the field.

isVersionNewer is deliberately untouched. It already degrades per segment
and is not what was broken; rewriting it while nearby would have put the
fallback path at risk for no gain.

code is nullable on the wire, and that is load-bearing rather than
stylistic. The app's Json sets coerceInputValues = true, which replaces a
JSON null with the declared default on a NON-nullable property — so
`val code: Long = 0` would have turned "this server reports no ordering
key" into "its key is 0" silently, ranking every such server as infinitely
behind and offering its build to everyone forever. Reading the field
declaration alone would never show that; it lives in AppModule.

A third caller turned up during the sweep and was deliberately left alone.
NetworkStatusController compares the /healthz minClientVersion, which is a
server-declared compatibility floor rather than the bundled APK — there is
no ordering key on that wire at all, so names remain the only thing it can
compare. Different question, correctly still using the old helper.

The update channel had no tests whatsoever before this, which is worth
stating: the thing deciding whether anyone is ever offered an update fails
silently in both directions. The new suite pins that the key wins when it
disagrees with the name, that a null key falls back rather than reading as
zero, the recorded migration constraint (a new-scheme name outranks an
old-scheme one across a day boundary but NOT within the same day), and the
degradation cases — including that an unparseable DECIDING segment reads as
zero and loses, which is why the channel must never live inside the name.

Every assertion was checked against the real comparison by mirroring it,
rather than from reading it: two of my first-draft comments described the
wrong mechanism and were corrected on the evidence.

Step 4 of 5 — Scribe task #3811, milestone #390.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 22:01:26 -04:00
bvandeusenandClaude Opus 5 9f3e0b8cd3 feat(version): sidecar and /api/client/version carry name, code and channel
test-go / test (push) Successful in 1m25s
test-go / integration (push) Successful in 5m52s
The client compares names while Android installs by versionCode, and the
wire had no way to close that gap: the sidecar was one positional line and
the endpoint returned a name only. This is the plumbing that makes the
ordering key decidable by the client at all.

The sidecar is now JSON rather than a grown positional string. That shape
was chosen against a specific failure: the obvious growth path was
"<name> <code>", which a first-space split silently mangles the moment a
third field appears — the code stops parsing as an integer and the reader
falls back to name comparison WITHOUT erroring. JSON cannot mistake a new
field for an old one.

code is a POINTER on both sides, and omitempty on the wire. Absent has to
stay distinguishable from zero: a build published before ordering keys were
recorded genuinely has no code, and zero would claim it is infinitely old
rather than unknown.

A malformed sidecar now fails loudly instead of serving a blank version.
If an unreadable file produced an empty name, every client would compare
against nothing, conclude it was current, and go quiet — "I cannot read
this" and "there is nothing newer" would return the same answer, which is
the failure mode nobody reports because nobody is offered anything to
report.

The non-tag :latest path no longer RECONSTRUCTS the bundled APK's version.
android-release now publishes the sidecar as a release asset beside the
APK, and the image build downloads it. The old reconstruction duplicated a
derivation formula across two files, and could only ever recover the name —
the ordering key is build-time minutes and exists nowhere once that build
ends. Releases predating the sidecar report their name with a null code,
which is the honest answer rather than a guessed one.

image-release also drops to a shallow checkout: it needed full history and
tags only to re-derive versions from the tagged commit, and now touches git
for nothing. MINSTREL_VERSION comes from GITHUB_REF.

Two things checked rather than assumed. The Android Json sets
ignoreUnknownKeys, so the added fields cannot break already-installed apps.
It also sets coerceInputValues, which will silently turn a null code into 0
if step 4 declares the field non-nullable — recorded on task #3811, because
reading the field declaration alone would never reveal it.

Also fixes a stale comment block describing "the Flutter client", deleted
in v2026.08.18.

Step 3 of 5 — Scribe task #3810, milestone #390.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 21:51:41 -04:00
bvandeusenandClaude Opus 5 e46c6bcccf docs(release): tags become vYYYY.MM.DD.HHMM, and stop telling people to move them
The tag is now the artifact's own version name with a `v` in front, so
`v2026.09.10.1432` and `2026.09.10.1432` are one string. Nothing has to
reconcile what the tag claims against what the APK reports, and minting one
is arithmetic on the tagged commit's timestamp rather than a lookup.

The substantive change is the prose. release.yml's header instructed the
reader to `git push -f origin vYYYY.MM.DD` on a same-day re-cut. That is
the operation the family rulebook forbids outright, and it has incidents
behind it — moving a same-day tag forward once took a published release
down with it. Anyone who had installed from that tag was holding something
it no longer pointed at.

With HHMM there is nothing left for mutability to buy: every tag is unique
by construction, so a second release the same day is not a collision to
resolve, just another tag.

The old instruction is recorded as retired rather than deleted. Someone who
remembers it should learn it was withdrawn and why, not find it silently
absent and assume they misremembered.

README contradicted itself inside one sentence — "immutable per-day release
tags ... a same-day re-cut moves the tag forward" — and now says which it
is, plus a note that pre-2026-09-10 tags keep the old shape and still work.

Transition wrinkle, deliberately left for step 3: the non-tag :latest path
reconstructs the bundled APK's name from the latest release's commit
timestamp, which for the one existing old-shape release yields
2026.09.09.1828 while that APK actually declares 2026.09.09.1895. It fails
SAFE — 1828 compares lower, so no false update is offered — and it
self-corrects at the first new-scheme release. Step 3 removes the
reconstruction entirely by having the sidecar carry recorded values instead
of derived ones.

Step 2 of 5 — Scribe task #3809, milestone #390.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 21:43:33 -04:00
bvandeusenandClaude Opus 5 bfdaed9365 fix(release): derive versionCode from build time, versionName from commit time
android / Build + lint + test (push) Successful in 4m19s
versionCode was `git rev-list --count HEAD`, and build.gradle.kts called it
"monotonic forever". It is not, and that claim was sitting directly above
the bug it denied.

A commit count runs ahead on `dev`. So a dev build carried a HIGHER code
than the `main` release meant to supersede it, and Android refuses that
install as a downgrade — a channel you can enter and cannot leave without
uninstalling and losing local data.

Two clocks now, and the split is deliberate even though it reads like an
inconsistency:

The NAME answers "is this the same code?", so it derives from COMMIT time
and reads identically on every lane building this source. A dev build and
a main build of one commit must report the same string. Build time cannot
do that — it prints two numbers for one thing.

The ORDERING KEY answers "may this be installed over that?", so it must be
monotonic BY CONSTRUCTION: minutes since 2020-01-01. Commit time fails
here for the mirror-image reason — rebuild an older commit and it goes
DOWN, which on a phone is a refused install rather than a confusing label.

The non-tag :latest path reconstructed the bundled APK's name with the old
formula, so it is moved to the same commit-timestamp derivation. That
duplication is temporary: once the tag becomes `v<version-name>` it
collapses to `${TAG#v}` with nothing left to keep in step.

Verified locally by running the derivations rather than reasoning about
them: HEAD yields 2026.09.09.1828; the key yields 3519456 against ~1895
from the old scheme, inside int32 with ~4000 years of headroom; a commit
at 00:42 UTC yields "0042", not "42". The workflow now asserts the emitted
shape too — a malformed name builds, signs and publishes happily and only
surfaces as an update nobody is offered, which nobody reports.

That local check is the only verification this commit gets. release.yml
triggers on main and tags only, so nothing on `dev` executes the new
derivation; CI here proves the Gradle file still parses and nothing else.

Also confirms the migration constraint recorded in milestone #390: this
commit would name a release 2026.09.09.1828, which is LOWER than the
installed 2026.09.09.1895 under name comparison. The first new-scheme
release must be cut on a later calendar day, or existing installs will
never be offered it.

Step 1 of 5 — Scribe task #3808, milestone #390.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 21:37:30 -04:00
bvandeusen 237380b122 New brand mark, and both clients stop fetching their fonts at runtime (#128)
test-web / test (push) Successful in 54s
android / Build + lint + test (push) Successful in 5m36s
release / Build signed APK (tag releases only) (push) Successful in 4m36s
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-09-09 14:36:15 -04:00
bvandeusenandClaude Opus 5 c27f9d484a test(android): guard that the typefaces stay bundled
android / Build + lint + test (push) Successful in 5m17s
The web side has no-external-assets.test.ts; Android had nothing, so the
font provider could come back with no test noticing. This is the Android
half.

Expectations are read out of Typography.kt rather than hardcoded, which
is what makes it a structural pin instead of a list that rots: the guard
extracts every Font(R.font.X, FontWeight.WN) declaration and checks that
X.ttf exists, is really TrueType, and reports N as its OS/2
usWeightClass. Add a face without vendoring it and this fails; change a
declared weight without refetching the matching static instance and it
fails too.

usWeightClass is the check worth having. css2 silently collapses a
multi-weight request to 400 for legacy clients, so Medium comes back as
Regular — a valid TrueType file that renders at the wrong weight
everywhere, and the only field that distinguishes it.

Comments are stripped before the absence check, so the KDoc explaining
why there is no GoogleFont reference cannot satisfy the assertion that
forbids it.

Falsified by mirroring every predicate and byte offset against the real
files: it passes on what is committed, and trips on HEAD~1's
Typography.kt via both the forbidden-symbol check and the
no-declarations-found check. A 400 file asserted against a declared 500
fails, so the weight comparison is not vacuous.

Compilation itself is unverified locally — no Gradle run here — so CI is
the first thing to actually build this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 14:28:56 -04:00
bvandeusenandClaude Opus 5 b52a00df66 fix(android): bundle the typefaces instead of fetching them at runtime
android / Build + lint + test (push) Successful in 4m19s
Typography.kt resolved Fraunces, Inter and JetBrains Mono through the Play
Services font provider, which fetches them over the network on first use.
Same rule-164 problem the web client had, with a second failure mode on
top: the provider is absent entirely on devices without Play Services, so
the app fell back to the platform default and stopped looking like
Minstrel — quietly, with no error.

The five static instances now live in res/font, vendored by the same
tools/vendor-fonts.py that produces the web bundle. Both clients draw
from one list of faces so they cannot drift apart. Cost is ~0.86 MB of
APK; the runtime path is removed rather than kept as a fallback — the
ui-text-google-fonts dependency, its version-catalog entry and the
provider certificate hashes in font_certs.xml are all gone.

Two things about fetching TTFs that are worth writing down, because both
fail by succeeding:

Google Fonts picks the format from the User-Agent, and there is no
parameter to ask for one. A modern UA gets woff2, which res/font cannot
load. The obvious "use an old UA" fix gets EOT — an IE-only format that
downloads happily, has a plausible size, and is entirely useless here. An
Android 4.4 UA is what actually yields TrueType.

css2 also collapses a multi-weight request to 400 for legacy clients, so
asking for Medium silently returns Regular: a valid TrueType file that
renders at the wrong weight everywhere. Each weight is therefore fetched
on its own URL, and the script now asserts OS/2 usWeightClass on every
download — that field is the only thing distinguishing the two files.

Verified before wiring: all five carry TrueType magic, the 400/500 pairs
differ, and their usWeightClass reads 400/400/500/500/400 as declared
beside them in the FontFamily.

Not covered: there is no guard for this on the Android side. The web
equivalent is asserted by no-external-assets.test.ts, but the Android
tree has no source-inspection test pattern to follow and no way to
falsify one without a local Gradle run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 14:13:57 -04:00
bvandeusenandClaude Opus 5 16005054eb fix(web): vendor the web fonts instead of loading them from Google
test-web / test (push) Successful in 42s
app.html linked its stylesheet straight from fonts.googleapis.com, with
preconnects to that host and fonts.gstatic.com. A deployed instance has
no outbound network, so those requests never arrive and the whole UI
renders in fallback faces — Georgia for the display face, whatever the
system has for Inter and JetBrains Mono.

This is invisible in development, which is why it survived: the dev
machine has internet, so the fonts load and everything looks right. Only
a real deployment shows the failure.

tools/vendor-fonts.py fetches the three families once and writes them
under web/static/fonts with a generated stylesheet. static/ is copied
into the SvelteKit build, which Go embeds, so the faces travel inside the
binary. 32 woff2 files, 912K.

Two details that matter for correctness rather than size:

Urls in the generated CSS are relative (./Inter-400-latin.woff2), not
absolute. A url() resolves against the stylesheet's own address, so the
directory keeps working when the app is served under a base path;
/fonts/... would not.

Every subset Google slices is kept, with unicode-range intact. The
browser still fetches only the ranges a page uses, so this costs
repository bytes rather than request bytes — and a library full of
Cyrillic or Greek artist names renders instead of falling back mid-list.

The guard asserts the property, not the vendor: any absolute url in a
resource-loading attribute fails, whoever hosts it, since naming Google
would pass the day someone reached for a different CDN. It also checks
preconnect separately (those carry no fetch of their own, so the url
check misses them), strips HTML comments before asserting an absence so
prose describing the forbidden thing cannot satisfy the check, and pins
the font families to tokens.json rather than a hardcoded list.

Falsified against the pre-change app.html: it trips both the external-url
and preconnect assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 13:49:47 -04:00
bvandeusenandClaude Opus 5 b06a1adfe8 feat(brand): draw a reduced mark so the favicon reads at 16px
The full hat does not resolve below ~32px, which the header worked around
by sizing up. A browser tab cannot: it renders the favicon at 16px and
does not ask. There the mark was a blob.

Deriving a small form from the traced art does not work, and this is the
non-obvious part. Hole-filling, morphological smoothing and dropping
components were all tried; every one of them preserves the overall
silhouette, and the overall silhouette — dominated by a long diagonal
plume — is precisely what fails. The result each time was a diagonal
smear that reads as no object at all.

So the reduced form is drawn rather than derived: a strong horizontal
brim under a crown that peaks left of centre, a band slit so the two do
not fuse, and a short pointed plume. Same lean and proportions as the
full mark, detail removed instead of minified.

favicon.svg and favicon.png now use it; apple-touch, icon-512 and the
Android launcher icons keep the full art, being large enough for it. The
plume carries the accent, which measures 3.04:1 on obsidian and 5.43:1 on
the light ground — both clear of the 3:1 graphics floor.

Also corrects the accent-on-iron figure in the generator's comment from
2.80:1 to 2.70:1. The real --fs-iron is #1E2228; 2.80 came from measuring
against a value I had guessed rather than read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 13:49:31 -04:00
bvandeusenandClaude Opus 5 5593f7ce17 feat(brand): replace the M mark with the traced bard-hat logo
test-web / test (push) Successful in 44s
android / Build + lint + test (push) Successful in 5m9s
The mark is now a feathered hat with an arc of eighth notes, traced from
the operator's reference artwork at 99.74% IoU. The hat takes the text
colour and the note arc holds the accent — the same construction the M
used, and for the same reason: parchment on a light surface is invisible,
so the silhouette has to flip with its background while the accent stays
constant.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 12:52:57 -04:00
80 changed files with 3038 additions and 256 deletions
+187 -55
View File
@@ -2,15 +2,51 @@ name: release
# Builds and pushes the minstrel container image to the Gitea registry.
#
# push to main → :main and :latest (latest-release APK bundled)
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built APK bundled)
# workflow_dispatch → manual trigger (same rules based on the ref)
# push to dev → :dev (freshly-built dev APK bundled)
# push to main → :main and :latest (latest-release APK bundled)
# push tag vYYYY.MM.DD.HHMM → :vYYYY.MM.DD.HHMM and :latest (fresh APK bundled)
# workflow_dispatch → manual trigger (same rules based on the ref)
#
# Release model: per-day CalVer tags (no trailing patch digit). The day's
# tag is intentionally mutable — if a second release happens the same day,
# move the tag with `git push -f origin vYYYY.MM.DD` and the image tag of
# the same name gets overwritten. :latest is updated by every main push
# AND every tag push, so it always reflects the newest blessed image.
# The dev channel exists so testing a build does not require shipping one.
# Before it, the only way to get an APK onto a phone was to cut a release,
# which made `main` the staging area by default. `:dev` carries its own
# freshly-built APK, signed with the SAME key as release builds — a different
# key cannot install over the stable app, so anyone crossing channels would
# have to uninstall and lose their data.
#
# :dev is published ALONE, with no per-commit tag. A rolling channel is
# rolling by definition; a commit-addressable image for it would be a
# rollback target nobody ever pulls, kept forever. Recovery on dev is to fix
# forward.
#
# Note what this repo does NOT need: a cross-repo dispatch to refresh the
# channel when its bundled APK is rebuilt. That mechanism exists elsewhere in
# the family because the app and the server live in separate repos. Minstrel
# is a monorepo — one push builds the APK and the image in the same run from
# the same commit, so the channel cannot go stale against its own artifact.
# The requirement is satisfied structurally; copying the mechanism would add
# a moving part to fix a problem that does not exist here.
#
# Release model: the tag IS the artifact's version name with a `v` in front.
# `v2026.09.10.1432` and `2026.09.10.1432` are the same string, derived from
# the tagged commit's UTC timestamp — so there is no mismatch to reconcile
# between what the tag says and what the APK reports, and nothing to look up
# when minting one.
#
# TAGS ARE IMMUTABLE. Never move, retarget or delete a published tag. A
# same-day second release is not a collision — HHMM makes every tag unique
# by construction, so the answer is simply another tag.
#
# This block used to say the opposite: that the per-day tag was
# "intentionally mutable" and that a same-day re-cut should
# `git push -f origin vYYYY.MM.DD`. That instruction is what the family
# rulebook now forbids outright, and it has incidents behind it — moving a
# same-day tag forward once took a published release down with it. Anyone
# installing from a tag is holding something the tag no longer points at,
# which is a worse failure than an extra row in the tag list.
#
# :latest is updated by every main push AND every tag push, so it always
# reflects the newest blessed image.
#
# APK pipeline: on tag pushes the android-release job builds + signs the
# Android APK and uploads it as a workflow artifact. The image-release
@@ -24,33 +60,37 @@ name: release
# :latest (not just tags), a main build with no APK would silently strip
# the in-app update channel off :latest until the next release. So on
# non-tag builds image-release pulls the MOST RECENT release's signed APK
# and reconstructs its exact versionName (tag + commit-count, the same
# formula android-release bakes in) for the version sidecar — no rebuild,
# just rebundle. Tag builds keep bundling their own freshly-built APK.
# AND the version sidecar published beside it — the recorded values, not
# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds
# keep bundling their own freshly-built APK.
#
# Android testing (lint + detekt + unit tests, debug APK upload on main)
# lives in android.yml and runs independently on every push.
on:
push:
branches: [main]
branches: [main, dev]
tags: ['v*']
paths-ignore:
- 'docs/**'
- '**/*.md'
workflow_dispatch:
# Force-moving the per-day tag (or rapidly re-pushing to main) should
# supersede the in-flight build — the operator explicitly wants the
# later commit to win.
# A rapid re-push to main should supersede the in-flight build — the
# operator explicitly wants the later commit to win. Tags no longer enter
# into this: they are immutable and unique, so no tag build can ever be
# superseded by another run on the same ref.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
android-release:
name: Build signed APK (tag releases only)
if: startsWith(github.ref, 'refs/tags/v')
name: Build signed APK (releases and dev)
# Also builds on `dev`, which is what makes a test channel possible at
# all. Without it the only way to get a build onto a phone was to cut a
# release, which quietly turns `main` into the staging area.
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev'
runs-on: flutter-ci
container:
image: git.fabledsword.com/bvandeusen/ci-android:36
@@ -75,14 +115,18 @@ jobs:
outputs:
version_name: ${{ steps.ver.outputs.name }}
version_code: ${{ steps.ver.outputs.code }}
channel: ${{ steps.ver.outputs.channel }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# fetch-depth: 0 retrieves full history; default shallow clone
# would return 1 for `git rev-list --count HEAD`, breaking the
# iteration suffix.
# Full history. The version name now reads only the tip commit's
# timestamp, so a shallow clone would technically serve — but this
# job derives a value that ships to devices, and a shallow checkout
# changes what git-derived values resolve to WITHOUT failing. The
# whole failure class here is a green build carrying a wrong
# version, so the cheap guarantee is worth keeping.
fetch-depth: 0
- name: Compute release version
@@ -91,12 +135,23 @@ jobs:
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/v}"
COMMIT_COUNT=$(git rev-list --count HEAD)
VERSION_NAME="${TAG}.${COMMIT_COUNT}"
echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"
# The derivation lives in ci/version.sh, not here, so it can be
# executed by a test on every push. Anything inline in this file is
# unverifiable until a release is already running.
out="$(ci/version.sh HEAD)"
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT"
# The channel is a property of the LANE, not of the commit, which is
# why it is derived here rather than in version.sh. Same commit built
# on dev and on main reports the same NAME and differs only here —
# that is the whole point of separating the two values.
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
channel=dev
else
channel=stable
fi
echo "channel=${channel}" >> "$GITHUB_OUTPUT"
echo "::notice::APK $(printf '%s' "${out}" | tr '\n' ' ') channel=${channel}"
# Checked BEFORE the expensive work, not after it. "Attach APK to gitea
# Release" below resolves the release by tag and fails if it is absent —
@@ -108,6 +163,7 @@ jobs:
# the release together, so this passes). A bare `git push origin vX` is the
# case this catches.
- name: Release must exist for this tag
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
working-directory: ${{ github.workspace }}
env:
@@ -171,9 +227,15 @@ jobs:
if-no-files-found: error
- name: Attach APK to gitea Release
# Tag releases only. A dev build has no Release to hang assets on and
# does not need one — the :dev image bundles the APK, and the server
# serves it from /api/client/apk like any other.
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
VERSION_NAME: ${{ steps.ver.outputs.name }}
VERSION_CODE: ${{ steps.ver.outputs.code }}
run: |
set -euxo pipefail
TAG="${GITHUB_REF#refs/tags/}"
@@ -181,6 +243,20 @@ jobs:
APK_PATH="app/build/outputs/apk/release/app-release.apk"
ls -lh "${APK_PATH}"
# Publish the version sidecar as a release asset next to the APK.
#
# This is what lets a later :latest build stop RECONSTRUCTING the
# bundled APK's version and simply read what was recorded. The
# ordering key in particular cannot be re-derived after the fact —
# it is build-time minutes, so once this job ends the value exists
# nowhere else. Reconstruction could only ever recover the name,
# and only by duplicating a formula that then has to be kept in
# step across two files.
SIDECAR_PATH="/tmp/minstrel.apk.version"
printf '{"name":"%s","code":%s,"channel":"stable"}\n' \
"${VERSION_NAME}" "${VERSION_CODE}" > "${SIDECAR_PATH}"
cat "${SIDECAR_PATH}"
RELEASE_JSON="$(curl -fsSL \
-H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}")"
@@ -202,6 +278,20 @@ jobs:
exit 1
fi
# Same treatment for the sidecar. Named `.apk.version` so the
# downloader's `\.apk$` match cannot pick it up by mistake.
SIDECAR_HTTP=$(curl -sS -L -o /tmp/upload-sidecar.out -w '%{http_code}' \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@${SIDECAR_PATH}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk.version")
echo "sidecar_upload_http=${SIDECAR_HTTP}"
cat /tmp/upload-sidecar.out || true
echo
if [ "${SIDECAR_HTTP}" -lt 200 ] || [ "${SIDECAR_HTTP}" -ge 300 ]; then
echo "::error::version sidecar upload returned HTTP ${SIDECAR_HTTP}"
exit 1
fi
image-release:
name: Build + push container image
# `needs:` waits for android-release. For tag pushes android-release
@@ -222,11 +312,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history + tags so non-tag :latest builds can resolve the
# latest release tag's commit count and reconstruct the bundled
# APK's exact versionName (see "Bundle latest release APK" below).
fetch-depth: 0
fetch-tags: true
# Shallow is fine here. This job used to need full history + tags to
# re-derive the bundled APK's version from the tagged commit; it now
# downloads the sidecar the release recorded, and touches git for
# nothing. MINSTREL_VERSION comes from GITHUB_REF, not from git.
fetch-depth: 1
- name: Detect buildable project
id: guard
@@ -249,11 +339,20 @@ jobs:
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "::notice::Release build: ${VERSION} + latest"
elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then
# The rolling test channel, and :dev ALONE — deliberately no
# per-commit tag. A rolling channel is rolling by definition, so a
# commit-addressable image here would be a rollback target nobody
# has ever pulled, accumulating in the registry forever. Recovery
# on dev is to fix forward.
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
echo "version=dev" >> "$GITHUB_OUTPUT"
echo "::notice::Dev-branch build: :dev"
else
# Main is the protected, post-PR-merge branch. Treat it as the
# rolling stable channel — every main push moves :latest.
# Pinned consumers can target :vYYYY.MM.DD; everyone else
# gets the newest main.
# Pinned consumers can target :vYYYY.MM.DD.HHMM, which never
# moves; everyone else gets the newest main.
echo "args=-t ${IMAGE}:main -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "version=main" >> "$GITHUB_OUTPUT"
echo "::notice::Main-branch build: :main + :latest"
@@ -267,9 +366,12 @@ jobs:
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
- name: Download signed APK artifact
# Tag pushes only — android-release just produced this. Non-tag
# builds take the "Bundle latest release APK" path below instead.
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
# Tag and dev pushes — android-release just produced this. Only `main`
# takes the "Bundle latest release APK" path below, because it is the
# one ref that moves a channel without building an APK of its own.
if: >-
steps.guard.outputs.ready == 'true' &&
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
# Consuming half of the pair — never actions/download-artifact. Same fork,
# same reason: upstream's client-side GHES check rejects this hostname
# before it connects. bvandeusen/download-artifact mirrors
@@ -289,32 +391,41 @@ jobs:
path: client/
- name: Stage bundled APK + version sidecar
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
if: >-
steps.guard.outputs.ready == 'true' &&
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
shell: bash
env:
# Pulled from android-release.outputs.version_name so the
# sidecar string the server hands clients matches the
# versionName baked into the APK they're comparing against.
# All three pulled from android-release's outputs so the sidecar the
# server hands clients matches exactly what is baked into the APK
# they are comparing against.
APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
APK_VERSION_CODE: ${{ needs.android-release.outputs.version_code }}
APK_CHANNEL: ${{ needs.android-release.outputs.channel }}
run: |
set -euxo pipefail
# The artifact lands as `app-release.apk` (the original Gradle
# output name). The Dockerfile COPYs client/* into /app/client/
# and the server reads minstrel.apk + minstrel.apk.version.
mv client/app-release.apk client/minstrel.apk
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
printf '{"name":"%s","code":%s,"channel":"%s"}\n' \
"${APK_VERSION_NAME}" "${APK_VERSION_CODE}" "${APK_CHANNEL}" \
> client/minstrel.apk.version
cat client/minstrel.apk.version
ls -lh client/
- name: Bundle latest release APK (non-tag :latest builds)
# Main pushes don't build an APK, but they DO move :latest — so
# without this the in-app update channel would vanish from :latest
# until the next tag. Pull the most-recent release's signed APK and
# reconstruct its exact versionName (${TAG#v}.$(git rev-list --count
# TAG) — identical to android-release's formula) so the version
# sidecar the server hands clients matches the installed build.
# the sidecar published beside it, so what the server reports is what
# that build actually recorded rather than something re-derived here.
# Degrades to an empty client/ (404 update channel) — never a wrong
# version — if no release / APK asset / tag-count can be resolved.
if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
# version — if no release or APK asset can be resolved. That
# degradation only actually works because the greps below carry
# `|| true`; under the runner's default pipefail a non-matching grep
# kills the step instead of falling through to the empty-case branch.
if: steps.guard.outputs.ready == 'true' && github.ref == 'refs/heads/main'
shell: bash
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
@@ -326,19 +437,40 @@ jobs:
if [ -z "${REL_JSON}" ]; then
echo "::notice::no published release — image ships without bundled APK"; exit 0
fi
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)"
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)"
# `|| true` on every one of these, and it is load-bearing rather
# than defensive habit. The runner already invokes this shell as
# `bash -e -o pipefail`, so a pipeline whose grep matches NOTHING
# exits non-zero even though `head` succeeded — and the step dies at
# the assignment, before ever reaching the `if` written to handle the
# empty case. Every "degrades gracefully" branch below is unreachable
# without this.
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)" || true
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)" || true
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
fi
COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)"
if [ -z "${COUNT}" ]; then
echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
fi
VERSION_NAME="${TAG#v}.${COUNT}"
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
echo "${VERSION_NAME}" > client/minstrel.apk.version
echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}"
# Take the version the release RECORDED rather than recomputing it.
# This used to re-derive the name from the tagged commit, which meant
# the formula lived in two files that had to be kept in step, and it
# could only ever recover the name — the ordering key is build-time
# minutes and does not exist anywhere after that build ends.
SIDECAR_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk\.version$' | head -1)" || true
if [ -n "${SIDECAR_URL}" ]; then
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk.version "${SIDECAR_URL}"
cat client/minstrel.apk.version
else
# Releases published before sidecars were attached. Their name is
# still recoverable from the tag, but their ordering key genuinely
# is not — so it is reported ABSENT rather than guessed. A wrong
# key is an install the platform refuses; an absent one just tells
# the client to fall back to comparing names, which is exactly
# what those builds already do.
echo "::notice::release ${TAG} predates the version sidecar — bundling with name only, no ordering key"
printf '{"name":"%s","code":null,"channel":"stable"}\n' "${TAG#v}" > client/minstrel.apk.version
fi
echo "::notice::bundled release APK from ${TAG}"
ls -lh client/
- name: Build and push
+6
View File
@@ -32,6 +32,12 @@ on:
- 'cmd/**'
- '.golangci.yml'
- '.gitea/workflows/test-go.yml'
# The release lane's own trigger is `main` + tags, so nothing it
# contains is exercised until a release is already running. These two
# entries are what let internal/server/release_version_test.go guard
# the version derivation on ordinary dev pushes instead.
- 'ci/**'
- '.gitea/workflows/release.yml'
# pull_request trigger intentionally omitted — see test-web.yml for
# the rationale (single-author repo, push covers PR-merge equivalent).
+9 -2
View File
@@ -113,10 +113,17 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
- `:vYYYY.MM.DD` — immutable per-day release tags. Pin one of these for a deployment you don't want moving under you. (Per-day CalVer — no trailing patch digit; a same-day re-cut moves the tag forward.)
- `:vYYYY.MM.DD.HHMM` — immutable release tags, never moved or deleted. Pin one for a deployment you don't want changing under you. The tag is the build's own version name with a `v` in front, derived from the tagged commit's UTC timestamp, so two releases can never collide and a re-cut is simply a new tag.
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
- `:dev` — the rolling test channel, rebuilt on every push to `dev` and carrying its own freshly-built Android APK. Run this when you want to try something before it ships. It moves constantly, has no per-commit tag to pin, and its only recovery path is forward — if a `:dev` image is broken, the fix is the next push, not a rollback.
Every `:latest` and every `:vYYYY.MM.DD` bundles the current signed Android APK, so the in-app update channel is always live. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
Every `:latest`, `:vYYYY.MM.DD.HHMM` and `:dev` bundles a signed Android APK, so the in-app update channel is always live. All of them are signed with the same key, so a phone can move between the stable and dev channels without uninstalling — point it at a `:dev` server and the in-app updater offers that channel's build.
The app reports which channel it is on alongside its version, and decides whether an update is available using the build's ordering key rather than its displayed name — the same value Android installs by, so an offer it makes is one the platform will accept.
Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
Releases before 2026-09-10 use the older per-day `:vYYYY.MM.DD` shape. Those tags still exist and still work — they are simply not extended.
## Specs
+18 -8
View File
@@ -21,13 +21,24 @@ android {
applicationId = "com.fabledsword.minstrel"
minSdk = 26
targetSdk = 36
// versionName / versionCode are released-build values injected by
// CI from the git tag + commit count. Local / debug builds fall
// back to "dev" so the About card reads honestly. Releases ship
// versionName="YYYY.MM.DD.<commits>" (e.g. "2026.06.02.142") and
// versionCode=<commits>, which is monotonic forever and lets the
// shared isVersionNewer comparator distinguish two same-day
// re-cuts (the iteration suffix differs).
// versionName / versionCode are released-build values injected by CI.
// Local / debug builds fall back to "dev" so the About card reads
// honestly.
//
// versionName is "YYYY.MM.DD.HHMM" from the COMMIT's timestamp, so
// every lane building this source reports the same string and the
// channel is the only thing that differs between them.
//
// versionCode is minutes since 2020-01-01 at BUILD time. It is the
// value the platform decides installs by, so it must be monotonic by
// construction.
//
// This comment used to say versionCode was a commit count and that it
// was "monotonic forever". It was neither — a commit count runs ahead
// on `dev`, so a dev build outranked the `main` release meant to
// replace it and Android refused the install as a downgrade. Worth
// knowing the claim was here, stated as a reassurance, while the bug
// it denied was live.
val versionNameOverride =
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val versionCodeOverride =
@@ -150,7 +161,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)
@@ -1,15 +1,26 @@
package com.fabledsword.minstrel.models
/**
* Wire shape returned by `GET /api/client/version`. Mirrors
* the Flutter client's `UpdateInfo`.
* The server-bundled APK, as reported by `GET /api/client/version`.
*
* `version` is the server-bundled APK version (may have a leading
* "v" from the git tag); `apkUrl` is server-relative (e.g.
* `/api/client/apk`); `sizeBytes` is the download size.
* Three values that are deliberately kept apart:
*
* - [version] is a LABEL for people — "YYYY.MM.DD.HHMM", derived from the
* build's commit, so two channels carrying the same code read the same.
* Display this; never decide on it when [code] is present.
* - [code] is the ORDERING KEY, and is the same value Android itself
* installs by. It answers "may this be installed over that?", which the
* name cannot. Null when the server predates the field.
* - [channel] is a SIBLING FIELD, never a suffix inside the name. Reported
* verbatim rather than validated, so an unexpected value is shown rather
* than dropped.
*
* [apkUrl] is server-relative (e.g. `/api/client/apk`).
*/
data class UpdateInfo(
val version: String,
val code: Long?,
val channel: String?,
val apkUrl: String,
val sizeBytes: Long,
)
@@ -4,12 +4,26 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire shape for `GET /api/client/version`. Defaults match Flutter:
* apk_url falls back to `/api/client/apk` if the server omits it.
* Wire shape for `GET /api/client/version`.
*
* `apkUrl` falls back to `/api/client/apk` if the server omits it.
*
* [code] MUST stay nullable, and this is not a style preference. The app's
* Json is configured with `coerceInputValues = true`, which replaces a JSON
* null with the declared default for a NON-nullable property — so writing
* `val code: Long = 0` would turn "this server reports no ordering key" into
* "this build's ordering key is 0", silently, with no error anywhere. A
* nullable type is what keeps absent distinguishable from zero, and the
* distinction is the whole reason the field exists.
*
* A server predating the ordering key sends neither [code] nor [channel];
* both arrive null and the caller falls back to comparing names.
*/
@Serializable
data class UpdateInfoWire(
val version: String = "",
val code: Long? = null,
val channel: String? = null,
@SerialName("apk_url") val apkUrl: String = "/api/client/apk",
@SerialName("size_bytes") val sizeBytes: Long = 0,
)
@@ -109,8 +109,11 @@ private fun MiniCover(coverUrl: String, contentDescription: String) {
* NowPlayingScreen via [onExpandClick].
*
* Layout (Column):
* - Slim seek slider at the top (4dp track)
* - Row: cover | title/artist column | like | prev | play/pause | next
* - Slim seek slider pinned at the top (4dp track)
* - Row: cover | title/artist column | like | prev | play/pause | next.
* Weighted so it fills the rest of the fixed-height bar and centres its
* own content; otherwise the row keeps its intrinsic 48dp and the
* leftover height collects at the bottom as dead surface.
*
* No kebab on the mini bar (operator 2026-06-01): the full kebab
* surface lives on NowPlayingScreen, and dropping it from the mini
@@ -164,6 +167,12 @@ fun MiniPlayer(
durationMs = state.durationMs,
)
MiniRow(
// Take whatever the progress fill leaves. Without this the
// Column stacks 4dp + the row's intrinsic 48dp from the top
// and the remaining 28dp of an 80dp bar sits empty
// underneath — the content looked top-aligned rather than
// centred, with a dead strip above the gesture bar.
modifier = Modifier.weight(1f),
track = track,
isPlaying = state.isPlaying,
isUpnpLoading = state.isUpnpLoading,
@@ -205,6 +214,7 @@ private fun MiniProgressFill(positionMs: Long, durationMs: Long) {
@Composable
@Suppress("LongParameterList")
private fun MiniRow(
modifier: Modifier,
track: TrackRef,
isPlaying: Boolean,
isUpnpLoading: Boolean,
@@ -216,7 +226,7 @@ private fun MiniRow(
onToggleLike: () -> Unit,
) {
Row(
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -9,7 +9,7 @@ import com.fabledsword.minstrel.update.data.ApkInstaller
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.UpdateRepository
import com.fabledsword.minstrel.update.data.isBusy
import com.fabledsword.minstrel.update.data.isVersionNewer
import com.fabledsword.minstrel.update.data.isUpdateAvailable
import com.fabledsword.minstrel.update.data.message
import com.fabledsword.minstrel.update.data.stage
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -37,6 +37,10 @@ sealed interface UpdateCheckResult {
data class AboutUiState(
val installedVersion: String = BuildConfig.VERSION_NAME,
// The value the platform installs by, and therefore the one the update
// check must decide on. Held in state rather than read inline so a test
// can drive the comparison without a BuildConfig.
val installedCode: Long = BuildConfig.VERSION_CODE.toLong(),
val isChecking: Boolean = false,
val installStage: InstallStage = InstallStage.IDLE,
val installMessage: String? = null,
@@ -45,8 +49,9 @@ data class AboutUiState(
/**
* Backs the About card's update controls. "Check for updates" calls
* [UpdateRepository.getLatest], compares versus the build's
* VERSION_NAME via [isVersionNewer], and reports the terminal state.
* [UpdateRepository.getLatest], compares versus this build via
* [isUpdateAvailable] — on the ordering key where the server reports one,
* on the name otherwise — and reports the terminal state.
* When an update is available, [install] downloads the APK via
* [ApkInstaller] and installs it — routing the user to the "install
* unknown apps" settings page first when that permission hasn't been
@@ -66,9 +71,17 @@ class AboutCardViewModel @Inject constructor(
viewModelScope.launch {
internal.update { it.copy(isChecking = true, installMessage = null) }
val installed = internal.value.installedVersion
val installedCode = internal.value.installedCode
val result = runCatching { repository.getLatest() }
.map { latest ->
if (isVersionNewer(latest.version, installed)) {
if (
isUpdateAvailable(
serverCode = latest.code,
serverName = latest.version,
installedCode = installedCode,
installedName = installed,
)
) {
UpdateCheckResult.UpdateAvailable(latest)
} else {
UpdateCheckResult.Latest
@@ -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),
)
/**
@@ -19,7 +19,8 @@ private const val POLL_INTERVAL_MS = 24 * 60 * 60 * 1000L
/**
* Drives the shell's soft "update available" banner. Polls
* `/api/client/version` at launch + every 24h and, when the bundled
* APK is strictly newer than this build, exposes its [UpdateInfo] so
* APK outranks this build — by ordering key where the server reports one,
* by name otherwise — exposes its [UpdateInfo] so
* [com.fabledsword.minstrel.update.ui.UpdateBanner] can nudge an
* install. Mirrors Flutter's `ClientUpdateController`.
*
@@ -58,6 +59,13 @@ class UpdateBannerController @Inject constructor(
private suspend fun runOnce() {
val info = runCatching { repository.getLatest() }.getOrNull() ?: return
latest.value = info.takeIf { isVersionNewer(it.version, BuildConfig.VERSION_NAME) }
latest.value = info.takeIf {
isUpdateAvailable(
serverCode = it.code,
serverName = it.version,
installedCode = BuildConfig.VERSION_CODE.toLong(),
installedName = BuildConfig.VERSION_NAME,
)
}
}
}
@@ -21,10 +21,39 @@ class UpdateRepository @Inject constructor(retrofit: Retrofit) {
private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo(
version = version,
code = code,
channel = channel,
apkUrl = apkUrl,
sizeBytes = sizeBytes,
)
/**
* True when [server] should be offered over the installed build.
*
* **Decide on the ordering key whenever the server sends one.** That is the
* same value Android's package installer compares, so an offer made this way
* implies an install the platform will actually accept. The app used to
* compare NAMES while the platform installed by `versionCode`, with nothing
* keeping the two orderings consistent — so it could offer a build Android
* then refused as a downgrade, or stay quiet about one it would have taken.
*
* Name comparison survives only as the fallback for a server that predates
* the field. A null code means "this server cannot tell me" — never "zero" —
* because treating absent as zero would rank every such server as infinitely
* old and offer its build to everyone, forever.
*/
fun isUpdateAvailable(
serverCode: Long?,
serverName: String,
installedCode: Long,
installedName: String,
): Boolean =
if (serverCode != null) {
serverCode > installedCode
} else {
isVersionNewer(serverName, installedName)
}
/**
* True when [server] is strictly newer than [installed]. Mirrors
* Flutter's `isVersionNewer` — splits both strings on `.`, parses
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 25 KiB

@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Google Fonts provider certificate hashes for downloadable fonts via
androidx.compose.ui.text.googlefonts.GoogleFont.Provider. Standard
values published by Google; copied verbatim from the AndroidX docs. -->
<resources>
<array name="com_google_android_gms_fonts_certs">
<item>@array/com_google_android_gms_fonts_certs_dev</item>
<item>@array/com_google_android_gms_fonts_certs_prod</item>
</array>
<string-array name="com_google_android_gms_fonts_certs_dev">
<item>MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpoyLcfobBPv6yyz8x1IxWWmF9c1IGN3vSL6BLNJEUyMEPzC2WZdwT4ZG2cuJTtzeETl6jWFKx68ETtZxNVHe9Iy9NMxEljDqVZ4y6+FlHaiYJqq3LcJpJVuKYz4kvOcyf3M0nDA8mUlVdfsOlw/H4uoNQ7VrAQUKB4kAyfxsKp/RZmnZSJ7+8Ag9aTC+oguTd1iFNuMqDUlpePo6CGuh73iKuq8mYvtdQQ0Yz+mF4j2YWB7Gj0R1k2cCAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs=</item>
</string-array>
<string-array name="com_google_android_gms_fonts_certs_prod">
<item>MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAwEAAaOB1zCB1DAdBgNVHQ4EFgQUhzkS9E6G+x8U7eIYZVgWyN4j2u4wgaQGA1UdIwSBnDCBmYAUhzkS9E6G+x8U7eIYZVgWyN4j2u6heKR2MHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZIIJAMLgh0ZkSjCNMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABywqUAtNkXf2EVQuRGiI3pnNvIYx7N5xj4LMtloEdEqMpEcMa6Qe87qDx2hsArOR1nzQAFGsT/8YIIfX0fAJjQuP1lAcExSxVKbFICEvFBaWuhGgOOZ7CYzfHB6tEzJFLR2DQHQrXLT2HKDDhxhe9hKzqIRDSc5Hjr3jY5MMzfYM5lFvKK9pLqEsP6/Ad9SDhupcVoOWVrSCNKfRb6jpJbZuxJhCnq8tmlV4iy5tEW0a3VBYzpRoBdAaORWqHQTUlt+iL3aH7C5OxhgN/JuxvxXBL/3kkc0wK1ZNuk+sb4lNXmHnVqQYTcyowQHRPCRsPzCCl4ANULRpZjxAd0xUgg=</item>
</string-array>
</resources>
@@ -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
}
}
@@ -0,0 +1,163 @@
package com.fabledsword.minstrel.update.data
import org.junit.jupiter.api.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The update channel had no tests at all before this. That is worth saying
* out loud, because the thing it decides — whether anyone is ever offered an
* update — fails silently in both directions: an update nobody is offered
* looks exactly like being up to date, and nobody files a bug about a prompt
* they never saw.
*/
class UpdateVersioningTest {
@Test
fun `decides on the ordering key when the server reports one`() {
assertTrue(
isUpdateAvailable(
serverCode = 3523847, serverName = "2026.09.10.1432",
installedCode = 3519456, installedName = "2026.09.09.1828",
),
)
assertFalse(
isUpdateAvailable(
serverCode = 3519456, serverName = "2026.09.09.1828",
installedCode = 3523847, installedName = "2026.09.10.1432",
),
)
}
@Test
fun `an equal ordering key is not an update`() {
assertFalse(
isUpdateAvailable(
serverCode = 3523847, serverName = "2026.09.10.1432",
installedCode = 3523847, installedName = "2026.09.10.1432",
),
)
}
/**
* The property the whole rework exists for: the offer must agree with what
* the platform will actually install. Where the two disagree, the ordering
* key wins, because that is the value Android compares.
*/
@Test
fun `the ordering key wins even when the name disagrees`() {
// Name looks older, key is newer — e.g. an older commit rebuilt later.
assertTrue(
isUpdateAvailable(
serverCode = 9_000_000, serverName = "2020.01.01.0000",
installedCode = 1, installedName = "2099.12.31.2359",
),
)
// Name looks newer, key is not. Offering this would be offering an
// install the platform then refuses as a downgrade.
assertFalse(
isUpdateAvailable(
serverCode = 1, serverName = "2099.12.31.2359",
installedCode = 9_000_000, installedName = "2020.01.01.0000",
),
)
}
@Test
fun `falls back to the name when the server reports no ordering key`() {
assertTrue(
isUpdateAvailable(
serverCode = null, serverName = "2026.09.10.1432",
installedCode = 3519456, installedName = "2026.09.09.1828",
),
)
assertFalse(
isUpdateAvailable(
serverCode = null, serverName = "2026.09.09.1828",
installedCode = 3519456, installedName = "2026.09.10.1432",
),
)
}
/**
* A null code must never be read as zero. Zero would rank every
* older server as infinitely behind and offer its build to everyone,
* forever — so this asserts the fallback runs instead of a comparison
* against 0 succeeding by accident.
*/
@Test
fun `a null ordering key is absent, not zero`() {
// installedCode is 0 here: if null coerced to 0, "0 > 0" would be
// false and this would wrongly report no update despite a newer name.
assertTrue(
isUpdateAvailable(
serverCode = null, serverName = "2026.09.10.1432",
installedCode = 0, installedName = "2026.09.09.1828",
),
)
}
/**
* The recorded migration constraint, pinned so it cannot be forgotten:
* the old scheme's fourth segment was a commit count (~1895), the new
* one is HHMM. Across a day boundary the date decides and all is well.
*/
@Test
fun `new-scheme name outranks an old-scheme name on a later day`() {
assertTrue(isVersionNewer("2026.09.10.1432", "2026.09.09.1895"))
}
/**
* ...but on the SAME day the comparison comes down to HHMM against a
* commit count, and any build before ~19:00 UTC reads as older. This is
* why the first new-scheme release had to be cut on a later calendar day.
* Asserting the trap so nobody "fixes" it by accident.
*/
@Test
fun `same-day new-scheme name can read older than an old-scheme name`() {
assertFalse(isVersionNewer("2026.09.09.1828", "2026.09.09.1895"))
}
@Test
fun `name comparison degrades per segment rather than discarding`() {
// The string is still compared rather than rejected outright: an
// earlier segment decides and the unparseable tail never matters.
assertTrue(isVersionNewer("2026.09.10.1432-dev", "2026.09.09.1828"))
// A shorter name pads with zeros instead of being refused.
assertTrue(isVersionNewer("2026.09.10", "2026.09.09.9999"))
assertFalse(isVersionNewer("2026.09.10", "2026.09.10.0"))
}
/**
* What "costs that segment's precision" actually means, and it is worth
* pinning because it is a real edge rather than a nicety: when the
* unparseable segment is the DECIDING one, it reads as 0 and loses. So a
* `-dev` suffixed build compares as older than an unsuffixed one from the
* same minute.
*
* That is the correct behaviour for a degrading parser — it is bounded
* loss rather than a discarded string — but it is exactly why the channel
* belongs in its own field and never in the name.
*/
@Test
fun `an unparseable deciding segment reads as zero and loses`() {
assertFalse(isVersionNewer("2026.09.10.1432-dev", "2026.09.10.1000"))
}
/**
* Both sides unparseable (branch-name builds) falls back to string
* inequality, so a dev build still surfaces rather than comparing equal
* and going silent.
*/
@Test
fun `two unparseable names fall back to string inequality`() {
assertTrue(isVersionNewer("main", "dev"))
assertFalse(isVersionNewer("dev", "dev"))
}
@Test
fun `a leading v is ignored on either side`() {
assertTrue(isVersionNewer("v2026.09.10.1432", "2026.09.09.1828"))
assertFalse(isVersionNewer("v2026.09.10.1432", "v2026.09.10.1432"))
}
}
-1
View File
@@ -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" }
Executable
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
#
# Derives the three values a build is stamped with, and the tag that names it.
#
# name=YYYY.MM.DD.HHMM label for people, from the COMMIT's timestamp
# code=<int> ordering key, minutes since 2020-01-01 at BUILD time
# tag=v<name> what a release of this commit must be called
#
# Usage: ci/version.sh [<commit-ish>] (default HEAD)
#
# This exists as a script rather than inline workflow YAML for one reason:
# release.yml only runs on `main` and on tags, so anything living inside it is
# unverifiable until a release is already happening — which is the worst
# possible moment to discover the version is wrong, because the failure mode
# is silent (an update nobody is offered looks exactly like being current).
# As a script it can be executed by a test on every push instead.
#
# The two clocks are deliberate and are NOT interchangeable:
#
# The NAME answers "is this the same code?" — so it must read identically on
# every lane that builds this commit. Commit time does that; build time
# prints two different strings for one thing.
#
# The CODE answers "may this be installed over that?" — so it must be
# monotonic BY CONSTRUCTION. Build time is; commit time is not (rebuild an
# older commit and it goes down, which on a phone is a refused install), and
# a commit COUNT is worse still, because it runs ahead on `dev` and inverts
# against `main`.
set -euo pipefail
readonly EPOCH_2020=1577836800 # 2020-01-01T00:00:00Z
readonly REF="${1:-HEAD}"
# Both clocks are overridable so a test can pin them. Nothing but tests should
# set these — the defaults are the real derivation.
commit_epoch="${MINSTREL_COMMIT_EPOCH:-}"
if [ -z "${commit_epoch}" ]; then
commit_epoch="$(git log --format=%ct -1 "${REF}")"
fi
now_epoch="${MINSTREL_NOW_EPOCH:-$(date -u +%s)}"
if ! name="$(date -u -d "@${commit_epoch}" +%Y.%m.%d.%H%M 2>/dev/null)"; then
echo "version.sh: could not read a commit timestamp from '${commit_epoch}'" >&2
exit 1
fi
if ! [ "${now_epoch}" -eq "${now_epoch}" ] 2>/dev/null; then
echo "version.sh: build timestamp '${now_epoch}' is not a number" >&2
exit 1
fi
code=$(( (now_epoch - EPOCH_2020) / 60 ))
# Assert the shape here, at the source. A malformed name builds, signs and
# publishes perfectly happily; it only surfaces later as an update channel
# that has quietly stopped offering anything.
if [[ ! "${name}" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]{4}$ ]]; then
echo "version.sh: name '${name}' is not YYYY.MM.DD.HHMM" >&2
exit 1
fi
# A non-positive key means the build clock is set before 2020, and every
# comparison downstream would be nonsense.
if [ "${code}" -le 0 ]; then
echo "version.sh: ordering key '${code}' is not positive — build clock wrong?" >&2
exit 1
fi
# Android's versionCode is a signed 32-bit int and the platform refuses an APK
# whose code exceeds it. At ~525k minutes a year this is four thousand years
# away in normal operation, so the realistic cause is a build machine with a
# badly wrong clock — which produces a code that is not merely too large but
# also unreachably high, permanently blocking every real build that follows
# from ever outranking it. Cheaper to refuse the build than to discover that
# from a phone that will not update.
readonly VERSION_CODE_CEILING=2147483647
if [ "${code}" -gt "${VERSION_CODE_CEILING}" ]; then
echo "version.sh: ordering key '${code}' exceeds versionCode's int32 ceiling — build clock wrong?" >&2
exit 1
fi
# KEY=VALUE, which is also exactly $GITHUB_OUTPUT's format.
echo "name=${name}"
echo "code=${code}"
echo "tag=v${name}"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+54 -7
View File
@@ -6,19 +6,21 @@ package api
// /app/client/ at image build time.
//
// Both endpoints are authenticated — the bandwidth cost of the APK
// (~30-60 MB) makes anonymous access an abuse vector. The Flutter
// client's polling only fires after login (banner mounts in the post-
// login shell), so this gate is invisible to the actual update flow.
// (~30-60 MB) makes anonymous access an abuse vector. The client only
// polls after login, so this gate is invisible to the actual update flow.
//
// /api/client/apk additionally rate-limits per user to a single
// download every 60s. Real install flows fire one download per
// update; anything tighter is scripted/abusive.
//
// Returns 404 gracefully when the APK isn't present (dev environments,
// pre-CI-wiring); the Flutter client treats 404 as "no update channel
// available."
// pre-CI-wiring); the client treats 404 as "no update channel available."
//
// (These paragraphs said "the Flutter client" until 2026-09-10. That client
// was deleted in v2026.08.18 — the Android app is the only one now.)
import (
"encoding/json"
"errors"
"net/http"
"os"
@@ -84,8 +86,36 @@ func clientAPKAllowDownload(userID string, now time.Time) time.Duration {
return 0
}
// clientVersionSidecar is the JSON written beside the bundled APK by
// release.yml. It carries three values that are deliberately separate:
//
// - Name is a LABEL for people, "YYYY.MM.DD.HHMM" from the commit's
// timestamp. Two channels carrying the same code report the same name.
// - Code is the ORDERING KEY, minutes since 2020-01-01 at build time, and
// is the value Android itself installs by. It answers "may this be
// installed over that?" — the name never does.
// - Channel is a SIBLING FIELD, never a suffix inside the name.
//
// JSON rather than a positional line on purpose. The obvious growth path for
// the old one-value file was "<name> <code>", which a first-space split
// silently mangles the moment a third field appears: the code stops parsing,
// and the reader falls back to name comparison WITHOUT erroring.
type clientVersionSidecar struct {
Name string `json:"name"`
// Pointer, not int64: absent must stay distinguishable from zero. An
// artifact published before codes were recorded genuinely has no code —
// zero would claim it is infinitely old rather than unknown.
Code *int64 `json:"code"`
Channel string `json:"channel"`
}
type clientVersionResponse struct {
Version string `json:"version"`
Version string `json:"version"`
// omitempty on both: the client must be able to tell "this server does
// not report a code" from "this build's code is 0", because those call
// for different behaviour on the other end.
Code *int64 `json:"code,omitempty"`
Channel string `json:"channel,omitempty"`
APKURL string `json:"apk_url"`
SizeBytes int64 `json:"size_bytes"`
}
@@ -117,8 +147,25 @@ func (h *handlers) handleClientVersion(w http.ResponseWriter, _ *http.Request) {
return
}
var sidecar clientVersionSidecar
if err := json.Unmarshal(versionBytes, &sidecar); err != nil {
// Fail LOUDLY rather than serving a blank version. The failure mode
// this avoids is the one that never gets reported: if an unreadable
// sidecar produced an empty name, every client would compare against
// nothing, conclude it was current, and go quiet — "I cannot read
// this" and "there is nothing newer" would be the same answer.
writeErrWithLog(w, h.logger, "client_version: sidecar is not valid JSON", err)
return
}
if sidecar.Name == "" {
http.Error(w, `{"error":{"code":"bad_client_version","message":"version sidecar has no name"}}`, http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, clientVersionResponse{
Version: strings.TrimSpace(string(versionBytes)),
Version: strings.TrimSpace(sidecar.Name),
Code: sidecar.Code,
Channel: strings.TrimSpace(sidecar.Channel),
APKURL: "/api/client/apk",
SizeBytes: stat.Size(),
})
+75 -6
View File
@@ -77,18 +77,32 @@ func TestClientVersion_404WhenAPKButNoVersion(t *testing.T) {
}
}
func TestClientVersion_200WithBothFiles(t *testing.T) {
// writeClientAssets stages an APK plus a raw sidecar body, and returns the
// APK's size so callers can assert size_bytes without recomputing it.
func writeClientAssets(t *testing.T, sidecar string) int64 {
t.Helper()
dir := withClientAPKDir(t)
body := []byte("fake apk content")
if err := os.WriteFile(filepath.Join(dir, clientAPKFilename), body, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, clientVersionFile), []byte("v2026.05.10\n"), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(dir, clientVersionFile), []byte(sidecar), 0o644); err != nil {
t.Fatal(err)
}
return int64(len(body))
}
func getClientVersion(t *testing.T) *httptest.ResponseRecorder {
t.Helper()
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
rr := httptest.NewRecorder()
h.handleClientVersion(rr, httptest.NewRequest(http.MethodGet, "/api/client/version", nil))
return rr
}
func TestClientVersion_200WithBothFiles(t *testing.T) {
size := writeClientAssets(t, `{"name":"2026.09.10.1432","code":3523847,"channel":"stable"}`+"\n")
rr := getClientVersion(t)
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d (body: %s)", rr.Code, rr.Body.String())
}
@@ -96,14 +110,69 @@ func TestClientVersion_200WithBothFiles(t *testing.T) {
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Version != "v2026.05.10" {
t.Errorf("version: want trimmed v2026.05.10, got %q", resp.Version)
if resp.Version != "2026.09.10.1432" {
t.Errorf("version: want 2026.09.10.1432, got %q", resp.Version)
}
if resp.Code == nil {
t.Fatal("code: want 3523847, got absent — the client decides on this, so absent means it silently falls back to name comparison")
}
if *resp.Code != 3523847 {
t.Errorf("code: want 3523847, got %d", *resp.Code)
}
if resp.Channel != "stable" {
t.Errorf("channel: want stable, got %q", resp.Channel)
}
if resp.APKURL != "/api/client/apk" {
t.Errorf("apk_url: want /api/client/apk, got %q", resp.APKURL)
}
if resp.SizeBytes != int64(len(body)) {
t.Errorf("size_bytes: want %d, got %d", len(body), resp.SizeBytes)
if resp.SizeBytes != size {
t.Errorf("size_bytes: want %d, got %d", size, resp.SizeBytes)
}
}
// A release published before ordering keys were recorded has a name and
// genuinely no code. That must arrive as ABSENT, not as 0 — zero would claim
// the build is infinitely old and offer an update to everyone forever.
func TestClientVersion_CodeAbsentIsOmittedNotZero(t *testing.T) {
writeClientAssets(t, `{"name":"2026.09.09","code":null,"channel":"stable"}`)
rr := getClientVersion(t)
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d (body: %s)", rr.Code, rr.Body.String())
}
var resp clientVersionResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Code != nil {
t.Errorf("code: want absent, got %d", *resp.Code)
}
// The wire must omit the key entirely, so a client can distinguish
// "this server reports no code" from "this build's code is 0".
var raw map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil {
t.Fatal(err)
}
if _, present := raw["code"]; present {
t.Errorf("code key should be omitted entirely, body was %s", rr.Body.String())
}
}
// The failure this guards is the one nobody reports: if an unreadable sidecar
// produced an empty version, every client would compare against nothing,
// decide it was current, and go quiet. "I cannot read this" and "there is
// nothing newer" must not be the same answer.
func TestClientVersion_MalformedSidecarErrorsRatherThanReportingNothing(t *testing.T) {
for _, sidecar := range []string{
"2026.09.10.1432", // the OLD plain-text format
`{"name":"x",`, // truncated JSON
`{"code":123,"channel":"dev"}`, // valid JSON, no name
"",
} {
writeClientAssets(t, sidecar)
rr := getClientVersion(t)
if rr.Code == http.StatusOK {
t.Errorf("sidecar %q: want an error status, got 200 with body %s", sidecar, rr.Body.String())
}
}
}
+288
View File
@@ -0,0 +1,288 @@
package server
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
)
// Guards the version derivation that stamps every build.
//
// These assertions used to be impossible to run. The derivation lived inline
// in release.yml, which triggers only on `main` and on tags — so a mistake in
// it could not surface until a release was already under way, and its failure
// mode is silence: a version nobody can compare looks exactly like being up to
// date, and nobody reports an update they were never offered.
//
// Moving it to ci/version.sh made it executable, so this runs on every push
// that touches the release machinery. That is the whole point of the file; the
// specific assertions below matter less than the fact that they run at all.
//
// The tests EXECUTE the script rather than asserting on its text, so they
// break when the behaviour changes rather than when the wording does.
// highestOldSchemeCode is the largest versionCode ever shipped under the
// retired commit-count scheme (v2026.09.09 shipped 1895). Every code the new
// scheme emits must clear it, or Android would refuse the upgrade as a
// downgrade and the update channel would be a one-way door.
const highestOldSchemeCode = 1895
func repoRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatalf("no go.mod above %s", dir)
}
dir = parent
}
}
// runVersion executes ci/version.sh with both clocks pinned, so the result is
// deterministic. Returns the parsed KEY=VALUE output.
func runVersion(t *testing.T, commitEpoch, nowEpoch string) map[string]string {
t.Helper()
out, err := versionScript(t, commitEpoch, nowEpoch)
if err != nil {
t.Fatalf("ci/version.sh failed: %v\n%s", err, out)
}
parsed := map[string]string{}
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
if k, v, ok := strings.Cut(line, "="); ok {
parsed[k] = v
}
}
return parsed
}
func versionScript(t *testing.T, commitEpoch, nowEpoch string) (string, error) {
t.Helper()
root := repoRoot(t)
cmd := exec.Command(filepath.Join(root, "ci", "version.sh"))
cmd.Dir = root
cmd.Env = append(os.Environ(),
"MINSTREL_COMMIT_EPOCH="+commitEpoch,
"MINSTREL_NOW_EPOCH="+nowEpoch,
)
out, err := cmd.CombinedOutput()
return string(out), err
}
func TestVersionName_IsCommitTimeToTheMinute(t *testing.T) {
// 2025-09-09T18:48:56Z
got := runVersion(t, "1757443736", "1789000920")
if want := "2025.09.09.1848"; got["name"] != want {
t.Errorf("name = %q, want %q", got["name"], want)
}
}
// HHMM is the segment most likely to be silently mangled, and it only bites
// for about a tenth of the day — a build just after midnight must emit "0042",
// never "42". A stripped leading zero shifts the segment by two orders of
// magnitude and reverses comparisons against every other build that day.
func TestVersionName_PadsTheMinuteSegment(t *testing.T) {
// 2026-09-10T00:42:00Z
got := runVersion(t, "1789000920", "1789000920")
if want := "2026.09.10.0042"; got["name"] != want {
t.Errorf("name = %q, want %q — leading zero lost?", got["name"], want)
}
}
func TestVersionName_DerivesFromCommitNotBuildClock(t *testing.T) {
// Same commit, two different build clocks: the NAME must not move, or a
// dev build and a main build of one commit would report different strings
// and the channel field would stop being the only thing separating them.
a := runVersion(t, "1757443736", "1789000920")
b := runVersion(t, "1757443736", "1789500000")
if a["name"] != b["name"] {
t.Errorf("name moved with the build clock: %q vs %q", a["name"], b["name"])
}
if a["code"] == b["code"] {
t.Errorf("code did NOT move with the build clock (%q) — it is not build-derived", a["code"])
}
}
func TestVersionCode_IsMinutesSince2020AndClearsTheOldScheme(t *testing.T) {
got := runVersion(t, "1789000920", "1789000920")
code, err := strconv.Atoi(got["code"])
if err != nil {
t.Fatalf("code %q is not an integer: %v", got["code"], err)
}
if want := (1789000920 - 1577836800) / 60; code != want {
t.Errorf("code = %d, want %d", code, want)
}
if code <= highestOldSchemeCode {
t.Errorf("code %d does not clear the retired commit-count scheme (%d) — "+
"Android would refuse the upgrade as a downgrade", code, highestOldSchemeCode)
}
if int64(code) > 2147483647 {
t.Errorf("code %d overflows versionCode's int32 ceiling", code)
}
}
// The tag is not chosen, it is the name with a `v`. Anything else reintroduces
// the mismatch between what a tag claims and what the artifact reports.
func TestTag_IsTheNameWithAPrefix(t *testing.T) {
got := runVersion(t, "1757443736", "1789000920")
if want := "v" + got["name"]; got["tag"] != want {
t.Errorf("tag = %q, want %q", got["tag"], want)
}
}
// A guard that cannot fail is worse than no guard. These prove the script
// rejects the shapes it claims to reject, rather than emitting something
// plausible and letting it ship.
func TestVersionScript_RejectsUnusableClocks(t *testing.T) {
for _, tc := range []struct{ name, commit, now string }{
{"unreadable commit timestamp", "notanumber", "1789000920"},
{"non-numeric build clock", "1789000920", "abc"},
{"build clock before 2020", "1789000920", "1000000000"},
} {
t.Run(tc.name, func(t *testing.T) {
out, err := versionScript(t, tc.commit, tc.now)
if err == nil {
t.Errorf("script succeeded on %s, output: %s", tc.name, out)
}
})
}
}
// Pins the wiring, not the formula: if release.yml stops calling the script,
// every assertion above keeps passing while the thing that actually ships goes
// unguarded again. That silent decoupling is the specific regression here.
func TestReleaseWorkflow_UsesTheSharedDerivation(t *testing.T) {
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
if err != nil {
t.Fatal(err)
}
yaml := string(body)
if !strings.Contains(yaml, "ci/version.sh") {
t.Error("release.yml no longer calls ci/version.sh — the derivation has drifted out of test coverage")
}
// The retired scheme, which must not come back. A presence check is safe
// against prose; the historical note in that file names the old formula
// only in comments, so match the executable form.
if strings.Contains(yaml, "$(git rev-list --count") {
t.Error("release.yml derives a commit count again — that is not monotonic across branches")
}
}
// devArm returns the branch of "Compute image tags" that handles refs/heads/dev.
func devArm(t *testing.T, yaml string) string {
t.Helper()
const marker = `elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then`
i := strings.Index(yaml, marker)
if i < 0 {
t.Fatal("no refs/heads/dev arm in Compute image tags — the dev channel is not wired")
}
rest := yaml[i+len(marker):]
if j := strings.Index(rest, "\n else"); j >= 0 {
return rest[:j]
}
return rest
}
// The worst regression this wiring can produce: a dev push that also moves
// :latest would ship untested code to every stable operator, silently, on the
// next pull. Nothing else in the suite would notice — the build stays green
// and the image is valid, it is simply the wrong audience.
func TestDevChannel_PublishesDevAloneAndNeverLatest(t *testing.T) {
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
if err != nil {
t.Fatal(err)
}
arm := devArm(t, string(body))
if !strings.Contains(arm, "${IMAGE}:dev") {
t.Errorf("dev arm does not publish :dev\n%s", arm)
}
if strings.Contains(arm, ":latest") {
t.Errorf("dev arm moves :latest — that ships dev code to every stable operator\n%s", arm)
}
// Rule 145: a rolling channel gets no commit-addressable tag.
if strings.Contains(arm, "GITHUB_SHA") {
t.Errorf("dev arm publishes a per-commit tag; a rolling channel should not\n%s", arm)
}
}
// The two bundling paths must stay mutually exclusive. If the rebundle step's
// condition were relaxed back to "not a tag", a dev push would run BOTH: stage
// its freshly-built APK, then overwrite it with the previous release's. The
// image would still build and the sidecar would still parse — it would just
// quietly serve stale art to the channel whose whole job is being current.
func TestDevChannel_RebundlePathIsMainOnly(t *testing.T) {
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
if err != nil {
t.Fatal(err)
}
yaml := string(body)
i := strings.Index(yaml, "- name: Bundle latest release APK")
if i < 0 {
t.Fatal("no 'Bundle latest release APK' step")
}
step := yaml[i:]
if j := strings.Index(step, "\n - name:"); j >= 0 {
step = step[:j]
}
if !strings.Contains(step, "github.ref == 'refs/heads/main'") {
t.Errorf("the rebundle step is not gated to main; a dev push would overwrite its own APK\n%s", step)
}
}
// The runner invokes `shell: bash` as `bash -e -o pipefail`. That makes any
// command substitution whose grep matches NOTHING fatal at the assignment —
// pipefail reports the grep's non-zero status even though `head` succeeded —
// so the step dies before reaching the `if` written to handle the empty case.
//
// This is not hypothetical. It took down the first `main` build after the
// version rework: the newest release predated sidecar assets, its
// `.apk.version` grep matched nothing, and the step aborted instead of
// falling through to the name-only branch that exists precisely for it. The
// same latent hazard sat on the other two assignments and had simply never
// fired, because their greps always matched.
//
// Pins the property that makes every "degrades gracefully" branch in that
// step reachable at all.
func TestBundleStep_GrepsCannotKillTheStep(t *testing.T) {
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
if err != nil {
t.Fatal(err)
}
yaml := string(body)
i := strings.Index(yaml, "- name: Bundle latest release APK")
if i < 0 {
t.Fatal("no 'Bundle latest release APK' step")
}
step := yaml[i:]
if j := strings.Index(step, "\n - name:"); j >= 0 {
step = step[:j]
}
for _, line := range strings.Split(step, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || !strings.Contains(trimmed, "grep") {
continue
}
// Only assignments from a command substitution can abort the step.
if !strings.Contains(trimmed, `="$(`) {
continue
}
if !strings.HasSuffix(trimmed, "|| true") {
t.Errorf("this assignment dies under pipefail when its grep matches nothing, "+
"skipping the empty-case branch below it:\n %s", trimmed)
}
}
}
+377
View File
@@ -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'<path d="([^"]+)"', s)),
re.search(r'<g transform="([^"]+)"', s).group(1))
def wrap(d, width=78, indent=" " * 6):
"""Re-wrap path data so diffs stay reviewable instead of one endless line."""
out, line = [], ""
for tok in d.split():
if len(line) + len(tok) + 1 > width:
out.append(line)
line = tok
else:
line = f"{line} {tok}".strip()
out.append(line)
return ("\n" + indent).join(out)
# ---------------------------------------------------------------------------
# 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'<path d="([^"]+)"', s)),
re.search(r'<g transform="([^"]+)"', s).group(1))
hat_d, tr = one(hat_only, "rhat")
plume_d, _ = one(plume_only, "rplume")
S = SUPERSAMPLE
vb = f"{box[0]*S} {box[1]*S} {(box[2]-box[0])*S} {(box[3]-box[1])*S}"
return hat_d, plume_d, tr, vb
def svg_doc(vb, tr, hat_fill, notes_fill, hat_d, notes_d, style="", head=""):
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{vb}">\n'
f'{head}{style}'
f' <g transform="{tr}" fill-rule="evenodd">\n'
f' <path fill="{hat_fill}" d="{wrap(hat_d)}"/>\n'
f' <path fill="{notes_fill}" d="{wrap(notes_d)}"/>\n'
f' </g>\n</svg>\n')
def rasterize(svg_path, out, px, plate=None, radius_frac=0.0, art_frac=1.0):
"""Render the mark to a PNG, optionally on a rounded plate.
Rasters carry their own background because a PNG cannot answer to a colour
scheme and iOS composites the touch icon onto white regardless. Obsidian
rather than the raised iron: the accent clears the 3:1 graphics floor
against obsidian (3.04:1) and fails against iron (2.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=" <!-- Generated by tools/gen-brand-assets.py — do not edit by hand.\n"
" Literal colours, for any consumer that cannot inline the SVG and\n"
" therefore cannot supply a currentColor. -->\n"))
# 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=" <!-- Generated by tools/gen-brand-assets.py — do not edit by hand.\n"
" The reduced mark, literal colours. For use at or below ~24px,\n"
" where the full art collapses into a diagonal smear. -->\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=" <!-- Generated by tools/gen-brand-assets.py — do not edit by hand.\n"
" The REDUCED mark, because a tab renders this at 16px.\n"
" The hat flips with the viewer's scheme because a favicon sits on\n"
" browser chrome we don't control: parchment would vanish on a light\n"
" tab strip, obsidian on a dark one. The plume holds the accent in\n"
" both — teal reads against either (3.04:1 on obsidian, 5.43:1 on\n"
" the light ground). -->\n",
style=" <style>\n"
f" svg {{ color: {PARCHMENT}; }}\n"
f" @media (prefers-color-scheme: light) {{ svg {{ color: {OBSIDIAN}; }} }}\n"
" </style>\n"))
plated = ROOT / ".brand-tmp/plated.svg"
plated.write_text(svg_doc(vb, tr, PARCHMENT, ACCENT, hat_d, notes_d))
# Web rasters: plate radius and art scale measured off the icons these
# replace, so the new mark sits where the old one did.
for out, px in [("web/static/brand/icon-512.png", 512),
("web/static/apple-touch-icon.png", 180)]:
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 = '''<script lang="ts">
// The Minstrel mark: a feathered bard's hat with an arc of notes overhead.
//
// Generated by tools/gen-brand-assets.py — do not edit the paths by hand.
// The same silhouette ships in web/static/brand/{{mark,favicon}}.svg and the
// Android launcher icons; regenerate all of them together.
//
// Inlined rather than <img src="mark.svg"> on purpose — an <img> cannot
// inherit currentColor, and inheriting it is the whole point: the hat takes
// the surrounding text colour, so it reads on both the dark and light
// palettes without a second asset. Parchment-on-parchment is invisible,
// which is exactly the bug a fixed fill would reintroduce.
//
// The notes keep the accent in both modes — one of the places the design
// system sanctions the accent (the wordmark).
//
// aria-hidden: every current use sits directly beside the words "Minstrel",
// so labelling it would make a screen reader announce the name twice. A
// STANDALONE use would need its own label.
let {{ size = 28, class: klass = '' }}: {{ size?: number; class?: string }} = $props();
</script>
<svg
viewBox="{vb}"
width={{size * {w} / {h}}}
height={{size}}
class={{klass}}
aria-hidden="true"
focusable="false"
>
<g transform="{tr}" fill-rule="evenodd">
<path fill="currentColor" d="{hat}"/>
<path fill="{accent}" d="{notes}"/>
</g>
</svg>
'''
if __name__ == "__main__":
main()
+172
View File
@@ -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())
+7 -7
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<!--
SVG first: it flips the M with the viewer's colour scheme, which the PNG
SVG first: it flips the hat with the viewer's colour scheme, which the PNG
can't. The PNG is the fallback for browsers without SVG-favicon support
and is plated for the same reason apple-touch-icon is — see brand/.
Ordering matters: browsers take the last icon they understand, so the
@@ -30,12 +30,12 @@
}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500&family=Inter:wght@400;500&family=JetBrains+Mono:wght@400;500&display=swap"
/>
<!-- Fonts are vendored into web/static/fonts by tools/vendor-fonts.py and
served from our own origin. A deployed instance has no outbound
network, so a provider stylesheet would simply never arrive and the
UI would render in fallback faces — hence no font-provider link and
no preconnect to a host we don't run. -->
<link rel="stylesheet" href="%sveltekit.assets%/fonts/fonts.css" />
%sveltekit.head%
<!-- MINSTREL_TOKENS -->
</head>
+279 -36
View File
@@ -1,57 +1,300 @@
<script lang="ts">
// The Minstrel mark: a Didone M whose right leg is an eighth note.
// The Minstrel mark: a feathered bard's hat with an arc of notes overhead.
//
// Generated by tools/gen-brand-assets.py — do not edit the paths by hand.
// The same silhouette ships in web/static/brand/{mark,favicon}.svg and the
// Android launcher icons; regenerate all of them together.
//
// Inlined rather than <img src="mark.svg"> on purpose — an <img> cannot
// inherit currentColor, and inheriting it is the whole point: the letter
// takes the surrounding text colour, so it reads on both the dark and light
// inherit currentColor, and inheriting it is the whole point: the hat takes
// the surrounding text colour, so it reads on both the dark and light
// palettes without a second asset. Parchment-on-parchment is invisible,
// which is exactly the bug a fixed fill would reintroduce.
//
// The note keeps the accent in both modes — one of the places the design
// The notes keep the accent in both modes — one of the places the design
// system sanctions the accent (the wordmark).
//
// ⚠ These paths are duplicated in web/static/brand/favicon.svg and
// web/static/brand/mark.svg, which need literal colours instead of
// currentColor (a favicon has no cascade to inherit from). Change the
// silhouette here, change it there.
//
// aria-hidden: every current use sits directly beside the words "Minstrel",
// so labelling it would make a screen reader announce the name twice. A
// STANDALONE use would need its own label.
let { size = 20, class: klass = '' }: { size?: number; class?: string } = $props();
let { size = 28, class: klass = '' }: { size?: number; class?: string } = $props();
</script>
<svg
viewBox="202 251 902 723"
width={size * 902 / 723}
viewBox="608 592 3928 3568"
width={size * 3928 / 3568}
height={size}
class={klass}
aria-hidden="true"
focusable="false"
>
<g transform="translate(0,1254) scale(0.1,-0.1)" fill-rule="evenodd">
<path fill="currentColor" d="M2252 9878 l3 -152 109 -22 c342 -72 492 -184 538 -405 16 -74 24
-4823 9 -5024 -20 -266 -73 -375 -236 -484 -116 -77 -332 -150 -543 -181 -114
-18 -107 -6 -110 -165 -1 -76 2 -143 7 -148 9 -9 2607 -11 2623 -1 14 9 10
280 -4 291 -7 5 -49 15 -93 22 -380 60 -638 193 -720 374 -63 136 -59 -12 -61
2247 -3 1999 -2 2054 15 2015 116 -253 646 -1520 1183 -2825 71 -173 216 -524
322 -780 206 -494 266 -640 370 -895 97 -237 68 -210 226 -210 l135 0 23 50
c13 27 95 212 182 410 134 307 747 1685 1015 2285 92 205 726 1599 910 2000
65 140 126 274 137 297 22 50 52 71 74 52 12 -10 14 -266 14 -1864 l0 -1852
-82 -7 c-347 -29 -716 -203 -973 -460 -710 -712 -343 -1645 650 -1649 453 -2
892 184 1206 510 193 202 295 397 351 676 l23 112 0 2357 c0 1297 0 2358 1
2358 12 0 142 -49 179 -67 342 -173 607 -545 715 -1004 85 -361 66 -801 -51
-1215 -43 -151 -40 -173 18 -174 53 0 284 377 396 650 243 590 303 1238 163
1754 -178 652 -643 1100 -1294 1248 -93 21 -122 22 -716 25 -707 4 -649 12
-696 -90 -15 -34 -78 -172 -140 -307 -593 -1297 -1123 -2469 -1717 -3800 -210
-472 -193 -437 -204 -418 -11 19 -173 423 -630 1568 -214 536 -394 986 -400
1000 -6 14 -76 187 -156 385 -80 198 -182 452 -228 565 -46 113 -140 346 -209
518 -205 507 -225 554 -244 568 -14 11 -209 13 -1055 14 l-1038 0 3 -152z"/>
<path fill="#4A6B5C" d="M8830 7450 l0 -2582 -32 6 c-517 106 -1064 -47 -1442 -405 -598 -566
-501 -1354 196 -1598 489 -170 1134 -19 1548 363 234 217 350 418 423 736 l22
95 3 2373 c2 1961 5 2372 16 2372 27 0 132 -41 205 -81 315 -169 569 -524 675
-944 50 -198 60 -285 60 -525 0 -288 -27 -487 -105 -756 -34 -120 -35 -130
-11 -143 33 -18 64 11 154 144 227 334 402 795 469 1235 37 238 34 646 -4 843
-149 761 -637 1271 -1353 1418 -98 20 -147 23 -466 27 l-358 4 0 -2582z"/>
<g transform="translate(0.000000,5016.000000) scale(0.250000,-0.250000)" fill-rule="evenodd">
<path fill="currentColor" d="M17412 17089 c-134 -7 -375 -42 -530 -77 -90 -20 -238 -58 -296 -76 -13 -4 -60
-19 -104 -32 -44 -14 -90 -28 -102 -32 -60 -21 -156 -55 -206 -75 -31 -12 -72
-28 -92 -35 -20 -7 -56 -22 -80 -32 -24 -10 -50 -21 -58 -24 -17 -6 -79 -34 -133
-59 -21 -10 -59 -27 -84 -38 -42 -19 -167 -81 -221 -111 -13 -7 -40 -21 -59 -32
-35 -18 -44 -23 -169 -97 -371 -217 -625 -419 -799 -637 -69 -87 -107 -141 -152
-216 -70 -117 -115 -204 -163 -314 -11 -26 -23 -54 -26 -61 -12 -26 -42 -108 -64
-173 -48 -144 -92 -356 -106 -516 -3 -27 -6 -63 -8 -78 -2 -15 -4 -64 -4 -108 0
-77 -2 -94 -11 -89 -5 3 -16 39 -29 91 -54 222 -65 467 -32 690 12 78 31 167 48
220 40 125 60 175 113 286 25 53 28 61 18 63 -9 2 -145 -92 -227 -157 -25 -20
-56 -44 -68 -54 -39 -30 -124 -107 -197 -180 -112 -109 -216 -229 -317 -364 -107
-142 -234 -379 -307 -568 -69 -177 -120 -389 -135 -558 -2 -26 -6 -68 -8 -92 -7
-80 -4 -224 8 -360 8 -89 28 -204 68 -392 8 -35 32 -125 40 -146 2 -5 8 -27 14
-48 6 -21 20 -65 31 -97 10 -33 19 -62 19 -64 0 -8 -17 11 -34 37 -33 51 -118
211 -158 298 -46 100 -102 256 -134 373 -56 211 -78 413 -67 635 4 78 17 180 30
241 12 51 -5 41 -49 -31 -16 -25 -33 -52 -38 -60 -65 -100 -169 -296 -217 -408
-42 -98 -90 -228 -118 -324 -37 -125 -87 -343 -103 -452 -22 -157 -30 -269 -30
-446 0 -178 6 -273 26 -406 2 -14 8 -54 12 -88 8 -58 49 -272 63 -326 21 -87 73
-265 94 -324 11 -31 29 -85 41 -120 12 -35 29 -83 38 -106 9 -23 24 -62 34 -86
22 -56 49 -122 69 -168 9 -19 15 -37 14 -40 -3 -5 -26 22 -62 74 -17 25 -41 60
-52 76 -21 30 -118 181 -134 208 -4 8 -21 35 -36 61 -37 60 -133 247 -170 329
-54 123 -107 261 -131 345 -7 23 -10 29 -16 29 -9 0 -10 -3 -16 -68 -16 -187 -15
-298 4 -473 14 -124 47 -294 94 -485 25 -97 27 -106 83 -274 57 -173 84 -249 124
-348 16 -41 41 -103 55 -138 14 -35 45 -105 68 -156 24 -51 49 -106 57 -124 39
-87 106 -217 205 -398 25 -45 53 -98 63 -117 11 -19 21 -36 23 -37 7 -4 221 -3
225 1 2 3 6 20 7 40 2 19 6 44 8 55 2 11 5 31 6 44 1 13 6 49 10 80 4 31 12 83
16 116 18 132 52 331 112 658 4 23 13 73 20 110 19 101 46 237 66 330 10 45 22
99 26 120 20 91 35 161 48 214 15 62 33 136 40 162 4 14 21 79 37 144 17 65 35
134 40 154 6 20 18 63 27 96 36 134 103 348 152 486 14 39 32 90 40 114 9 24 23
65 32 90 9 25 24 66 32 90 8 24 24 65 34 90 10 25 28 72 40 104 26 70 43 112 119
292 9 21 20 48 25 60 5 12 20 46 33 76 157 352 355 737 536 1042 76 127 135 224
149 246 8 12 22 36 32 52 123 215 410 628 606 872 180 225 306 368 466 528 173
173 333 313 513 449 72 55 91 67 91 58 0 -6 -8 -14 -123 -135 -81 -86 -168 -183
-283 -316 -135 -156 -335 -426 -496 -668 -81 -123 -202 -319 -270 -441 -21 -36
-52 -91 -70 -123 -52 -91 -166 -313 -209 -408 -22 -47 -48 -101 -57 -120 -9 -19
-25 -54 -36 -78 -17 -39 -35 -79 -99 -224 -9 -20 -23 -54 -32 -76 -23 -55 -65
-155 -87 -208 -19 -46 -48 -118 -80 -202 -10 -27 -27 -70 -36 -94 -10 -24 -29
-76 -44 -116 -15 -40 -30 -81 -34 -92 -4 -11 -15 -42 -24 -68 -9 -26 -19 -54 -22
-62 -6 -15 -26 -74 -50 -144 -8 -25 -23 -69 -33 -98 -30 -86 -124 -379 -151 -472
-9 -31 -26 -87 -38 -124 -12 -37 -27 -89 -34 -114 -7 -25 -19 -66 -26 -90 -7 -24
-17 -59 -22 -78 -9 -34 -28 -101 -47 -164 -5 -19 -15 -54 -21 -78 -13 -51 -28
-105 -48 -180 -5 -18 -16 -59 -24 -92 -8 -33 -21 -81 -28 -106 -24 -87 -61 -229
-92 -358 -11 -44 -25 -98 -30 -120 -21 -80 -86 -345 -94 -380 -12 -52 -17 -75
-38 -158 -21 -87 -35 -147 -44 -186 -15 -67 -47 -211 -54 -244 -5 -20 -12 -50
-16 -68 -24 -105 -33 -145 -42 -188 -6 -26 -13 -60 -16 -74 -9 -41 -19 -101 -20
-116 0 -14 0 -14 58 -43 32 -16 61 -29 65 -29 4 0 23 24 52 67 55 80 95 133 200
257 108 129 221 243 347 350 20 18 47 41 60 51 12 11 45 37 74 59 29 22 65 50 80
61 34 26 135 97 182 128 19 12 58 38 88 57 30 19 63 41 74 48 11 7 39 25 62 39
77 49 110 70 192 127 45 31 89 61 96 66 22 15 121 95 164 132 57 50 156 149 208
209 57 64 151 193 193 263 31 51 38 68 34 73 -2 1 -43 -18 -92 -42 -49 -24 -100
-48 -113 -54 -79 -34 -112 -48 -148 -60 -86 -31 -283 -95 -338 -111 -32 -8 -91
-25 -132 -37 -78 -21 -317 -101 -398 -133 -26 -10 -64 -24 -84 -32 -20 -7 -48
-19 -64 -25 -26 -12 -46 -17 -46 -10 0 4 12 13 74 60 87 65 101 74 216 143 99 59
294 161 394 205 71 32 147 67 236 111 17 8 51 24 76 35 122 54 319 160 433 235
150 97 274 196 384 307 124 125 209 240 286 385 20 38 53 108 63 136 3 8 13 37
24 66 31 84 61 193 72 263 6 38 6 47 -2 47 -1 0 -17 -12 -34 -26 -39 -32 -127
-96 -175 -127 -110 -72 -280 -155 -429 -211 -42 -15 -181 -58 -260 -79 -162 -43
-418 -79 -644 -91 -105 -5 -130 -5 -130 2 0 7 16 12 67 24 176 40 443 130 669
225 93 39 268 127 356 178 62 36 198 123 240 155 263 195 443 402 602 694 52 97
104 228 139 354 15 53 36 152 51 246 20 123 31 317 18 322 -8 4 -15 -1 -29 -19
-26 -33 -92 -101 -137 -141 -107 -96 -288 -218 -431 -290 -49 -24 -149 -71 -169
-78 -8 -3 -27 -11 -44 -18 -16 -7 -34 -14 -40 -16 -5 -2 -23 -8 -38 -14 -67 -25
-143 -50 -206 -68 -10 -3 -46 -13 -80 -23 -63 -19 -115 -32 -186 -47 -22 -5 -55
-12 -74 -16 -19 -4 -47 -10 -62 -14 -15 -3 -44 -8 -64 -10 -20 -3 -57 -8 -82 -12
-63 -10 -80 -12 -80 -8 0 6 15 16 42 26 15 6 56 24 90 40 34 16 91 42 126 57 95
42 300 147 399 205 152 88 190 110 293 178 197 129 349 246 490 379 139 129 221
225 306 357 159 244 256 534 310 924 3 26 7 50 26 174 6 42 19 112 34 187 15 78
53 213 83 299 31 89 67 168 120 268 100 185 247 349 415 460 53 35 163 86 230
106 62 19 57 22 -74 46 -83 15 -107 18 -190 24 -69 5 -270 7 -340 3z M10962
13252 c-22 -4 -51 -10 -64 -14 -13 -4 -38 -11 -56 -17 -141 -42 -381 -164 -597
-303 -12 -8 -31 -21 -44 -29 -71 -45 -259 -181 -345 -250 -207 -164 -321 -263
-550 -480 -94 -88 -287 -283 -354 -358 -17 -18 -70 -76 -120 -130 -94 -102 -172
-190 -256 -287 -27 -32 -62 -72 -78 -90 -16 -18 -61 -70 -100 -116 -155 -182
-267 -303 -416 -450 -127 -125 -183 -178 -212 -202 -13 -11 -37 -32 -54 -46 -74
-65 -108 -89 -108 -76 0 4 31 51 70 104 14 19 37 52 52 74 15 22 54 76 87 119 53
72 67 95 55 95 -6 0 -79 -23 -140 -45 -239 -85 -448 -207 -599 -353 -44 -41 -107
-111 -146 -160 -57 -73 -143 -211 -203 -330 -41 -80 -115 -241 -150 -326 -8 -20
-20 -47 -26 -60 -26 -56 -106 -258 -163 -412 -34 -92 -132 -383 -161 -476 -72
-235 -91 -299 -115 -394 -42 -157 -53 -203 -67 -268 -6 -27 -12 -56 -14 -64 -6
-23 -16 -77 -16 -88 0 -12 23 -34 74 -67 71 -47 136 -74 276 -115 176 -51 387
-77 708 -86 153 -5 357 -1 476 8 26 2 76 6 110 8 139 9 375 34 504 54 21 3 57 9
80 12 82 11 129 18 197 30 38 7 78 14 90 16 11 2 36 6 54 10 18 3 57 11 87 16 63
12 227 46 290 61 78 17 195 45 246 58 28 6 60 14 72 17 25 6 134 36 382 104 37
10 76 21 86 24 41 11 281 85 380 116 250 79 681 228 878 302 54 20 195 73 344
128 48 18 107 40 131 48 24 9 47 18 51 21 5 3 21 26 37 51 16 25 53 78 81 118 87
122 116 167 147 231 54 109 73 181 73 281 0 90 -6 109 -96 298 -78 165 -170 375
-229 524 -37 93 -108 303 -133 394 -36 130 -85 346 -102 446 -23 143 -30 193 -34
242 -2 29 -6 77 -8 108 -8 91 -5 301 4 414 16 192 38 322 90 528 34 136 90 308
130 399 32 75 95 200 116 235 9 14 16 28 16 32 0 10 -27 41 -100 116 -105 107
-159 150 -247 193 -114 58 -250 78 -371 57z m-574 -653 c0 -3 -11 -19 -24 -35
-56 -66 -193 -249 -280 -372 -11 -16 -37 -53 -58 -82 -36 -52 -121 -175 -270
-396 -75 -111 -134 -197 -180 -262 -11 -16 -37 -53 -56 -82 -20 -29 -50 -72 -68
-96 -17 -24 -51 -71 -75 -104 -23 -33 -53 -73 -67 -90 -13 -16 -45 -57 -70 -90
-25 -33 -54 -71 -66 -84 -11 -14 -36 -44 -56 -68 -211 -257 -378 -416 -712 -676
-202 -158 -451 -314 -678 -427 -89 -44 -108 -51 -108 -43 0 6 60 71 172 186 36
37 101 106 144 152 43 46 93 99 110 118 35 36 141 155 190 212 17 19 46 53 66 75
106 116 265 304 348 409 22 28 50 62 62 76 23 26 112 138 204 254 45 57 121 152
238 294 56 69 157 186 190 222 14 15 39 42 54 60 81 93 266 279 418 420 102 95
305 256 444 353 125 86 128 88 128 76z M15278 9526 c-254 -10 -537 -42 -734 -84
-25 -6 -76 -16 -112 -24 -36 -8 -77 -17 -90 -20 -13 -3 -40 -9 -60 -14 -53 -12
-94 -23 -148 -39 -26 -7 -67 -19 -90 -26 -105 -29 -86 -17 -169 -106 -108 -115
-183 -200 -183 -207 0 -3 1 -6 3 -7 2 -1 60 12 129 30 238 60 436 99 672 133 378
53 762 62 1100 26 394 -43 669 -112 950 -237 86 -38 120 -56 199 -104 127 -76
190 -124 288 -220 56 -55 72 -75 59 -75 -3 0 -36 11 -75 24 -104 35 -245 74 -334
92 -333 70 -595 86 -899 56 -432 -43 -871 -187 -1332 -438 -230 -125 -518 -327
-752 -525 -121 -103 -206 -178 -288 -255 -42 -39 -93 -86 -112 -104 -177 -163
-532 -518 -810 -808 -46 -48 -129 -135 -184 -192 -55 -58 -148 -155 -207 -216
-100 -105 -332 -337 -427 -428 -81 -78 -251 -228 -320 -282 -287 -228 -476 -351
-804 -523 -150 -78 -582 -266 -840 -365 -243 -93 -485 -179 -676 -241 -74 -24
-251 -78 -274 -85 -11 -3 -36 -10 -56 -16 -106 -31 -150 -44 -280 -80 -47 -13
-154 -41 -238 -60 -23 -6 -56 -14 -74 -18 -49 -12 -99 -23 -152 -34 -26 -5 -77
-16 -113 -24 -67 -15 -315 -60 -391 -72 -24 -4 -62 -10 -84 -14 -52 -10 -236 -33
-245 -31 -14 3 25 18 115 45 164 49 282 91 490 173 160 63 238 98 416 183 31 15
73 35 93 45 168 79 470 256 681 398 361 243 556 410 872 745 370 392 569 586 811
794 96 82 247 200 349 274 154 112 324 220 486 308 97 53 271 133 347 160 36 13
31 15 98 -39 67 -55 115 -85 214 -132 36 -17 125 -51 135 -51 14 0 12 8 -3 17
-85 50 -155 106 -231 184 -78 80 -123 140 -167 230 -67 131 -93 230 -104 379 -10
146 6 266 54 414 47 145 132 289 232 396 80 84 173 154 279 208 60 31 97 45 164
64 90 25 155 34 256 34 89 0 130 -5 210 -22 159 -36 312 -120 437 -242 73 -71
127 -143 178 -242 24 -45 36 -73 65 -154 9 -25 18 -35 27 -28 4 2 29 20 56 40 28
20 52 40 55 45 5 11 -14 67 -52 154 -62 139 -127 230 -246 343 -51 49 -83 74
-130 105 -131 85 -243 133 -370 159 -57 12 -167 24 -215 24 -43 0 -151 -9 -193
-16 -197 -33 -383 -125 -554 -272 -110 -94 -212 -233 -282 -384 -36 -76 -52 -123
-78 -226 -28 -109 -38 -196 -35 -317 2 -72 1 -86 -4 -91 -3 -3 -6 -6 -6 -6 -1 0
-28 -10 -97 -35 -24 -9 -67 -24 -96 -35 -126 -45 -218 -78 -238 -86 -12 -5 -27
-10 -32 -12 -6 -2 -36 -13 -66 -25 -78 -30 -130 -50 -214 -81 -55 -20 -147 -55
-232 -88 -69 -26 -139 -54 -188 -72 -71 -27 -114 -43 -168 -64 -30 -12 -76 -30
-102 -40 -80 -31 -85 -33 -194 -76 -58 -23 -124 -48 -146 -56 -22 -8 -73 -28
-114 -44 -41 -16 -91 -36 -112 -44 -21 -8 -50 -19 -64 -25 -14 -5 -38 -14 -52
-20 -14 -5 -41 -16 -60 -23 -19 -8 -51 -20 -72 -28 -21 -8 -49 -19 -62 -24 -13
-5 -46 -18 -74 -28 -27 -10 -65 -25 -84 -32 -19 -7 -47 -18 -62 -24 -15 -6 -36
-14 -46 -18 -10 -4 -31 -12 -46 -18 -15 -6 -34 -13 -42 -16 -8 -3 -25 -9 -38 -14
-13 -5 -30 -11 -38 -14 -8 -3 -61 -23 -118 -44 -57 -22 -113 -43 -124 -47 -11 -4
-69 -25 -128 -47 -59 -22 -118 -43 -130 -48 -18 -7 -73 -27 -198 -72 -11 -4 -35
-13 -54 -20 -19 -7 -69 -25 -112 -41 -43 -15 -97 -34 -120 -43 -23 -8 -67 -24
-98 -35 -31 -11 -66 -24 -78 -29 -27 -10 -65 -24 -118 -42 -22 -7 -74 -25 -116
-40 -42 -15 -91 -32 -110 -38 -19 -6 -47 -16 -64 -22 -16 -6 -61 -21 -98 -34 -37
-13 -113 -39 -168 -58 -55 -19 -160 -54 -234 -79 -74 -24 -162 -53 -196 -65 -66
-23 -147 -50 -304 -101 -54 -18 -151 -50 -216 -71 -65 -21 -164 -53 -220 -71
-224 -70 -333 -103 -388 -119 -32 -9 -81 -24 -110 -33 -59 -18 -258 -73 -362
-101 -38 -10 -102 -27 -142 -38 -367 -99 -775 -177 -1038 -196 -98 -8 -291 -7
-360 1 -187 23 -313 72 -405 158 -49 46 -73 87 -87 147 -10 40 -10 52 0 100 43
209 194 467 432 740 139 159 315 325 508 480 88 71 120 95 250 191 56 41 254 173
304 203 16 10 46 29 67 41 41 27 244 142 307 176 172 90 263 134 484 231 46 20
129 52 202 78 88 31 137 49 145 54 5 2 18 22 29 45 31 59 73 139 100 186 24 41
27 50 20 55 -5 3 -108 -15 -168 -30 -25 -6 -56 -13 -68 -16 -100 -22 -195 -52
-362 -111 -217 -78 -354 -137 -565 -242 -216 -109 -445 -244 -633 -374 -169 -116
-247 -176 -402 -305 -78 -65 -78 -64 -205 -186 -201 -190 -371 -390 -508 -594
-62 -93 -121 -196 -178 -309 -19 -37 -23 -47 -60 -138 -39 -94 -61 -169 -81 -269
-30 -159 -29 -288 2 -424 23 -101 47 -163 97 -254 139 -249 392 -463 778 -657 66
-34 155 -75 203 -94 34 -13 55 -22 82 -33 35 -14 183 -68 218 -80 15 -5 43 -14
62 -19 19 -6 52 -16 74 -23 84 -26 175 -51 248 -68 104 -25 98 -24 220 -50 306
-65 634 -107 994 -126 100 -5 385 -11 458 -9 144 3 321 10 372 13 137 9 232 16
288 22 232 23 225 22 460 52 169 22 440 68 672 114 33 6 70 13 82 16 32 6 187 40
282 62 24 6 59 14 78 18 19 4 66 15 104 24 39 9 81 19 94 22 68 15 513 133 614
163 19 6 57 17 84 25 28 8 70 21 94 28 24 7 79 23 122 36 43 12 89 26 102 30 13
4 48 15 78 24 168 52 316 100 446 146 42 15 107 37 144 50 132 45 359 128 502
183 61 23 141 53 178 67 37 14 91 35 118 46 28 11 73 29 100 40 99 38 343 137
458 186 171 72 333 141 384 164 31 14 79 35 108 47 54 23 129 56 222 99 30 14 86
39 124 56 39 17 118 54 177 82 59 27 108 50 109 50 1 0 46 22 101 48 54 27 122
60 151 73 98 46 561 281 637 324 17 10 64 35 105 57 344 182 837 498 1136 730
178 137 220 171 330 264 68 57 138 116 204 170 27 22 62 51 78 64 17 14 53 42 82
64 29 22 77 59 108 82 120 91 338 241 431 297 14 9 62 38 106 65 104 64 270 158
363 207 40 21 92 49 117 62 25 14 77 41 116 60 39 20 116 58 171 86 55 27 114 56
130 63 17 7 65 31 109 52 43 22 97 48 120 58 73 33 280 130 304 143 15 8 24 15
25 21 3 12 -21 90 -44 140 -22 48 -70 133 -106 188 -43 65 -110 143 -189 220
-117 115 -171 158 -301 237 -91 55 -165 95 -220 120 -24 11 -54 24 -66 30 -71 32
-189 73 -324 112 -54 16 -219 52 -294 65 -109 18 -272 36 -380 42 -88 4 -259 8
-312 6z M12694 8423 c-3 -7 -6 -19 -8 -27 -5 -24 -26 -84 -47 -132 -43 -96 -79
-152 -141 -214 -80 -81 -161 -130 -292 -178 -26 -9 -40 -16 -40 -20 0 -4 12 -10
36 -17 161 -51 276 -136 366 -270 42 -63 97 -192 120 -281 9 -35 20 -34 28 2 10
46 53 165 78 212 44 86 102 158 170 214 70 57 129 89 222 120 52 17 55 23 20 33
-118 35 -241 107 -308 180 -78 84 -148 224 -177 359 -8 35 -17 42 -27 19z M5933
7589 c-7 -5 -39 -97 -53 -154 -7 -27 -15 -59 -18 -71 -16 -59 -50 -198 -58 -230
-4 -20 -16 -73 -26 -118 -19 -86 -24 -107 -40 -196 -29 -151 -30 -170 -18 -231
25 -131 110 -235 254 -311 83 -44 266 -98 352 -104 32 -3 32 -3 96 23 35 14 91
36 124 49 80 31 216 86 258 104 19 8 55 23 80 33 55 21 143 57 185 75 16 7 83 34
148 60 64 25 122 48 127 51 9 4 70 28 144 58 17 7 61 24 98 39 37 15 97 38 132
52 73 29 135 53 216 84 31 12 68 26 82 32 14 6 58 22 96 37 64 24 131 50 246 95
24 10 71 28 104 40 33 12 66 25 74 28 8 3 27 10 42 16 15 6 34 13 42 16 19 8 98
38 144 55 61 23 177 66 194 71 9 3 48 17 88 32 40 15 93 34 118 42 53 17 92 34
92 39 0 6 -35 2 -148 -15 -21 -3 -69 -10 -106 -14 -37 -5 -91 -12 -120 -16 -223
-33 -612 -69 -900 -84 -387 -19 -756 -12 -1049 20 -168 18 -373 60 -496 100 -165
54 -271 105 -368 177 -43 32 -106 90 -116 108 -7 11 -11 13 -20 8z"/>
<path fill="#4A6B5C" d="M11642 17427 c4 -332 10 -651 14 -799 7 -251 10 -586 5 -589 -2 -2 -16 2 -31 7
-43 15 -81 19 -148 17 -47 -1 -68 -4 -100 -12 -101 -26 -179 -72 -254 -147 -67
-67 -115 -151 -141 -248 -7 -28 -9 -45 -9 -92 0 -73 10 -119 38 -178 25 -50 48
-79 89 -110 124 -94 277 -104 429 -29 169 84 285 250 316 453 7 45 8 269 2 534
-2 92 -5 254 -6 359 -3 182 -3 191 4 194 4 1 11 0 17 -3 5 -3 26 -17 48 -30 117
-70 178 -120 234 -190 68 -85 98 -225 73 -346 -13 -62 -52 -158 -78 -190 -12 -16
-20 -29 -20 -36 0 -25 127 73 179 137 40 51 79 136 102 225 42 157 12 352 -73
488 -71 112 -130 174 -264 274 -104 78 -211 178 -269 252 -31 40 -97 143 -124
193 -15 26 -21 35 -28 35 -8 0 -8 -1 -5 -169z M9054 17478 c-44 -17 -84 -32 -90
-34 -5 -2 -34 -13 -64 -24 -30 -11 -90 -34 -134 -50 -92 -34 -137 -51 -188 -71
-32 -13 -121 -46 -204 -76 -42 -16 -95 -36 -165 -63 -35 -14 -69 -27 -77 -30 -78
-29 -158 -59 -196 -74 -16 -6 -62 -23 -102 -38 -40 -15 -82 -30 -94 -35 -12 -4
-28 -10 -36 -13 -8 -3 -45 -17 -82 -31 -98 -39 -154 -60 -201 -77 -62 -22 -59
-18 -58 -86 1 -32 3 -89 5 -128 2 -38 6 -140 8 -226 2 -86 6 -186 8 -222 2 -36 6
-125 8 -198 2 -73 7 -195 10 -272 3 -77 8 -199 10 -272 2 -73 6 -162 8 -198 6
-101 5 -198 -1 -203 -4 -3 -11 -2 -27 3 -29 10 -89 19 -136 23 -73 4 -157 -13
-231 -49 -132 -63 -232 -160 -302 -294 -54 -103 -78 -243 -61 -348 16 -92 49
-158 110 -220 45 -46 94 -75 164 -98 32 -11 38 -12 106 -12 66 0 75 1 112 11 95
25 152 54 220 111 117 99 204 229 239 360 28 104 32 152 27 330 -2 77 -6 208 -8
292 -2 84 -6 207 -8 274 -2 67 -7 208 -10 312 -3 105 -8 237 -10 294 -8 244 -9
300 -3 307 5 6 26 16 65 29 9 3 38 14 64 24 26 10 85 32 130 48 45 17 105 39 132
50 28 11 82 32 122 46 90 33 112 41 134 50 27 11 86 34 118 46 15 6 34 13 42 16
19 8 77 30 154 58 75 28 161 60 210 80 58 23 159 57 170 58 12 0 12 0 15 -34 1
-19 2 -71 3 -116 0 -78 3 -202 12 -494 2 -73 5 -212 6 -309 3 -204 7 -188 -38
-173 -87 27 -205 27 -295 -1 -167 -53 -300 -170 -379 -333 -64 -133 -74 -282 -26
-407 32 -82 102 -158 182 -197 61 -30 98 -39 171 -41 50 -2 64 -1 99 7 155 33
282 127 389 285 51 77 87 173 106 287 5 36 6 66 6 202 -1 88 -3 235 -5 326 -6
240 -15 700 -18 860 -1 76 -4 212 -6 302 -2 90 -5 234 -6 320 -2 93 -5 158 -7
161 -2 3 -7 5 -10 5 -4 0 -43 -14 -87 -30z M10590 16917 c-241 -7 -571 -39 -792
-76 -20 -3 -48 -7 -62 -9 -59 -9 -147 -29 -177 -41 -54 -21 -82 -53 -117 -131
-27 -61 -25 -67 22 -58 96 19 176 34 190 36 9 1 54 8 100 16 199 32 325 50 476
64 33 3 76 8 96 10 166 19 676 42 990 46 76 1 78 1 79 9 3 19 -49 74 -84 89 -39
17 -188 37 -327 44 -76 4 -295 4 -394 1z M12612 16699 c-3 -5 -3 -12 2 -32 3 -14
7 -38 8 -54 4 -35 9 -41 45 -49 12 -3 47 -11 77 -19 30 -7 76 -19 102 -26 105
-26 423 -127 520 -165 26 -10 57 -22 68 -26 56 -20 134 -52 186 -75 32 -14 74
-32 94 -40 20 -7 89 -40 153 -72 64 -32 118 -57 119 -56 5 4 -39 38 -122 93 -47
31 -100 65 -118 76 -18 11 -35 22 -38 24 -3 3 -22 13 -42 24 -20 11 -50 27 -68
37 -53 30 -201 103 -260 128 -48 21 -131 54 -236 95 -24 10 -209 70 -260 86 -22
6 -219 56 -224 56 0 0 -3 -2 -6 -5z M7107 16128 c-9 -5 -50 -24 -92 -43 -323
-143 -696 -369 -1031 -626 -221 -170 -375 -306 -558 -494 -111 -115 -112 -116
-93 -140 6 -6 22 -29 36 -51 39 -59 40 -60 50 -56 5 2 42 35 82 72 203 190 371
336 516 449 58 45 189 143 234 174 18 12 46 32 62 44 28 21 70 49 173 115 29 18
64 41 78 50 14 9 47 29 72 44 25 15 57 34 70 42 33 20 113 66 206 119 44 24 88
50 98 58 11 7 35 24 53 37 18 12 41 30 51 39 18 17 19 18 22 49 3 18 4 53 3 78
-1 52 -3 55 -32 40z M10520 15886 c-173 -8 -506 -53 -752 -102 -120 -24 -324 -75
-333 -83 -7 -6 -7 -14 -6 -92 0 -59 2 -88 5 -92 5 -6 11 -5 75 10 317 80 557 129
797 163 163 23 352 47 410 51 32 3 57 7 61 10 3 3 10 16 15 30 5 14 17 40 26 58
20 36 21 44 11 46 -8 2 -265 2 -309 1z M12215 15662 c-35 -7 -64 -14 -65 -15 -7
-7 3 -15 25 -19 13 -2 46 -10 75 -16 29 -6 64 -14 78 -16 32 -6 243 -58 314 -78
145 -41 396 -121 476 -151 112 -43 195 -71 205 -71 7 0 20 8 36 20 28 22 32 29
25 38 -7 9 -174 90 -254 124 -174 74 -276 111 -414 151 -86 25 -89 26 -205 37
-147 14 -213 13 -296 -4z M4890 15080 c-8 -13 -6 -751 6 -2098 2 -253 3 -483 1
-512 -4 -61 1 -57 -55 -38 -75 26 -179 34 -265 22 -80 -12 -190 -56 -270 -108
-89 -56 -190 -168 -241 -264 -60 -114 -78 -180 -81 -300 -1 -61 -1 -83 5 -112 12
-67 38 -129 81 -194 63 -95 171 -162 292 -182 214 -34 443 73 605 283 93 120 153
267 172 421 3 25 4 228 3 690 0 360 0 742 0 850 1 108 1 200 1 205 0 6 2 9 7 9 6
0 108 -66 205 -132 102 -70 218 -182 280 -270 132 -187 162 -383 91 -596 -31 -93
-101 -215 -158 -275 -24 -25 -28 -35 -15 -35 10 0 96 43 123 61 94 64 168 140
219 227 23 37 69 140 84 185 32 96 46 187 46 309 0 121 -11 186 -50 305 -33 100
-73 178 -148 287 -55 82 -102 140 -174 216 -117 125 -256 277 -304 332 -158 184
-324 442 -415 646 -29 63 -32 69 -38 71 -2 1 -6 -1 -7 -3z M6454 14216 c-4 -5
-30 -27 -56 -49 -162 -137 -326 -292 -326 -310 0 -5 11 -29 25 -54 14 -25 34 -64
44 -87 10 -23 21 -44 24 -46 5 -3 35 24 125 111 108 105 152 145 251 231 19 16
35 33 36 37 2 4 -3 14 -12 24 -23 28 -50 71 -68 111 -19 42 -28 49 -43 32z M4552
13379 c-42 -45 -92 -104 -122 -143 -16 -21 -44 -57 -62 -80 -51 -65 -155 -224
-237 -360 -26 -43 -73 -127 -160 -286 -82 -152 -244 -485 -311 -640 -26 -60 -68
-161 -76 -184 -4 -11 -11 -30 -16 -42 -8 -21 -21 -56 -61 -168 -10 -27 -34 -99
-53 -158 -19 -59 -36 -112 -38 -118 -2 -5 -6 -20 -10 -32 -3 -12 -14 -53 -24 -90
-27 -101 -59 -231 -78 -326 -15 -70 -48 -260 -60 -338 -50 -343 -62 -711 -32
-1028 10 -108 22 -209 30 -254 12 -65 39 -198 46 -232 4 -19 12 -56 18 -82 12
-55 16 -69 46 -175 11 -38 72 -221 86 -256 14 -37 36 -96 49 -127 5 -14 24 -57
40 -94 56 -126 184 -370 194 -370 7 0 -2 30 -33 102 -39 93 -68 173 -118 332 -10
33 -21 67 -24 76 -12 35 -34 119 -68 256 -25 104 -49 226 -74 376 -20 125 -23
156 -36 300 -11 120 -16 255 -16 400 0 314 25 562 88 879 7 33 15 73 18 90 20
104 73 321 115 461 32 110 105 326 145 430 5 13 15 39 22 58 7 19 18 46 25 60 12
26 12 26 13 138 0 97 2 118 9 160 27 144 62 233 136 348 44 67 121 150 207 222
36 30 36 30 118 174 78 136 124 212 202 336 117 185 143 228 156 267 5 14 5 21 0
55 -11 66 -16 86 -23 87 -5 1 -16 -8 -31 -24z M4548 11050 c-43 -28 -87 -50 -120
-59 -47 -14 -47 -14 -60 -34 -36 -51 -112 -312 -159 -541 -36 -173 -50 -258 -69
-422 -19 -166 -28 -402 -20 -546 11 -213 12 -221 40 -432 9 -70 54 -300 71 -366
50 -190 136 -430 215 -598 84 -179 112 -228 129 -228 10 0 29 16 29 24 0 3 -11
30 -24 61 -74 171 -142 387 -200 631 -15 62 -53 270 -64 348 -41 293 -46 648 -14
964 12 113 55 379 82 504 35 163 93 371 156 559 24 73 44 137 44 141 0 12 -11 10
-36 -6z"/>
</g>
</svg>
+1 -1
View File
@@ -71,7 +71,7 @@
href="/"
class="flex items-center gap-2 font-semibold text-sm md:text-base whitespace-nowrap justify-self-start"
>
<MinstrelMark size={20} class="shrink-0" />
<MinstrelMark size={28} class="shrink-0" />
{appName()}
</a>
+80
View File
@@ -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(/<!--[\s\S]*?-->/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(/<link\b[^>]*\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<string, string> };
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);
}
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+215 -31
View File
@@ -1,35 +1,219 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="157 116 992 992">
<!-- Minstrel mark. The M flips with the viewer's scheme because a favicon
sits on browser chrome we don't control: parchment would vanish on a
light tab strip, obsidian on a dark one. The note keeps the accent in
both — teal holds against either. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="72 72 2384 1488">
<!-- Generated by tools/gen-brand-assets.py — do not edit by hand.
The REDUCED mark, because a tab renders this at 16px.
The hat flips with the viewer's scheme because a favicon sits on
browser chrome we don't control: parchment would vanish on a light
tab strip, obsidian on a dark one. The plume holds the accent in
both — teal reads against either (3.04:1 on obsidian, 5.3:1 on
the light ground). -->
<style>
.m { fill: #E8E4D8; }
@media (prefers-color-scheme: light) { .m { fill: #14171A; } }
svg { color: #E8E4D8; }
@media (prefers-color-scheme: light) { svg { color: #14171A; } }
</style>
<g transform="translate(0,1254) scale(0.1,-0.1)" fill-rule="evenodd">
<path class="m" d="M2252 9878 l3 -152 109 -22 c342 -72 492 -184 538 -405 16 -74 24
-4823 9 -5024 -20 -266 -73 -375 -236 -484 -116 -77 -332 -150 -543 -181 -114
-18 -107 -6 -110 -165 -1 -76 2 -143 7 -148 9 -9 2607 -11 2623 -1 14 9 10
280 -4 291 -7 5 -49 15 -93 22 -380 60 -638 193 -720 374 -63 136 -59 -12 -61
2247 -3 1999 -2 2054 15 2015 116 -253 646 -1520 1183 -2825 71 -173 216 -524
322 -780 206 -494 266 -640 370 -895 97 -237 68 -210 226 -210 l135 0 23 50
c13 27 95 212 182 410 134 307 747 1685 1015 2285 92 205 726 1599 910 2000
65 140 126 274 137 297 22 50 52 71 74 52 12 -10 14 -266 14 -1864 l0 -1852
-82 -7 c-347 -29 -716 -203 -973 -460 -710 -712 -343 -1645 650 -1649 453 -2
892 184 1206 510 193 202 295 397 351 676 l23 112 0 2357 c0 1297 0 2358 1
2358 12 0 142 -49 179 -67 342 -173 607 -545 715 -1004 85 -361 66 -801 -51
-1215 -43 -151 -40 -173 18 -174 53 0 284 377 396 650 243 590 303 1238 163
1754 -178 652 -643 1100 -1294 1248 -93 21 -122 22 -716 25 -707 4 -649 12
-696 -90 -15 -34 -78 -172 -140 -307 -593 -1297 -1123 -2469 -1717 -3800 -210
-472 -193 -437 -204 -418 -11 19 -173 423 -630 1568 -214 536 -394 986 -400
1000 -6 14 -76 187 -156 385 -80 198 -182 452 -228 565 -46 113 -140 346 -209
518 -205 507 -225 554 -244 568 -14 11 -209 13 -1055 14 l-1038 0 3 -152z"/>
<path fill="#4A6B5C" d="M8830 7450 l0 -2582 -32 6 c-517 106 -1064 -47 -1442 -405 -598 -566
-501 -1354 196 -1598 489 -170 1134 -19 1548 363 234 217 350 418 423 736 l22
95 3 2373 c2 1961 5 2372 16 2372 27 0 132 -41 205 -81 315 -169 569 -524 675
-944 50 -198 60 -285 60 -525 0 -288 -27 -487 -105 -756 -34 -120 -35 -130
-11 -143 33 -18 64 11 154 144 227 334 402 795 469 1235 37 238 34 646 -4 843
-149 761 -637 1271 -1353 1418 -98 20 -147 23 -466 27 l-358 4 0 -2582z"/>
<g transform="translate(0.000000,2560.000000) scale(0.250000,-0.250000)" fill-rule="evenodd">
<path fill="currentColor" d="M3904 8952 c-8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -7 -6 -15 -8 -32 -8 -17 0
-25 -2 -32 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -7 -6 -15 -8 -32 -8 -21 0
-24 -1 -40 -16 -9 -9 -20 -16 -24 -16 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4
0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -8 0 -16 -8 -16 -17 0 -9 -38 -47 -48 -47
-10 0 -80 -70 -80 -80 0 -4 -7 -15 -16 -24 -9 -9 -16 -20 -16 -24 0 -4 -4 -11 -8
-16 -4 -5 -8 -12 -8 -16 0 -4 -4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4 -7 -15 -16
-24 -9 -9 -16 -20 -16 -24 0 -4 -7 -15 -16 -24 -9 -9 -16 -20 -16 -24 0 -4 -7
-15 -16 -24 -9 -9 -16 -20 -16 -24 0 -4 -4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4
-4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4 -7 -15 -16 -24 -9 -9 -16 -20 -16 -24 0
-4 -4 -11 -8 -16 -5 -6 -8 -15 -8 -24 0 -9 -3 -18 -8 -24 -4 -5 -8 -12 -8 -16 0
-4 -4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4 -4 -11 -8 -16 -5 -6 -8 -15 -8 -24 0
-9 -3 -18 -8 -24 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -4 -5 -8 -12 -8 -16 0
-4 -4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4 -4 -11 -8 -16 -5 -6 -8 -15 -8 -24 0
-9 -3 -18 -8 -24 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -6 -7 -8 -15 -8 -32 0
-17 -2 -25 -8 -32 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -7 -8 -8 -15 -8 -64
0 -49 -1 -56 -8 -64 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -7 -8 -8 -15 -8
-64 0 -49 -1 -56 -8 -64 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -8 -9 -8 -15
-8 -104 0 -89 0 -95 -8 -104 -8 -9 -8 -15 -8 -496 0 -481 0 -487 8 -496 8 -9 8
-15 8 -160 0 -145 0 -151 8 -160 8 -9 8 -15 8 -128 0 -113 0 -119 8 -128 7 -8 8
-15 8 -56 0 -47 0 -47 -12 -60 -12 -11 -14 -12 -44 -12 -25 0 -32 -1 -40 -8 -8
-7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8
-7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40
-8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1
-40 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3
-24 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2
-32 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -7 -6 -15 -8 -32 -8 -17 0 -25
-2 -32 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -6 -5 -15 -8 -24 -8 -9 0 -18
-3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18
-3 -24 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -7 -6 -15 -8 -32 -8 -17 0
-25 -2 -32 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0
-18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0
-18 -3 -24 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -6 -5 -15 -8 -24 -8 -9 0
-18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0
-18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0
-11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0
-15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4
0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9
0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4
0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4
0 -18 -11 -32 -24 -14 -13 -28 -24 -32 -24 -4 0 -11 -4 -16 -8 -7 -6 -15 -8 -32
-8 -17 0 -25 -2 -32 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -13 -8 -17
-8 -4 0 -27 -20 -51 -44 -44 -44 -44 -44 -44 -92 0 -48 0 -48 36 -84 20 -20 39
-36 43 -36 4 0 12 -4 17 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 7 -6 15 -8 32 -8
17 0 25 -2 32 -8 8 -7 15 -8 40 -8 25 0 32 -1 40 -8 8 -7 15 -8 56 -8 41 0 48 -1
56 -8 6 -5 15 -8 24 -8 9 0 18 -3 24 -8 6 -5 15 -8 24 -8 9 0 18 -3 24 -8 8 -7
15 -8 56 -8 41 0 48 -1 56 -8 8 -7 15 -8 56 -8 41 0 48 -1 56 -8 8 -7 15 -8 56
-8 41 0 48 -1 56 -8 8 -7 15 -8 64 -8 49 0 56 -1 64 -8 8 -7 15 -8 64 -8 49 0 56
-1 64 -8 8 -7 15 -8 64 -8 49 0 56 -1 64 -8 8 -7 15 -8 64 -8 49 0 56 -1 64 -8 8
-7 15 -8 64 -8 49 0 56 -1 64 -8 9 -8 15 -8 144 -8 129 0 135 0 144 -8 8 -7 15
-8 72 -8 57 0 64 -1 72 -8 9 -8 15 -8 152 -8 137 0 143 0 152 -8 9 -8 15 -8 160
-8 145 0 151 0 160 -8 9 -8 15 -8 576 -8 561 0 567 0 576 8 9 8 15 8 176 8 161 0
167 0 176 8 9 8 15 8 168 8 153 0 159 0 168 8 9 7 15 8 88 8 73 0 79 1 88 8 9 8
15 8 168 8 153 0 159 0 168 8 9 7 15 8 88 8 73 0 79 1 88 8 9 7 15 8 88 8 73 0
79 1 88 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 48 8 33 0 40 1 48 8 9 7 15 8
88 8 73 0 79 1 88 8 8 7 15 8 80 8 65 0 72 1 80 8 9 7 15 8 88 8 73 0 79 1 88 8
8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0
32 1 40 8 8 7 15 8 48 8 33 0 40 1 48 8 8 7 15 8 80 8 65 0 72 1 80 8 8 7 15 8
40 8 25 0 32 1 40 8 8 7 15 8 48 8 33 0 40 1 48 8 8 7 15 8 40 8 25 0 32 1 40 8
8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0
32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 6 5 15 8
24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 8 7
15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1
40 8 8 7 15 8 40 8 25 0 32 1 40 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9
0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8
24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5
15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24
8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18
3 24 8 5 4 12 8 16 8 4 0 11 4 16 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9
0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8
24 8 9 0 18 3 24 8 5 4 12 8 16 8 4 0 11 4 16 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5
15 8 24 8 9 0 18 3 24 8 5 4 12 8 16 8 4 0 11 4 16 8 6 5 15 8 24 8 9 0 18 3 24
8 5 4 12 8 16 8 4 0 15 7 24 16 9 9 20 16 24 16 4 0 11 4 16 8 5 4 12 8 16 8 4 0
11 4 16 8 5 4 12 8 16 8 4 0 15 7 24 16 9 9 20 16 24 16 4 0 11 4 16 8 5 4 12 8
16 8 4 0 11 4 16 8 6 5 15 8 24 8 9 0 18 3 24 8 5 4 12 8 16 8 4 0 11 4 16 8 6 5
15 8 24 8 14 0 19 3 48 32 18 18 36 32 40 32 10 0 128 118 128 128 0 4 7 15 16
24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 4 11 8 16 4 5 8 12 8 16
0 4 4 11 8 16 5 6 8 15 8 24 0 9 3 18 8 24 7 8 8 15 8 48 0 33 1 40 8 48 7 8 8
15 8 40 0 25 -1 32 -8 40 -7 8 -8 15 -8 40 0 25 -1 32 -8 40 -4 5 -8 12 -8 16 0
4 -3 11 -7 14 -4 4 -8 15 -9 25 -1 11 -5 19 -8 21 -18 8 -22 12 -24 21 -2 5 -10
17 -17 25 -8 8 -15 18 -15 22 0 8 -8 16 -16 16 -4 0 -18 11 -32 24 -14 13 -28 24
-32 24 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8 -9 8 -15 8 -368 8
-353 0 -359 0 -368 -8 -9 -8 -15 -8 -296 -8 -281 0 -287 0 -296 -8 -9 -8 -15 -8
-160 -8 -145 0 -151 0 -160 -8 -9 -8 -15 -8 -160 -8 -145 0 -151 0 -160 -8 -9 -8
-15 -8 -160 -8 -145 0 -151 0 -160 -8 -9 -8 -15 -8 -160 -8 -151 0 -151 0 -164
12 -6 7 -12 16 -12 20 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -7 15 -16 24 -9 9
-16 20 -16 24 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8
16 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4
-29 37 -64 72 -36 36 -64 68 -64 72 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 11
-134 144 -144 144 -5 0 -33 25 -72 64 -37 37 -68 64 -72 64 -4 0 -18 11 -32 24
-14 13 -28 24 -32 24 -4 0 -35 27 -72 64 -38 38 -67 64 -72 64 -10 0 -48 38 -48
48 0 10 -54 64 -64 64 -10 0 -80 70 -80 80 0 10 -22 32 -32 32 -4 0 -17 9 -28 20
-11 11 -20 24 -20 28 0 4 -6 13 -12 20 -7 6 -16 12 -20 12 -10 0 -48 38 -48 48 0
4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -18 26 -40 48 -22 22 -40 44 -40 48 0 4
-18 26 -40 48 -22 22 -40 44 -40 48 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -4
11 -8 16 -4 5 -8 12 -8 16 0 4 -14 22 -32 40 -18 18 -32 36 -32 40 0 4 -4 11 -8
16 -4 5 -8 12 -8 16 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -7 15 -16 24 -9 9
-16 20 -16 24 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8
16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -4
11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4
5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -5 6 -8 15 -8
24 0 9 -3 18 -8 24 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11
-8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -7 15 -16 24 -9 9
-16 20 -16 24 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8
16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -11 18 -24 32 -13 14 -24 28 -24 32 0 4
-11 18 -24 32 -13 14 -24 28 -24 32 0 8 -8 16 -16 16 -10 0 -32 23 -32 32 0 4
-11 18 -24 32 -13 14 -24 28 -24 32 0 8 -8 16 -17 16 -3 0 -28 22 -55 48 -27 27
-52 48 -56 48 -4 0 -22 14 -40 32 -18 18 -36 32 -40 32 -4 0 -11 4 -16 8 -5 4
-12 8 -16 8 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8 -5 4 -12 8 -16
8 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -15
7 -24 16 -9 9 -20 16 -24 16 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8
-7 6 -15 8 -32 8 -17 0 -25 2 -32 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8 -7 6 -15
8 -32 8 -17 0 -25 2 -32 8 -8 7 -15 8 -48 8 -33 0 -40 1 -48 8 -9 8 -15 8 -104 8
-89 0 -95 0 -104 -8z m3115 -3165 c8 -8 6 -48 -3 -59 -4 -5 -8 -13 -8 -17 0 -4
-16 -23 -36 -43 -20 -20 -39 -36 -43 -36 -4 0 -12 -4 -17 -8 -5 -4 -12 -8 -16 -8
-4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8
-4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8
-4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8
-9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -7 -6 -15 -8 -32 -8
-17 0 -25 -2 -32 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -8 -7 -15 -8 -64 -8
-49 0 -56 -1 -64 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -7 -6 -15 -8 -32
-8 -17 0 -25 -2 -32 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -8 -7 -15 -8
-72 -8 -57 0 -64 -1 -72 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15
-8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8 -80 -8 -65 0 -72 -1 -80 -8 -8 -7
-15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8
-7 -15 -8 -80 -8 -65 0 -72 -1 -80 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8
-9 -7 -15 -8 -88 -8 -73 0 -79 -1 -88 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40
-8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -9 -7 -15 -8 -88 -8 -73 0 -79 -1
-88 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -48 -8 -33 0 -40
-1 -48 -8 -9 -7 -15 -8 -96 -8 -81 0 -87 -1 -96 -8 -8 -7 -15 -8 -48 -8 -33 0
-40 -1 -48 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -40 -8 -25
0 -32 -1 -40 -8 -9 -7 -15 -8 -96 -8 -81 0 -87 -1 -96 -8 -8 -7 -15 -8 -48 -8
-33 0 -40 -1 -48 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -48
-8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8
-48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15
-8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8 -72 -8 -57 0 -64 -1 -72 -8 -5 -4
-12 -8 -16 -8 -4 0 -9 -3 -12 -6 -3 -3 -8 -6 -12 -6 -8 0 -8 7 -8 193 0 201 0
203 16 203 4 0 15 7 24 16 17 16 17 16 64 16 41 0 48 1 56 8 6 5 15 8 24 8 9 0
18 3 24 8 7 6 15 8 32 8 17 0 25 2 32 8 7 6 15 8 32 8 17 0 25 2 32 8 6 5 15 8
24 8 9 0 18 3 24 8 7 6 15 8 32 8 17 0 25 2 32 8 7 6 15 8 32 8 17 0 25 2 32 8 8
7 15 8 64 8 49 0 56 1 64 8 8 7 15 8 40 8 25 0 32 1 40 8 7 6 15 8 32 8 17 0 25
2 32 8 8 7 15 8 64 8 49 0 56 1 64 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 64 8
49 0 56 1 64 8 8 7 15 8 72 8 57 0 64 1 72 8 8 7 15 8 80 8 65 0 72 1 80 8 7 6
15 8 32 8 17 0 25 2 32 8 8 7 15 8 72 8 57 0 64 1 72 8 9 8 15 8 112 8 97 0 103
0 112 8 8 7 15 8 80 8 65 0 72 1 80 8 9 8 15 8 104 8 89 0 95 0 104 8 8 7 15 8
80 8 65 0 72 1 80 8 9 8 15 8 104 8 89 0 95 0 104 8 9 8 15 8 144 8 129 0 135 0
144 8 9 8 15 8 136 8 121 0 127 0 136 8 9 8 15 8 128 8 113 0 119 0 128 8 9 8 15
8 152 8 137 0 143 0 152 8 9 8 15 8 120 8 80 0 112 -1 115 -5z"/>
<path fill="#4A6B5C" d="M9328 9816 c-5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0
-11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -13 0 -19 -3 -40 -24 -21 -21 -27 -24 -40 -24
-12 0 -18 -3 -32 -16 -9 -9 -20 -16 -24 -16 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16
-8 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -11 -4 -16 -8 -6 -5 -15 -8
-24 -8 -12 0 -18 -3 -32 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9
-20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16
-9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7
-24 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0
-15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16
-4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -22 -14 -40 -32 -18 -18 -36 -32
-40 -32 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -26 -18 -48 -40 -22 -22
-44 -40 -48 -40 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -18 -11 -32 -24
-14 -13 -28 -24 -32 -24 -4 0 -26 -18 -48 -40 -22 -22 -44 -40 -48 -40 -4 0 -29
-21 -56 -48 -27 -26 -52 -48 -56 -48 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16
-4 0 -29 -22 -56 -48 -28 -27 -52 -48 -56 -48 -10 0 -48 -38 -48 -48 0 -10 -54
-64 -64 -64 -5 0 -36 -29 -80 -72 -43 -43 -75 -72 -80 -72 -10 0 -48 -38 -48 -48
0 -10 -54 -64 -64 -64 -10 0 -48 -38 -48 -48 0 -10 -54 -64 -64 -64 -10 0 -48
-38 -48 -48 0 -5 -32 -40 -80 -88 -47 -47 -80 -83 -80 -88 0 -4 -11 -18 -24 -32
-13 -14 -24 -28 -24 -32 0 -10 -54 -64 -64 -64 -10 0 -48 -38 -48 -48 0 -4 -11
-18 -24 -32 -13 -14 -24 -28 -24 -32 0 -4 -24 -32 -56 -64 -32 -32 -56 -60 -56
-64 0 -4 -11 -18 -24 -32 -13 -14 -24 -28 -24 -32 0 -4 -11 -18 -24 -32 -13 -14
-24 -28 -24 -32 0 -4 -11 -18 -24 -32 -13 -14 -24 -28 -24 -32 0 -4 -4 -11 -8
-16 -4 -5 -8 -12 -8 -15 0 -4 -3 -10 -6 -14 -8 -8 -5 -19 5 -19 4 0 12 -4 17 -8
5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8
16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11
-4 16 -8 6 -5 15 -8 24 -8 9 0 18 -3 24 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5
-4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16
-8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4
16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4
12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8
4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 6 -5 15 -8 24 -8 9 0 18 -3 24
-8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12
-8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0
11 -4 16 -8 5 -4 12 -8 16 -8 4 0 9 -3 12 -6 3 -3 8 -6 11 -6 9 0 25 19 25 28 0
10 54 64 64 64 4 0 18 11 32 24 14 13 28 24 32 24 10 0 48 38 48 48 0 10 54 64
64 64 11 0 416 405 416 416 0 10 54 64 64 64 10 0 48 38 48 48 0 5 29 37 72 80
42 42 72 75 72 80 0 4 11 18 24 32 13 14 24 28 24 32 0 4 18 26 40 48 22 22 40
44 40 48 0 4 21 29 48 56 26 27 48 52 48 56 0 4 7 15 16 24 9 9 16 20 16 24 0 4
11 18 24 32 13 14 24 28 24 32 0 4 18 26 40 48 22 22 40 44 40 48 0 4 7 15 16 24
9 9 16 20 16 24 0 4 11 18 24 32 13 14 24 28 24 32 0 4 7 15 16 24 9 9 16 20 16
24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 4 11 8 16
4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 7 15 16 24 9 9 16 20 16 24 0 4 7
15 16 24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16
20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 4
11 8 16 4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16
0 4 4 11 8 16 4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 7 15 16 24 9 9 16
20 16 24 0 4 4 11 8 16 5 6 8 15 8 24 0 9 3 18 8 24 4 5 8 12 8 16 0 4 4 11 8 16
4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 4 11
8 16 5 6 8 15 8 24 0 9 3 18 8 24 4 5 8 12 8 16 0 4 4 11 8 16 5 6 8 15 8 24 0 9
3 18 8 24 4 5 8 12 8 16 0 4 4 11 8 16 7 8 8 15 8 40 0 25 1 32 8 40 5 6 8 15 8
24 0 9 3 18 8 24 6 7 8 15 8 32 0 17 2 25 8 32 6 7 8 15 8 31 0 15 2 25 6 30 12
12 5 19 -21 19 -18 0 -26 -2 -33 -8z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 47 KiB

+211
View File
@@ -0,0 +1,211 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="72 72 2384 1488">
<!-- Generated by tools/gen-brand-assets.py — do not edit by hand.
The reduced mark, literal colours. For use at or below ~24px,
where the full art collapses into a diagonal smear. -->
<g transform="translate(0.000000,2560.000000) scale(0.250000,-0.250000)" fill-rule="evenodd">
<path fill="#E8E4D8" d="M3904 8952 c-8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -7 -6 -15 -8 -32 -8 -17 0
-25 -2 -32 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -7 -6 -15 -8 -32 -8 -21 0
-24 -1 -40 -16 -9 -9 -20 -16 -24 -16 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4
0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -8 0 -16 -8 -16 -17 0 -9 -38 -47 -48 -47
-10 0 -80 -70 -80 -80 0 -4 -7 -15 -16 -24 -9 -9 -16 -20 -16 -24 0 -4 -4 -11 -8
-16 -4 -5 -8 -12 -8 -16 0 -4 -4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4 -7 -15 -16
-24 -9 -9 -16 -20 -16 -24 0 -4 -7 -15 -16 -24 -9 -9 -16 -20 -16 -24 0 -4 -7
-15 -16 -24 -9 -9 -16 -20 -16 -24 0 -4 -4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4
-4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4 -7 -15 -16 -24 -9 -9 -16 -20 -16 -24 0
-4 -4 -11 -8 -16 -5 -6 -8 -15 -8 -24 0 -9 -3 -18 -8 -24 -4 -5 -8 -12 -8 -16 0
-4 -4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4 -4 -11 -8 -16 -5 -6 -8 -15 -8 -24 0
-9 -3 -18 -8 -24 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -4 -5 -8 -12 -8 -16 0
-4 -4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4 -4 -11 -8 -16 -5 -6 -8 -15 -8 -24 0
-9 -3 -18 -8 -24 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -6 -7 -8 -15 -8 -32 0
-17 -2 -25 -8 -32 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -7 -8 -8 -15 -8 -64
0 -49 -1 -56 -8 -64 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -7 -8 -8 -15 -8
-64 0 -49 -1 -56 -8 -64 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -8 -9 -8 -15
-8 -104 0 -89 0 -95 -8 -104 -8 -9 -8 -15 -8 -496 0 -481 0 -487 8 -496 8 -9 8
-15 8 -160 0 -145 0 -151 8 -160 8 -9 8 -15 8 -128 0 -113 0 -119 8 -128 7 -8 8
-15 8 -56 0 -47 0 -47 -12 -60 -12 -11 -14 -12 -44 -12 -25 0 -32 -1 -40 -8 -8
-7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8
-7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40
-8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1
-40 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3
-24 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2
-32 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -7 -6 -15 -8 -32 -8 -17 0 -25
-2 -32 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -6 -5 -15 -8 -24 -8 -9 0 -18
-3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18
-3 -24 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -7 -6 -15 -8 -32 -8 -17 0
-25 -2 -32 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0
-18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0
-18 -3 -24 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -6 -5 -15 -8 -24 -8 -9 0
-18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0
-18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0
-11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0
-15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4
0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9
0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4
0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4
0 -18 -11 -32 -24 -14 -13 -28 -24 -32 -24 -4 0 -11 -4 -16 -8 -7 -6 -15 -8 -32
-8 -17 0 -25 -2 -32 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -13 -8 -17
-8 -4 0 -27 -20 -51 -44 -44 -44 -44 -44 -44 -92 0 -48 0 -48 36 -84 20 -20 39
-36 43 -36 4 0 12 -4 17 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 7 -6 15 -8 32 -8
17 0 25 -2 32 -8 8 -7 15 -8 40 -8 25 0 32 -1 40 -8 8 -7 15 -8 56 -8 41 0 48 -1
56 -8 6 -5 15 -8 24 -8 9 0 18 -3 24 -8 6 -5 15 -8 24 -8 9 0 18 -3 24 -8 8 -7
15 -8 56 -8 41 0 48 -1 56 -8 8 -7 15 -8 56 -8 41 0 48 -1 56 -8 8 -7 15 -8 56
-8 41 0 48 -1 56 -8 8 -7 15 -8 64 -8 49 0 56 -1 64 -8 8 -7 15 -8 64 -8 49 0 56
-1 64 -8 8 -7 15 -8 64 -8 49 0 56 -1 64 -8 8 -7 15 -8 64 -8 49 0 56 -1 64 -8 8
-7 15 -8 64 -8 49 0 56 -1 64 -8 9 -8 15 -8 144 -8 129 0 135 0 144 -8 8 -7 15
-8 72 -8 57 0 64 -1 72 -8 9 -8 15 -8 152 -8 137 0 143 0 152 -8 9 -8 15 -8 160
-8 145 0 151 0 160 -8 9 -8 15 -8 576 -8 561 0 567 0 576 8 9 8 15 8 176 8 161 0
167 0 176 8 9 8 15 8 168 8 153 0 159 0 168 8 9 7 15 8 88 8 73 0 79 1 88 8 9 8
15 8 168 8 153 0 159 0 168 8 9 7 15 8 88 8 73 0 79 1 88 8 9 7 15 8 88 8 73 0
79 1 88 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 48 8 33 0 40 1 48 8 9 7 15 8
88 8 73 0 79 1 88 8 8 7 15 8 80 8 65 0 72 1 80 8 9 7 15 8 88 8 73 0 79 1 88 8
8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0
32 1 40 8 8 7 15 8 48 8 33 0 40 1 48 8 8 7 15 8 80 8 65 0 72 1 80 8 8 7 15 8
40 8 25 0 32 1 40 8 8 7 15 8 48 8 33 0 40 1 48 8 8 7 15 8 40 8 25 0 32 1 40 8
8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0
32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 6 5 15 8
24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 8 7
15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1
40 8 8 7 15 8 40 8 25 0 32 1 40 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9
0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8
24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5
15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24
8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18
3 24 8 5 4 12 8 16 8 4 0 11 4 16 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9
0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8
24 8 9 0 18 3 24 8 5 4 12 8 16 8 4 0 11 4 16 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5
15 8 24 8 9 0 18 3 24 8 5 4 12 8 16 8 4 0 11 4 16 8 6 5 15 8 24 8 9 0 18 3 24
8 5 4 12 8 16 8 4 0 15 7 24 16 9 9 20 16 24 16 4 0 11 4 16 8 5 4 12 8 16 8 4 0
11 4 16 8 5 4 12 8 16 8 4 0 15 7 24 16 9 9 20 16 24 16 4 0 11 4 16 8 5 4 12 8
16 8 4 0 11 4 16 8 6 5 15 8 24 8 9 0 18 3 24 8 5 4 12 8 16 8 4 0 11 4 16 8 6 5
15 8 24 8 14 0 19 3 48 32 18 18 36 32 40 32 10 0 128 118 128 128 0 4 7 15 16
24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 4 11 8 16 4 5 8 12 8 16
0 4 4 11 8 16 5 6 8 15 8 24 0 9 3 18 8 24 7 8 8 15 8 48 0 33 1 40 8 48 7 8 8
15 8 40 0 25 -1 32 -8 40 -7 8 -8 15 -8 40 0 25 -1 32 -8 40 -4 5 -8 12 -8 16 0
4 -3 11 -7 14 -4 4 -8 15 -9 25 -1 11 -5 19 -8 21 -18 8 -22 12 -24 21 -2 5 -10
17 -17 25 -8 8 -15 18 -15 22 0 8 -8 16 -16 16 -4 0 -18 11 -32 24 -14 13 -28 24
-32 24 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8 -9 8 -15 8 -368 8
-353 0 -359 0 -368 -8 -9 -8 -15 -8 -296 -8 -281 0 -287 0 -296 -8 -9 -8 -15 -8
-160 -8 -145 0 -151 0 -160 -8 -9 -8 -15 -8 -160 -8 -145 0 -151 0 -160 -8 -9 -8
-15 -8 -160 -8 -145 0 -151 0 -160 -8 -9 -8 -15 -8 -160 -8 -151 0 -151 0 -164
12 -6 7 -12 16 -12 20 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -7 15 -16 24 -9 9
-16 20 -16 24 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8
16 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4
-29 37 -64 72 -36 36 -64 68 -64 72 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 11
-134 144 -144 144 -5 0 -33 25 -72 64 -37 37 -68 64 -72 64 -4 0 -18 11 -32 24
-14 13 -28 24 -32 24 -4 0 -35 27 -72 64 -38 38 -67 64 -72 64 -10 0 -48 38 -48
48 0 10 -54 64 -64 64 -10 0 -80 70 -80 80 0 10 -22 32 -32 32 -4 0 -17 9 -28 20
-11 11 -20 24 -20 28 0 4 -6 13 -12 20 -7 6 -16 12 -20 12 -10 0 -48 38 -48 48 0
4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -18 26 -40 48 -22 22 -40 44 -40 48 0 4
-18 26 -40 48 -22 22 -40 44 -40 48 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -4
11 -8 16 -4 5 -8 12 -8 16 0 4 -14 22 -32 40 -18 18 -32 36 -32 40 0 4 -4 11 -8
16 -4 5 -8 12 -8 16 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -7 15 -16 24 -9 9
-16 20 -16 24 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8
16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -4
11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4
5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -5 6 -8 15 -8
24 0 9 -3 18 -8 24 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11
-8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -7 15 -16 24 -9 9
-16 20 -16 24 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8
16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -11 18 -24 32 -13 14 -24 28 -24 32 0 4
-11 18 -24 32 -13 14 -24 28 -24 32 0 8 -8 16 -16 16 -10 0 -32 23 -32 32 0 4
-11 18 -24 32 -13 14 -24 28 -24 32 0 8 -8 16 -17 16 -3 0 -28 22 -55 48 -27 27
-52 48 -56 48 -4 0 -22 14 -40 32 -18 18 -36 32 -40 32 -4 0 -11 4 -16 8 -5 4
-12 8 -16 8 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8 -5 4 -12 8 -16
8 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -15
7 -24 16 -9 9 -20 16 -24 16 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8
-7 6 -15 8 -32 8 -17 0 -25 2 -32 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8 -7 6 -15
8 -32 8 -17 0 -25 2 -32 8 -8 7 -15 8 -48 8 -33 0 -40 1 -48 8 -9 8 -15 8 -104 8
-89 0 -95 0 -104 -8z m3115 -3165 c8 -8 6 -48 -3 -59 -4 -5 -8 -13 -8 -17 0 -4
-16 -23 -36 -43 -20 -20 -39 -36 -43 -36 -4 0 -12 -4 -17 -8 -5 -4 -12 -8 -16 -8
-4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8
-4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8
-4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8
-9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -7 -6 -15 -8 -32 -8
-17 0 -25 -2 -32 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -8 -7 -15 -8 -64 -8
-49 0 -56 -1 -64 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -7 -6 -15 -8 -32
-8 -17 0 -25 -2 -32 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -8 -7 -15 -8
-72 -8 -57 0 -64 -1 -72 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15
-8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8 -80 -8 -65 0 -72 -1 -80 -8 -8 -7
-15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8
-7 -15 -8 -80 -8 -65 0 -72 -1 -80 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8
-9 -7 -15 -8 -88 -8 -73 0 -79 -1 -88 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40
-8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -9 -7 -15 -8 -88 -8 -73 0 -79 -1
-88 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -48 -8 -33 0 -40
-1 -48 -8 -9 -7 -15 -8 -96 -8 -81 0 -87 -1 -96 -8 -8 -7 -15 -8 -48 -8 -33 0
-40 -1 -48 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -40 -8 -25
0 -32 -1 -40 -8 -9 -7 -15 -8 -96 -8 -81 0 -87 -1 -96 -8 -8 -7 -15 -8 -48 -8
-33 0 -40 -1 -48 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -48
-8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8
-48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15
-8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8 -72 -8 -57 0 -64 -1 -72 -8 -5 -4
-12 -8 -16 -8 -4 0 -9 -3 -12 -6 -3 -3 -8 -6 -12 -6 -8 0 -8 7 -8 193 0 201 0
203 16 203 4 0 15 7 24 16 17 16 17 16 64 16 41 0 48 1 56 8 6 5 15 8 24 8 9 0
18 3 24 8 7 6 15 8 32 8 17 0 25 2 32 8 7 6 15 8 32 8 17 0 25 2 32 8 6 5 15 8
24 8 9 0 18 3 24 8 7 6 15 8 32 8 17 0 25 2 32 8 7 6 15 8 32 8 17 0 25 2 32 8 8
7 15 8 64 8 49 0 56 1 64 8 8 7 15 8 40 8 25 0 32 1 40 8 7 6 15 8 32 8 17 0 25
2 32 8 8 7 15 8 64 8 49 0 56 1 64 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 64 8
49 0 56 1 64 8 8 7 15 8 72 8 57 0 64 1 72 8 8 7 15 8 80 8 65 0 72 1 80 8 7 6
15 8 32 8 17 0 25 2 32 8 8 7 15 8 72 8 57 0 64 1 72 8 9 8 15 8 112 8 97 0 103
0 112 8 8 7 15 8 80 8 65 0 72 1 80 8 9 8 15 8 104 8 89 0 95 0 104 8 8 7 15 8
80 8 65 0 72 1 80 8 9 8 15 8 104 8 89 0 95 0 104 8 9 8 15 8 144 8 129 0 135 0
144 8 9 8 15 8 136 8 121 0 127 0 136 8 9 8 15 8 128 8 113 0 119 0 128 8 9 8 15
8 152 8 137 0 143 0 152 8 9 8 15 8 120 8 80 0 112 -1 115 -5z"/>
<path fill="#4A6B5C" d="M9328 9816 c-5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0
-11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -13 0 -19 -3 -40 -24 -21 -21 -27 -24 -40 -24
-12 0 -18 -3 -32 -16 -9 -9 -20 -16 -24 -16 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16
-8 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -11 -4 -16 -8 -6 -5 -15 -8
-24 -8 -12 0 -18 -3 -32 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9
-20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16
-9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7
-24 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0
-15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16
-4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -22 -14 -40 -32 -18 -18 -36 -32
-40 -32 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -26 -18 -48 -40 -22 -22
-44 -40 -48 -40 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -18 -11 -32 -24
-14 -13 -28 -24 -32 -24 -4 0 -26 -18 -48 -40 -22 -22 -44 -40 -48 -40 -4 0 -29
-21 -56 -48 -27 -26 -52 -48 -56 -48 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16
-4 0 -29 -22 -56 -48 -28 -27 -52 -48 -56 -48 -10 0 -48 -38 -48 -48 0 -10 -54
-64 -64 -64 -5 0 -36 -29 -80 -72 -43 -43 -75 -72 -80 -72 -10 0 -48 -38 -48 -48
0 -10 -54 -64 -64 -64 -10 0 -48 -38 -48 -48 0 -10 -54 -64 -64 -64 -10 0 -48
-38 -48 -48 0 -5 -32 -40 -80 -88 -47 -47 -80 -83 -80 -88 0 -4 -11 -18 -24 -32
-13 -14 -24 -28 -24 -32 0 -10 -54 -64 -64 -64 -10 0 -48 -38 -48 -48 0 -4 -11
-18 -24 -32 -13 -14 -24 -28 -24 -32 0 -4 -24 -32 -56 -64 -32 -32 -56 -60 -56
-64 0 -4 -11 -18 -24 -32 -13 -14 -24 -28 -24 -32 0 -4 -11 -18 -24 -32 -13 -14
-24 -28 -24 -32 0 -4 -11 -18 -24 -32 -13 -14 -24 -28 -24 -32 0 -4 -4 -11 -8
-16 -4 -5 -8 -12 -8 -15 0 -4 -3 -10 -6 -14 -8 -8 -5 -19 5 -19 4 0 12 -4 17 -8
5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8
16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11
-4 16 -8 6 -5 15 -8 24 -8 9 0 18 -3 24 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5
-4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16
-8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4
16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4
12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8
4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 6 -5 15 -8 24 -8 9 0 18 -3 24
-8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12
-8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0
11 -4 16 -8 5 -4 12 -8 16 -8 4 0 9 -3 12 -6 3 -3 8 -6 11 -6 9 0 25 19 25 28 0
10 54 64 64 64 4 0 18 11 32 24 14 13 28 24 32 24 10 0 48 38 48 48 0 10 54 64
64 64 11 0 416 405 416 416 0 10 54 64 64 64 10 0 48 38 48 48 0 5 29 37 72 80
42 42 72 75 72 80 0 4 11 18 24 32 13 14 24 28 24 32 0 4 18 26 40 48 22 22 40
44 40 48 0 4 21 29 48 56 26 27 48 52 48 56 0 4 7 15 16 24 9 9 16 20 16 24 0 4
11 18 24 32 13 14 24 28 24 32 0 4 18 26 40 48 22 22 40 44 40 48 0 4 7 15 16 24
9 9 16 20 16 24 0 4 11 18 24 32 13 14 24 28 24 32 0 4 7 15 16 24 9 9 16 20 16
24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 4 11 8 16
4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 7 15 16 24 9 9 16 20 16 24 0 4 7
15 16 24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16
20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 4
11 8 16 4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16
0 4 4 11 8 16 4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 7 15 16 24 9 9 16
20 16 24 0 4 4 11 8 16 5 6 8 15 8 24 0 9 3 18 8 24 4 5 8 12 8 16 0 4 4 11 8 16
4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 4 11
8 16 5 6 8 15 8 24 0 9 3 18 8 24 4 5 8 12 8 16 0 4 4 11 8 16 5 6 8 15 8 24 0 9
3 18 8 24 4 5 8 12 8 16 0 4 4 11 8 16 7 8 8 15 8 40 0 25 1 32 8 40 5 6 8 15 8
24 0 9 3 18 8 24 6 7 8 15 8 32 0 17 2 25 8 32 6 7 8 15 8 31 0 15 2 25 6 30 12
12 5 19 -21 19 -18 0 -26 -2 -33 -8z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

+272 -25
View File
@@ -1,27 +1,274 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="202 251 902 723">
<g transform="translate(0,1254) scale(0.1,-0.1)" fill-rule="evenodd">
<path fill="currentColor" d="M2252 9878 l3 -152 109 -22 c342 -72 492 -184 538 -405 16 -74 24
-4823 9 -5024 -20 -266 -73 -375 -236 -484 -116 -77 -332 -150 -543 -181 -114
-18 -107 -6 -110 -165 -1 -76 2 -143 7 -148 9 -9 2607 -11 2623 -1 14 9 10
280 -4 291 -7 5 -49 15 -93 22 -380 60 -638 193 -720 374 -63 136 -59 -12 -61
2247 -3 1999 -2 2054 15 2015 116 -253 646 -1520 1183 -2825 71 -173 216 -524
322 -780 206 -494 266 -640 370 -895 97 -237 68 -210 226 -210 l135 0 23 50
c13 27 95 212 182 410 134 307 747 1685 1015 2285 92 205 726 1599 910 2000
65 140 126 274 137 297 22 50 52 71 74 52 12 -10 14 -266 14 -1864 l0 -1852
-82 -7 c-347 -29 -716 -203 -973 -460 -710 -712 -343 -1645 650 -1649 453 -2
892 184 1206 510 193 202 295 397 351 676 l23 112 0 2357 c0 1297 0 2358 1
2358 12 0 142 -49 179 -67 342 -173 607 -545 715 -1004 85 -361 66 -801 -51
-1215 -43 -151 -40 -173 18 -174 53 0 284 377 396 650 243 590 303 1238 163
1754 -178 652 -643 1100 -1294 1248 -93 21 -122 22 -716 25 -707 4 -649 12
-696 -90 -15 -34 -78 -172 -140 -307 -593 -1297 -1123 -2469 -1717 -3800 -210
-472 -193 -437 -204 -418 -11 19 -173 423 -630 1568 -214 536 -394 986 -400
1000 -6 14 -76 187 -156 385 -80 198 -182 452 -228 565 -46 113 -140 346 -209
518 -205 507 -225 554 -244 568 -14 11 -209 13 -1055 14 l-1038 0 3 -152z"/>
<path fill="#4A6B5C" d="M8830 7450 l0 -2582 -32 6 c-517 106 -1064 -47 -1442 -405 -598 -566
-501 -1354 196 -1598 489 -170 1134 -19 1548 363 234 217 350 418 423 736 l22
95 3 2373 c2 1961 5 2372 16 2372 27 0 132 -41 205 -81 315 -169 569 -524 675
-944 50 -198 60 -285 60 -525 0 -288 -27 -487 -105 -756 -34 -120 -35 -130
-11 -143 33 -18 64 11 154 144 227 334 402 795 469 1235 37 238 34 646 -4 843
-149 761 -637 1271 -1353 1418 -98 20 -147 23 -466 27 l-358 4 0 -2582z"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="608 592 3928 3568">
<!-- Generated by tools/gen-brand-assets.py — do not edit by hand.
Literal colours, for any consumer that cannot inline the SVG and
therefore cannot supply a currentColor. -->
<g transform="translate(0.000000,5016.000000) scale(0.250000,-0.250000)" fill-rule="evenodd">
<path fill="#E8E4D8" d="M17412 17089 c-134 -7 -375 -42 -530 -77 -90 -20 -238 -58 -296 -76 -13 -4 -60
-19 -104 -32 -44 -14 -90 -28 -102 -32 -60 -21 -156 -55 -206 -75 -31 -12 -72
-28 -92 -35 -20 -7 -56 -22 -80 -32 -24 -10 -50 -21 -58 -24 -17 -6 -79 -34 -133
-59 -21 -10 -59 -27 -84 -38 -42 -19 -167 -81 -221 -111 -13 -7 -40 -21 -59 -32
-35 -18 -44 -23 -169 -97 -371 -217 -625 -419 -799 -637 -69 -87 -107 -141 -152
-216 -70 -117 -115 -204 -163 -314 -11 -26 -23 -54 -26 -61 -12 -26 -42 -108 -64
-173 -48 -144 -92 -356 -106 -516 -3 -27 -6 -63 -8 -78 -2 -15 -4 -64 -4 -108 0
-77 -2 -94 -11 -89 -5 3 -16 39 -29 91 -54 222 -65 467 -32 690 12 78 31 167 48
220 40 125 60 175 113 286 25 53 28 61 18 63 -9 2 -145 -92 -227 -157 -25 -20
-56 -44 -68 -54 -39 -30 -124 -107 -197 -180 -112 -109 -216 -229 -317 -364 -107
-142 -234 -379 -307 -568 -69 -177 -120 -389 -135 -558 -2 -26 -6 -68 -8 -92 -7
-80 -4 -224 8 -360 8 -89 28 -204 68 -392 8 -35 32 -125 40 -146 2 -5 8 -27 14
-48 6 -21 20 -65 31 -97 10 -33 19 -62 19 -64 0 -8 -17 11 -34 37 -33 51 -118
211 -158 298 -46 100 -102 256 -134 373 -56 211 -78 413 -67 635 4 78 17 180 30
241 12 51 -5 41 -49 -31 -16 -25 -33 -52 -38 -60 -65 -100 -169 -296 -217 -408
-42 -98 -90 -228 -118 -324 -37 -125 -87 -343 -103 -452 -22 -157 -30 -269 -30
-446 0 -178 6 -273 26 -406 2 -14 8 -54 12 -88 8 -58 49 -272 63 -326 21 -87 73
-265 94 -324 11 -31 29 -85 41 -120 12 -35 29 -83 38 -106 9 -23 24 -62 34 -86
22 -56 49 -122 69 -168 9 -19 15 -37 14 -40 -3 -5 -26 22 -62 74 -17 25 -41 60
-52 76 -21 30 -118 181 -134 208 -4 8 -21 35 -36 61 -37 60 -133 247 -170 329
-54 123 -107 261 -131 345 -7 23 -10 29 -16 29 -9 0 -10 -3 -16 -68 -16 -187 -15
-298 4 -473 14 -124 47 -294 94 -485 25 -97 27 -106 83 -274 57 -173 84 -249 124
-348 16 -41 41 -103 55 -138 14 -35 45 -105 68 -156 24 -51 49 -106 57 -124 39
-87 106 -217 205 -398 25 -45 53 -98 63 -117 11 -19 21 -36 23 -37 7 -4 221 -3
225 1 2 3 6 20 7 40 2 19 6 44 8 55 2 11 5 31 6 44 1 13 6 49 10 80 4 31 12 83
16 116 18 132 52 331 112 658 4 23 13 73 20 110 19 101 46 237 66 330 10 45 22
99 26 120 20 91 35 161 48 214 15 62 33 136 40 162 4 14 21 79 37 144 17 65 35
134 40 154 6 20 18 63 27 96 36 134 103 348 152 486 14 39 32 90 40 114 9 24 23
65 32 90 9 25 24 66 32 90 8 24 24 65 34 90 10 25 28 72 40 104 26 70 43 112 119
292 9 21 20 48 25 60 5 12 20 46 33 76 157 352 355 737 536 1042 76 127 135 224
149 246 8 12 22 36 32 52 123 215 410 628 606 872 180 225 306 368 466 528 173
173 333 313 513 449 72 55 91 67 91 58 0 -6 -8 -14 -123 -135 -81 -86 -168 -183
-283 -316 -135 -156 -335 -426 -496 -668 -81 -123 -202 -319 -270 -441 -21 -36
-52 -91 -70 -123 -52 -91 -166 -313 -209 -408 -22 -47 -48 -101 -57 -120 -9 -19
-25 -54 -36 -78 -17 -39 -35 -79 -99 -224 -9 -20 -23 -54 -32 -76 -23 -55 -65
-155 -87 -208 -19 -46 -48 -118 -80 -202 -10 -27 -27 -70 -36 -94 -10 -24 -29
-76 -44 -116 -15 -40 -30 -81 -34 -92 -4 -11 -15 -42 -24 -68 -9 -26 -19 -54 -22
-62 -6 -15 -26 -74 -50 -144 -8 -25 -23 -69 -33 -98 -30 -86 -124 -379 -151 -472
-9 -31 -26 -87 -38 -124 -12 -37 -27 -89 -34 -114 -7 -25 -19 -66 -26 -90 -7 -24
-17 -59 -22 -78 -9 -34 -28 -101 -47 -164 -5 -19 -15 -54 -21 -78 -13 -51 -28
-105 -48 -180 -5 -18 -16 -59 -24 -92 -8 -33 -21 -81 -28 -106 -24 -87 -61 -229
-92 -358 -11 -44 -25 -98 -30 -120 -21 -80 -86 -345 -94 -380 -12 -52 -17 -75
-38 -158 -21 -87 -35 -147 -44 -186 -15 -67 -47 -211 -54 -244 -5 -20 -12 -50
-16 -68 -24 -105 -33 -145 -42 -188 -6 -26 -13 -60 -16 -74 -9 -41 -19 -101 -20
-116 0 -14 0 -14 58 -43 32 -16 61 -29 65 -29 4 0 23 24 52 67 55 80 95 133 200
257 108 129 221 243 347 350 20 18 47 41 60 51 12 11 45 37 74 59 29 22 65 50 80
61 34 26 135 97 182 128 19 12 58 38 88 57 30 19 63 41 74 48 11 7 39 25 62 39
77 49 110 70 192 127 45 31 89 61 96 66 22 15 121 95 164 132 57 50 156 149 208
209 57 64 151 193 193 263 31 51 38 68 34 73 -2 1 -43 -18 -92 -42 -49 -24 -100
-48 -113 -54 -79 -34 -112 -48 -148 -60 -86 -31 -283 -95 -338 -111 -32 -8 -91
-25 -132 -37 -78 -21 -317 -101 -398 -133 -26 -10 -64 -24 -84 -32 -20 -7 -48
-19 -64 -25 -26 -12 -46 -17 -46 -10 0 4 12 13 74 60 87 65 101 74 216 143 99 59
294 161 394 205 71 32 147 67 236 111 17 8 51 24 76 35 122 54 319 160 433 235
150 97 274 196 384 307 124 125 209 240 286 385 20 38 53 108 63 136 3 8 13 37
24 66 31 84 61 193 72 263 6 38 6 47 -2 47 -1 0 -17 -12 -34 -26 -39 -32 -127
-96 -175 -127 -110 -72 -280 -155 -429 -211 -42 -15 -181 -58 -260 -79 -162 -43
-418 -79 -644 -91 -105 -5 -130 -5 -130 2 0 7 16 12 67 24 176 40 443 130 669
225 93 39 268 127 356 178 62 36 198 123 240 155 263 195 443 402 602 694 52 97
104 228 139 354 15 53 36 152 51 246 20 123 31 317 18 322 -8 4 -15 -1 -29 -19
-26 -33 -92 -101 -137 -141 -107 -96 -288 -218 -431 -290 -49 -24 -149 -71 -169
-78 -8 -3 -27 -11 -44 -18 -16 -7 -34 -14 -40 -16 -5 -2 -23 -8 -38 -14 -67 -25
-143 -50 -206 -68 -10 -3 -46 -13 -80 -23 -63 -19 -115 -32 -186 -47 -22 -5 -55
-12 -74 -16 -19 -4 -47 -10 -62 -14 -15 -3 -44 -8 -64 -10 -20 -3 -57 -8 -82 -12
-63 -10 -80 -12 -80 -8 0 6 15 16 42 26 15 6 56 24 90 40 34 16 91 42 126 57 95
42 300 147 399 205 152 88 190 110 293 178 197 129 349 246 490 379 139 129 221
225 306 357 159 244 256 534 310 924 3 26 7 50 26 174 6 42 19 112 34 187 15 78
53 213 83 299 31 89 67 168 120 268 100 185 247 349 415 460 53 35 163 86 230
106 62 19 57 22 -74 46 -83 15 -107 18 -190 24 -69 5 -270 7 -340 3z M10962
13252 c-22 -4 -51 -10 -64 -14 -13 -4 -38 -11 -56 -17 -141 -42 -381 -164 -597
-303 -12 -8 -31 -21 -44 -29 -71 -45 -259 -181 -345 -250 -207 -164 -321 -263
-550 -480 -94 -88 -287 -283 -354 -358 -17 -18 -70 -76 -120 -130 -94 -102 -172
-190 -256 -287 -27 -32 -62 -72 -78 -90 -16 -18 -61 -70 -100 -116 -155 -182
-267 -303 -416 -450 -127 -125 -183 -178 -212 -202 -13 -11 -37 -32 -54 -46 -74
-65 -108 -89 -108 -76 0 4 31 51 70 104 14 19 37 52 52 74 15 22 54 76 87 119 53
72 67 95 55 95 -6 0 -79 -23 -140 -45 -239 -85 -448 -207 -599 -353 -44 -41 -107
-111 -146 -160 -57 -73 -143 -211 -203 -330 -41 -80 -115 -241 -150 -326 -8 -20
-20 -47 -26 -60 -26 -56 -106 -258 -163 -412 -34 -92 -132 -383 -161 -476 -72
-235 -91 -299 -115 -394 -42 -157 -53 -203 -67 -268 -6 -27 -12 -56 -14 -64 -6
-23 -16 -77 -16 -88 0 -12 23 -34 74 -67 71 -47 136 -74 276 -115 176 -51 387
-77 708 -86 153 -5 357 -1 476 8 26 2 76 6 110 8 139 9 375 34 504 54 21 3 57 9
80 12 82 11 129 18 197 30 38 7 78 14 90 16 11 2 36 6 54 10 18 3 57 11 87 16 63
12 227 46 290 61 78 17 195 45 246 58 28 6 60 14 72 17 25 6 134 36 382 104 37
10 76 21 86 24 41 11 281 85 380 116 250 79 681 228 878 302 54 20 195 73 344
128 48 18 107 40 131 48 24 9 47 18 51 21 5 3 21 26 37 51 16 25 53 78 81 118 87
122 116 167 147 231 54 109 73 181 73 281 0 90 -6 109 -96 298 -78 165 -170 375
-229 524 -37 93 -108 303 -133 394 -36 130 -85 346 -102 446 -23 143 -30 193 -34
242 -2 29 -6 77 -8 108 -8 91 -5 301 4 414 16 192 38 322 90 528 34 136 90 308
130 399 32 75 95 200 116 235 9 14 16 28 16 32 0 10 -27 41 -100 116 -105 107
-159 150 -247 193 -114 58 -250 78 -371 57z m-574 -653 c0 -3 -11 -19 -24 -35
-56 -66 -193 -249 -280 -372 -11 -16 -37 -53 -58 -82 -36 -52 -121 -175 -270
-396 -75 -111 -134 -197 -180 -262 -11 -16 -37 -53 -56 -82 -20 -29 -50 -72 -68
-96 -17 -24 -51 -71 -75 -104 -23 -33 -53 -73 -67 -90 -13 -16 -45 -57 -70 -90
-25 -33 -54 -71 -66 -84 -11 -14 -36 -44 -56 -68 -211 -257 -378 -416 -712 -676
-202 -158 -451 -314 -678 -427 -89 -44 -108 -51 -108 -43 0 6 60 71 172 186 36
37 101 106 144 152 43 46 93 99 110 118 35 36 141 155 190 212 17 19 46 53 66 75
106 116 265 304 348 409 22 28 50 62 62 76 23 26 112 138 204 254 45 57 121 152
238 294 56 69 157 186 190 222 14 15 39 42 54 60 81 93 266 279 418 420 102 95
305 256 444 353 125 86 128 88 128 76z M15278 9526 c-254 -10 -537 -42 -734 -84
-25 -6 -76 -16 -112 -24 -36 -8 -77 -17 -90 -20 -13 -3 -40 -9 -60 -14 -53 -12
-94 -23 -148 -39 -26 -7 -67 -19 -90 -26 -105 -29 -86 -17 -169 -106 -108 -115
-183 -200 -183 -207 0 -3 1 -6 3 -7 2 -1 60 12 129 30 238 60 436 99 672 133 378
53 762 62 1100 26 394 -43 669 -112 950 -237 86 -38 120 -56 199 -104 127 -76
190 -124 288 -220 56 -55 72 -75 59 -75 -3 0 -36 11 -75 24 -104 35 -245 74 -334
92 -333 70 -595 86 -899 56 -432 -43 -871 -187 -1332 -438 -230 -125 -518 -327
-752 -525 -121 -103 -206 -178 -288 -255 -42 -39 -93 -86 -112 -104 -177 -163
-532 -518 -810 -808 -46 -48 -129 -135 -184 -192 -55 -58 -148 -155 -207 -216
-100 -105 -332 -337 -427 -428 -81 -78 -251 -228 -320 -282 -287 -228 -476 -351
-804 -523 -150 -78 -582 -266 -840 -365 -243 -93 -485 -179 -676 -241 -74 -24
-251 -78 -274 -85 -11 -3 -36 -10 -56 -16 -106 -31 -150 -44 -280 -80 -47 -13
-154 -41 -238 -60 -23 -6 -56 -14 -74 -18 -49 -12 -99 -23 -152 -34 -26 -5 -77
-16 -113 -24 -67 -15 -315 -60 -391 -72 -24 -4 -62 -10 -84 -14 -52 -10 -236 -33
-245 -31 -14 3 25 18 115 45 164 49 282 91 490 173 160 63 238 98 416 183 31 15
73 35 93 45 168 79 470 256 681 398 361 243 556 410 872 745 370 392 569 586 811
794 96 82 247 200 349 274 154 112 324 220 486 308 97 53 271 133 347 160 36 13
31 15 98 -39 67 -55 115 -85 214 -132 36 -17 125 -51 135 -51 14 0 12 8 -3 17
-85 50 -155 106 -231 184 -78 80 -123 140 -167 230 -67 131 -93 230 -104 379 -10
146 6 266 54 414 47 145 132 289 232 396 80 84 173 154 279 208 60 31 97 45 164
64 90 25 155 34 256 34 89 0 130 -5 210 -22 159 -36 312 -120 437 -242 73 -71
127 -143 178 -242 24 -45 36 -73 65 -154 9 -25 18 -35 27 -28 4 2 29 20 56 40 28
20 52 40 55 45 5 11 -14 67 -52 154 -62 139 -127 230 -246 343 -51 49 -83 74
-130 105 -131 85 -243 133 -370 159 -57 12 -167 24 -215 24 -43 0 -151 -9 -193
-16 -197 -33 -383 -125 -554 -272 -110 -94 -212 -233 -282 -384 -36 -76 -52 -123
-78 -226 -28 -109 -38 -196 -35 -317 2 -72 1 -86 -4 -91 -3 -3 -6 -6 -6 -6 -1 0
-28 -10 -97 -35 -24 -9 -67 -24 -96 -35 -126 -45 -218 -78 -238 -86 -12 -5 -27
-10 -32 -12 -6 -2 -36 -13 -66 -25 -78 -30 -130 -50 -214 -81 -55 -20 -147 -55
-232 -88 -69 -26 -139 -54 -188 -72 -71 -27 -114 -43 -168 -64 -30 -12 -76 -30
-102 -40 -80 -31 -85 -33 -194 -76 -58 -23 -124 -48 -146 -56 -22 -8 -73 -28
-114 -44 -41 -16 -91 -36 -112 -44 -21 -8 -50 -19 -64 -25 -14 -5 -38 -14 -52
-20 -14 -5 -41 -16 -60 -23 -19 -8 -51 -20 -72 -28 -21 -8 -49 -19 -62 -24 -13
-5 -46 -18 -74 -28 -27 -10 -65 -25 -84 -32 -19 -7 -47 -18 -62 -24 -15 -6 -36
-14 -46 -18 -10 -4 -31 -12 -46 -18 -15 -6 -34 -13 -42 -16 -8 -3 -25 -9 -38 -14
-13 -5 -30 -11 -38 -14 -8 -3 -61 -23 -118 -44 -57 -22 -113 -43 -124 -47 -11 -4
-69 -25 -128 -47 -59 -22 -118 -43 -130 -48 -18 -7 -73 -27 -198 -72 -11 -4 -35
-13 -54 -20 -19 -7 -69 -25 -112 -41 -43 -15 -97 -34 -120 -43 -23 -8 -67 -24
-98 -35 -31 -11 -66 -24 -78 -29 -27 -10 -65 -24 -118 -42 -22 -7 -74 -25 -116
-40 -42 -15 -91 -32 -110 -38 -19 -6 -47 -16 -64 -22 -16 -6 -61 -21 -98 -34 -37
-13 -113 -39 -168 -58 -55 -19 -160 -54 -234 -79 -74 -24 -162 -53 -196 -65 -66
-23 -147 -50 -304 -101 -54 -18 -151 -50 -216 -71 -65 -21 -164 -53 -220 -71
-224 -70 -333 -103 -388 -119 -32 -9 -81 -24 -110 -33 -59 -18 -258 -73 -362
-101 -38 -10 -102 -27 -142 -38 -367 -99 -775 -177 -1038 -196 -98 -8 -291 -7
-360 1 -187 23 -313 72 -405 158 -49 46 -73 87 -87 147 -10 40 -10 52 0 100 43
209 194 467 432 740 139 159 315 325 508 480 88 71 120 95 250 191 56 41 254 173
304 203 16 10 46 29 67 41 41 27 244 142 307 176 172 90 263 134 484 231 46 20
129 52 202 78 88 31 137 49 145 54 5 2 18 22 29 45 31 59 73 139 100 186 24 41
27 50 20 55 -5 3 -108 -15 -168 -30 -25 -6 -56 -13 -68 -16 -100 -22 -195 -52
-362 -111 -217 -78 -354 -137 -565 -242 -216 -109 -445 -244 -633 -374 -169 -116
-247 -176 -402 -305 -78 -65 -78 -64 -205 -186 -201 -190 -371 -390 -508 -594
-62 -93 -121 -196 -178 -309 -19 -37 -23 -47 -60 -138 -39 -94 -61 -169 -81 -269
-30 -159 -29 -288 2 -424 23 -101 47 -163 97 -254 139 -249 392 -463 778 -657 66
-34 155 -75 203 -94 34 -13 55 -22 82 -33 35 -14 183 -68 218 -80 15 -5 43 -14
62 -19 19 -6 52 -16 74 -23 84 -26 175 -51 248 -68 104 -25 98 -24 220 -50 306
-65 634 -107 994 -126 100 -5 385 -11 458 -9 144 3 321 10 372 13 137 9 232 16
288 22 232 23 225 22 460 52 169 22 440 68 672 114 33 6 70 13 82 16 32 6 187 40
282 62 24 6 59 14 78 18 19 4 66 15 104 24 39 9 81 19 94 22 68 15 513 133 614
163 19 6 57 17 84 25 28 8 70 21 94 28 24 7 79 23 122 36 43 12 89 26 102 30 13
4 48 15 78 24 168 52 316 100 446 146 42 15 107 37 144 50 132 45 359 128 502
183 61 23 141 53 178 67 37 14 91 35 118 46 28 11 73 29 100 40 99 38 343 137
458 186 171 72 333 141 384 164 31 14 79 35 108 47 54 23 129 56 222 99 30 14 86
39 124 56 39 17 118 54 177 82 59 27 108 50 109 50 1 0 46 22 101 48 54 27 122
60 151 73 98 46 561 281 637 324 17 10 64 35 105 57 344 182 837 498 1136 730
178 137 220 171 330 264 68 57 138 116 204 170 27 22 62 51 78 64 17 14 53 42 82
64 29 22 77 59 108 82 120 91 338 241 431 297 14 9 62 38 106 65 104 64 270 158
363 207 40 21 92 49 117 62 25 14 77 41 116 60 39 20 116 58 171 86 55 27 114 56
130 63 17 7 65 31 109 52 43 22 97 48 120 58 73 33 280 130 304 143 15 8 24 15
25 21 3 12 -21 90 -44 140 -22 48 -70 133 -106 188 -43 65 -110 143 -189 220
-117 115 -171 158 -301 237 -91 55 -165 95 -220 120 -24 11 -54 24 -66 30 -71 32
-189 73 -324 112 -54 16 -219 52 -294 65 -109 18 -272 36 -380 42 -88 4 -259 8
-312 6z M12694 8423 c-3 -7 -6 -19 -8 -27 -5 -24 -26 -84 -47 -132 -43 -96 -79
-152 -141 -214 -80 -81 -161 -130 -292 -178 -26 -9 -40 -16 -40 -20 0 -4 12 -10
36 -17 161 -51 276 -136 366 -270 42 -63 97 -192 120 -281 9 -35 20 -34 28 2 10
46 53 165 78 212 44 86 102 158 170 214 70 57 129 89 222 120 52 17 55 23 20 33
-118 35 -241 107 -308 180 -78 84 -148 224 -177 359 -8 35 -17 42 -27 19z M5933
7589 c-7 -5 -39 -97 -53 -154 -7 -27 -15 -59 -18 -71 -16 -59 -50 -198 -58 -230
-4 -20 -16 -73 -26 -118 -19 -86 -24 -107 -40 -196 -29 -151 -30 -170 -18 -231
25 -131 110 -235 254 -311 83 -44 266 -98 352 -104 32 -3 32 -3 96 23 35 14 91
36 124 49 80 31 216 86 258 104 19 8 55 23 80 33 55 21 143 57 185 75 16 7 83 34
148 60 64 25 122 48 127 51 9 4 70 28 144 58 17 7 61 24 98 39 37 15 97 38 132
52 73 29 135 53 216 84 31 12 68 26 82 32 14 6 58 22 96 37 64 24 131 50 246 95
24 10 71 28 104 40 33 12 66 25 74 28 8 3 27 10 42 16 15 6 34 13 42 16 19 8 98
38 144 55 61 23 177 66 194 71 9 3 48 17 88 32 40 15 93 34 118 42 53 17 92 34
92 39 0 6 -35 2 -148 -15 -21 -3 -69 -10 -106 -14 -37 -5 -91 -12 -120 -16 -223
-33 -612 -69 -900 -84 -387 -19 -756 -12 -1049 20 -168 18 -373 60 -496 100 -165
54 -271 105 -368 177 -43 32 -106 90 -116 108 -7 11 -11 13 -20 8z"/>
<path fill="#4A6B5C" d="M11642 17427 c4 -332 10 -651 14 -799 7 -251 10 -586 5 -589 -2 -2 -16 2 -31 7
-43 15 -81 19 -148 17 -47 -1 -68 -4 -100 -12 -101 -26 -179 -72 -254 -147 -67
-67 -115 -151 -141 -248 -7 -28 -9 -45 -9 -92 0 -73 10 -119 38 -178 25 -50 48
-79 89 -110 124 -94 277 -104 429 -29 169 84 285 250 316 453 7 45 8 269 2 534
-2 92 -5 254 -6 359 -3 182 -3 191 4 194 4 1 11 0 17 -3 5 -3 26 -17 48 -30 117
-70 178 -120 234 -190 68 -85 98 -225 73 -346 -13 -62 -52 -158 -78 -190 -12 -16
-20 -29 -20 -36 0 -25 127 73 179 137 40 51 79 136 102 225 42 157 12 352 -73
488 -71 112 -130 174 -264 274 -104 78 -211 178 -269 252 -31 40 -97 143 -124
193 -15 26 -21 35 -28 35 -8 0 -8 -1 -5 -169z M9054 17478 c-44 -17 -84 -32 -90
-34 -5 -2 -34 -13 -64 -24 -30 -11 -90 -34 -134 -50 -92 -34 -137 -51 -188 -71
-32 -13 -121 -46 -204 -76 -42 -16 -95 -36 -165 -63 -35 -14 -69 -27 -77 -30 -78
-29 -158 -59 -196 -74 -16 -6 -62 -23 -102 -38 -40 -15 -82 -30 -94 -35 -12 -4
-28 -10 -36 -13 -8 -3 -45 -17 -82 -31 -98 -39 -154 -60 -201 -77 -62 -22 -59
-18 -58 -86 1 -32 3 -89 5 -128 2 -38 6 -140 8 -226 2 -86 6 -186 8 -222 2 -36 6
-125 8 -198 2 -73 7 -195 10 -272 3 -77 8 -199 10 -272 2 -73 6 -162 8 -198 6
-101 5 -198 -1 -203 -4 -3 -11 -2 -27 3 -29 10 -89 19 -136 23 -73 4 -157 -13
-231 -49 -132 -63 -232 -160 -302 -294 -54 -103 -78 -243 -61 -348 16 -92 49
-158 110 -220 45 -46 94 -75 164 -98 32 -11 38 -12 106 -12 66 0 75 1 112 11 95
25 152 54 220 111 117 99 204 229 239 360 28 104 32 152 27 330 -2 77 -6 208 -8
292 -2 84 -6 207 -8 274 -2 67 -7 208 -10 312 -3 105 -8 237 -10 294 -8 244 -9
300 -3 307 5 6 26 16 65 29 9 3 38 14 64 24 26 10 85 32 130 48 45 17 105 39 132
50 28 11 82 32 122 46 90 33 112 41 134 50 27 11 86 34 118 46 15 6 34 13 42 16
19 8 77 30 154 58 75 28 161 60 210 80 58 23 159 57 170 58 12 0 12 0 15 -34 1
-19 2 -71 3 -116 0 -78 3 -202 12 -494 2 -73 5 -212 6 -309 3 -204 7 -188 -38
-173 -87 27 -205 27 -295 -1 -167 -53 -300 -170 -379 -333 -64 -133 -74 -282 -26
-407 32 -82 102 -158 182 -197 61 -30 98 -39 171 -41 50 -2 64 -1 99 7 155 33
282 127 389 285 51 77 87 173 106 287 5 36 6 66 6 202 -1 88 -3 235 -5 326 -6
240 -15 700 -18 860 -1 76 -4 212 -6 302 -2 90 -5 234 -6 320 -2 93 -5 158 -7
161 -2 3 -7 5 -10 5 -4 0 -43 -14 -87 -30z M10590 16917 c-241 -7 -571 -39 -792
-76 -20 -3 -48 -7 -62 -9 -59 -9 -147 -29 -177 -41 -54 -21 -82 -53 -117 -131
-27 -61 -25 -67 22 -58 96 19 176 34 190 36 9 1 54 8 100 16 199 32 325 50 476
64 33 3 76 8 96 10 166 19 676 42 990 46 76 1 78 1 79 9 3 19 -49 74 -84 89 -39
17 -188 37 -327 44 -76 4 -295 4 -394 1z M12612 16699 c-3 -5 -3 -12 2 -32 3 -14
7 -38 8 -54 4 -35 9 -41 45 -49 12 -3 47 -11 77 -19 30 -7 76 -19 102 -26 105
-26 423 -127 520 -165 26 -10 57 -22 68 -26 56 -20 134 -52 186 -75 32 -14 74
-32 94 -40 20 -7 89 -40 153 -72 64 -32 118 -57 119 -56 5 4 -39 38 -122 93 -47
31 -100 65 -118 76 -18 11 -35 22 -38 24 -3 3 -22 13 -42 24 -20 11 -50 27 -68
37 -53 30 -201 103 -260 128 -48 21 -131 54 -236 95 -24 10 -209 70 -260 86 -22
6 -219 56 -224 56 0 0 -3 -2 -6 -5z M7107 16128 c-9 -5 -50 -24 -92 -43 -323
-143 -696 -369 -1031 -626 -221 -170 -375 -306 -558 -494 -111 -115 -112 -116
-93 -140 6 -6 22 -29 36 -51 39 -59 40 -60 50 -56 5 2 42 35 82 72 203 190 371
336 516 449 58 45 189 143 234 174 18 12 46 32 62 44 28 21 70 49 173 115 29 18
64 41 78 50 14 9 47 29 72 44 25 15 57 34 70 42 33 20 113 66 206 119 44 24 88
50 98 58 11 7 35 24 53 37 18 12 41 30 51 39 18 17 19 18 22 49 3 18 4 53 3 78
-1 52 -3 55 -32 40z M10520 15886 c-173 -8 -506 -53 -752 -102 -120 -24 -324 -75
-333 -83 -7 -6 -7 -14 -6 -92 0 -59 2 -88 5 -92 5 -6 11 -5 75 10 317 80 557 129
797 163 163 23 352 47 410 51 32 3 57 7 61 10 3 3 10 16 15 30 5 14 17 40 26 58
20 36 21 44 11 46 -8 2 -265 2 -309 1z M12215 15662 c-35 -7 -64 -14 -65 -15 -7
-7 3 -15 25 -19 13 -2 46 -10 75 -16 29 -6 64 -14 78 -16 32 -6 243 -58 314 -78
145 -41 396 -121 476 -151 112 -43 195 -71 205 -71 7 0 20 8 36 20 28 22 32 29
25 38 -7 9 -174 90 -254 124 -174 74 -276 111 -414 151 -86 25 -89 26 -205 37
-147 14 -213 13 -296 -4z M4890 15080 c-8 -13 -6 -751 6 -2098 2 -253 3 -483 1
-512 -4 -61 1 -57 -55 -38 -75 26 -179 34 -265 22 -80 -12 -190 -56 -270 -108
-89 -56 -190 -168 -241 -264 -60 -114 -78 -180 -81 -300 -1 -61 -1 -83 5 -112 12
-67 38 -129 81 -194 63 -95 171 -162 292 -182 214 -34 443 73 605 283 93 120 153
267 172 421 3 25 4 228 3 690 0 360 0 742 0 850 1 108 1 200 1 205 0 6 2 9 7 9 6
0 108 -66 205 -132 102 -70 218 -182 280 -270 132 -187 162 -383 91 -596 -31 -93
-101 -215 -158 -275 -24 -25 -28 -35 -15 -35 10 0 96 43 123 61 94 64 168 140
219 227 23 37 69 140 84 185 32 96 46 187 46 309 0 121 -11 186 -50 305 -33 100
-73 178 -148 287 -55 82 -102 140 -174 216 -117 125 -256 277 -304 332 -158 184
-324 442 -415 646 -29 63 -32 69 -38 71 -2 1 -6 -1 -7 -3z M6454 14216 c-4 -5
-30 -27 -56 -49 -162 -137 -326 -292 -326 -310 0 -5 11 -29 25 -54 14 -25 34 -64
44 -87 10 -23 21 -44 24 -46 5 -3 35 24 125 111 108 105 152 145 251 231 19 16
35 33 36 37 2 4 -3 14 -12 24 -23 28 -50 71 -68 111 -19 42 -28 49 -43 32z M4552
13379 c-42 -45 -92 -104 -122 -143 -16 -21 -44 -57 -62 -80 -51 -65 -155 -224
-237 -360 -26 -43 -73 -127 -160 -286 -82 -152 -244 -485 -311 -640 -26 -60 -68
-161 -76 -184 -4 -11 -11 -30 -16 -42 -8 -21 -21 -56 -61 -168 -10 -27 -34 -99
-53 -158 -19 -59 -36 -112 -38 -118 -2 -5 -6 -20 -10 -32 -3 -12 -14 -53 -24 -90
-27 -101 -59 -231 -78 -326 -15 -70 -48 -260 -60 -338 -50 -343 -62 -711 -32
-1028 10 -108 22 -209 30 -254 12 -65 39 -198 46 -232 4 -19 12 -56 18 -82 12
-55 16 -69 46 -175 11 -38 72 -221 86 -256 14 -37 36 -96 49 -127 5 -14 24 -57
40 -94 56 -126 184 -370 194 -370 7 0 -2 30 -33 102 -39 93 -68 173 -118 332 -10
33 -21 67 -24 76 -12 35 -34 119 -68 256 -25 104 -49 226 -74 376 -20 125 -23
156 -36 300 -11 120 -16 255 -16 400 0 314 25 562 88 879 7 33 15 73 18 90 20
104 73 321 115 461 32 110 105 326 145 430 5 13 15 39 22 58 7 19 18 46 25 60 12
26 12 26 13 138 0 97 2 118 9 160 27 144 62 233 136 348 44 67 121 150 207 222
36 30 36 30 118 174 78 136 124 212 202 336 117 185 143 228 156 267 5 14 5 21 0
55 -11 66 -16 86 -23 87 -5 1 -16 -8 -31 -24z M4548 11050 c-43 -28 -87 -50 -120
-59 -47 -14 -47 -14 -60 -34 -36 -51 -112 -312 -159 -541 -36 -173 -50 -258 -69
-422 -19 -166 -28 -402 -20 -546 11 -213 12 -221 40 -432 9 -70 54 -300 71 -366
50 -190 136 -430 215 -598 84 -179 112 -228 129 -228 10 0 29 16 29 24 0 3 -11
30 -24 61 -74 171 -142 387 -200 631 -15 62 -53 270 -64 348 -41 293 -46 648 -14
964 12 113 55 379 82 504 35 163 93 371 156 559 24 73 44 137 44 141 0 12 -11 10
-36 -6z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 908 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+296
View File
@@ -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;
}