Compare commits

..
11 Commits
Author SHA1 Message Date
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
bvandeusen 4f077736b6 Merge PR #127: Sonos queue verification, cast double-download fix, stutter instrumentation
android / Build + lint + test (push) Successful in 4m16s
release / Build signed APK (tag releases only) (push) Successful in 5m25s
release / Build + push container image (push) Successful in 1m16s
release / Verify release artifacts (tag releases only) (push) Successful in 1s
2026-08-18 10:50:10 -04:00
bvandeusen 0103953953 refactor(diagnostics): split the flap window and episode rule apart
android / Build + lint + test (push) Successful in 4m8s
detekt ReturnCount. Extracting the pruning and the is-this-an-episode
predicate reads better than suppressing it, and the cooldown rule now
has a name and a docstring of its own.
2026-08-18 10:00:43 -04:00
bvandeusen 72c0e96f92 fix(player): stop the local player during a cast; capture transport flap
android / Build + lint + test (push) Failing after 1m11s
Two changes for the Sonos stutter the operator describes as rapid
play-pause-play at the start of a track.

The measurable one: nothing in the diagnostics could see it.
player_state records source/loading/error but not whether we are
playing, track_change needs the queue index to move, and the heartbeat
samples once every 45s. A few seconds of oscillation that changes no
index fell through all three, which is why the symptom has been
described repeatedly and measured never. The poll loop now publishes
raw GetTransportInfo readings on change, and TransportFlapDetector
turns a burst of them into one summary event carrying the sequence
alongside local-vs-Sonos track and position -- enough to tell a cursor
disagreement from the renderer rebuffering. It samples at the 1Hz poll
cadence, so a faster oscillation lands aliased; that still answers
whether the renderer is leaving PLAYING, which is the open question.

The suspect one: during a cast the wrapped ExoPlayer was paused, not
stopped. pause() is only playWhenReady=false -- LoadControl keeps
loading, so the phone went on downloading the track the renderer was
streaming, over the same WiFi, re-arming at every track change via
syncLocalCursorToRemote's seekTo. At FLAC bitrates that is a second
full-rate download competing with the speaker, beginning exactly when
a new track does. stop() ends it; Media3 keeps media items, index and
position, and getPlaybackState() already reports STATE_READY while
remote, so cursor sync and handoff are unaffected. The route teardown
re-prepares for local playback.

Whether that download is the cause is unproven -- hence the
instrument landing alongside it rather than after it.
2026-08-18 09:55:41 -04:00
bvandeusen e87516bbe4 refactor(player): split Sonos queue loading out of the picker — #2728
android / Build + lint + test (push) Successful in 3m48s
detekt flagged OutputPickerController as LargeClass once the verify
path landed. Extracting rather than suppressing: how the renderer's
queue is shaped is a different concern from which route is selected,
and it had grown big enough to hide a bug — every write in here is a
SOAP call that can fail on its own, and nothing ever read the result
back.

SonosQueueLoader now owns load / extend / verify / append and the
incremental diff. The picker keeps route selection and asks it for
queue work. No behaviour change.
2026-08-17 22:35:53 -04:00
bvandeusen 8e21bce103 fix(player): verify the Sonos queue actually landed — #2728
android / Build + lint + test (push) Failing after 1m18s
The renderer's queue was written and never read back. loadQueueOnSonos
background-appends the tail one AddURIToQueue at a time and gives up
after 3 consecutive failures; Sonos rate-limits burst adds, so that
happens. The renderer was then left holding fewer tracks than we
believed, played what it actually had, and stopped — which looked
exactly like playback dying for no reason.

GetMediaInfo's NrTracks is the cheap authoritative answer and was not
being asked for anywhere in the app. Now:

- verifyQueueLength after every load (including when there is no tail
  to append — the initial batch can be dropped the same way), appending
  what the renderer is missing, bounded at 2 passes.
- RemoteStallWatchdog gains QueueState, so a stop is classified rather
  than assumed: a stream that died resumes, a truncated queue gets
  repaired at the next track, and a queue that simply ended does
  nothing at all.

That last case was a bug shipped in #2700: the normal end of a queue is
a confirmed STOPPED with play intent, so every cast session would have
ended with three resume attempts and a `stalled` error for playback
that finished perfectly. No test described the end of a queue, so CI
had nothing to catch it with.

Queue reads are gated on the transport being stopped and cached for 5s,
so this never becomes a third SOAP call per second.
2026-08-17 22:29:54 -04:00
79 changed files with 3236 additions and 419 deletions
-1
View File
@@ -150,7 +150,6 @@ dependencies {
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.material3)
implementation(libs.compose.ui.text.google.fonts)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.tooling.preview)
@@ -18,6 +18,7 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.TransportObservation
import com.fabledsword.minstrel.player.output.OutputPickerController
import com.fabledsword.minstrel.player.output.OutputRoute
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -103,6 +104,7 @@ class DiagnosticsReporter @Inject constructor(
launch { collectUpnpDrops() }
launch { collectPlayerState() }
launch { collectTrackChanges() }
launch { collectTransportFlap() }
launch { collectRoutes() }
launch { heartbeatLoop() }
}
@@ -191,6 +193,67 @@ class DiagnosticsReporter @Inject constructor(
}
}
/**
* Catch the renderer rapidly leaving and re-entering PLAYING.
*
* The operator reports the Sonos "play pause play pause, like someone
* pressing it every half second", usually as a track starts, cleared by a
* manual pause or skip. Nothing here could see that: `player_state`
* carries source/loading/error but not playing, `track_change` needs the
* index to move, and the heartbeat samples once per 45s. The symptom fell
* through every existing collector, which is why it has only ever been
* described and never measured.
*
* Records every raw transport change (cheap — steady playback produces
* a couple per track) and, when they come in a burst, one summary event
* carrying the whole sequence. The summary is the useful artefact: it
* pairs the renderer's states with local-vs-Sonos track and position, so
* an episode says whether the app and the renderer disagreed about which
* track was playing, or agreed while the renderer rebuffered.
*
* See [TransportObservation] on the 1 Hz sampling limit.
*/
private suspend fun collectTransportFlap() {
val detector = TransportFlapDetector()
playerController.transportEvents.collect { obs ->
record("upnp_sync", buildJsonObject {
put("event", "transport")
put("state", obs.state)
put("status_ok", obs.statusOk)
put("sonos_track", obs.trackNumber)
put("sonos_pos_ms", obs.positionMs)
put("play_intent", obs.playIntent)
})
detector.onChange(obs)?.let { recordFlapSummary(it) }
}
}
private suspend fun recordFlapSummary(recent: List<TransportObservation>) {
val ui = playerController.uiState.value
val casting = outputPicker.routesState.value.current.protocol !=
OutputRoute.Protocol.SYSTEM
val spanMs = recent.last().atElapsedMs - recent.first().atElapsedMs
record("upnp_sync", buildJsonObject {
put("event", "transport_flap")
put("changes", recent.size)
put("window_ms", spanMs)
// The sequence itself, e.g. "PLAYING>TRANSITIONING>STOPPED>PLAYING".
// Whether STOPPED appears at all is the first question to ask of a
// captured episode.
put("sequence", recent.joinToString(">") { it.state })
put("sonos_positions_ms", recent.joinToString(",") { it.positionMs.toString() })
put("sonos_tracks", recent.joinToString(",") { it.trackNumber.toString() })
put("local_index", ui.queueIndex)
put("local_track_id", ui.currentTrack?.id ?: "")
put("local_pos_ms", ui.positionMs)
putSonos(this, casting)
put("upnp_loading", ui.isUpnpLoading)
put("server_health", networkStatus.state.value.name)
put("route", outputPicker.routesState.value.current.name)
addPowerFields(this)
})
}
private suspend fun collectRoutes() {
// 'playback' — route changes happen for all outputs. This only ever
// logs the ACTIVE route (routesState.current), so no "connected" flag.
@@ -0,0 +1,75 @@
package com.fabledsword.minstrel.diagnostics
import com.fabledsword.minstrel.player.TransportObservation
/**
* Decides when a run of renderer transport changes is a *flap* — the renderer
* repeatedly failing to settle — rather than an ordinary track transition.
*
* The operator reports the Sonos "play pause play pause, like someone pressing
* it every half second", usually as a track starts. No diagnostic event could
* see it, so it has been described several times and measured never. This is
* the rule that decides when an episode is worth writing down.
*
* Pure decision state, like [com.fabledsword.minstrel.player.RemoteStallWatchdog]:
* the caller owns the flow and the recording, this only answers "is this an
* episode, and which readings make it up". Keeps the windowing and the
* one-episode-one-summary rule testable without a renderer or a clock.
*/
class TransportFlapDetector(
private val windowMs: Long = FLAP_WINDOW_MS,
private val minChanges: Int = FLAP_MIN_CHANGES,
private val summaryCooldownMs: Long = FLAP_SUMMARY_COOLDOWN_MS,
) {
private val recent = ArrayDeque<TransportObservation>()
private var lastSummaryAtMs: Long? = null
/**
* Feed one transport change. Returns the readings making up an episode
* worth recording, or null when there is nothing to say.
*
* The returned list is a copy: the caller may hold it while more readings
* arrive.
*/
fun onChange(observation: TransportObservation): List<TransportObservation>? {
recent.addLast(observation)
dropReadingsOlderThan(observation.atElapsedMs)
if (!isEpisode(observation.atElapsedMs)) return null
lastSummaryAtMs = observation.atElapsedMs
return recent.toList()
}
private fun dropReadingsOlderThan(nowMs: Long) {
while (recent.isNotEmpty() && nowMs - recent.first().atElapsedMs > windowMs) {
recent.removeFirst()
}
}
/**
* Enough changes packed together, and far enough from the last thing we
* wrote down. The cooldown is what keeps one episode to one summary: a
* sustained fault produces a change every poll, and a summary per reading
* would bury the per-change events underneath them.
*/
private fun isEpisode(nowMs: Long): Boolean {
val since = lastSummaryAtMs
val cooled = since == null || nowMs - since >= summaryCooldownMs
return recent.size >= minChanges && cooled
}
/** Forget everything — call when the route changes or casting ends. */
fun reset() {
recent.clear()
lastSummaryAtMs = null
}
companion object {
// Readings arrive at the 1 Hz poll cadence, and a normal track
// transition is 2-3 changes (PLAYING -> TRANSITIONING -> PLAYING).
// Four inside six seconds is not a track change, and it is not a
// person at the Sonos app either; it is the renderer not settling.
const val FLAP_WINDOW_MS = 6_000L
const val FLAP_MIN_CHANGES = 4
const val FLAP_SUMMARY_COOLDOWN_MS = 60_000L
}
}
@@ -14,6 +14,7 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.player.output.ActiveUpnp
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.upnp.PositionInfo
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportInfo
import com.fabledsword.minstrel.player.output.upnp.TransportState
@@ -48,12 +49,12 @@ import timber.log.Timber
*
* Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route
* liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold
* (DROP_THRESHOLD consecutive failures) fires [onDrop]. A failed transport
* (DROP_THRESHOLD consecutive failures) fires [RemoteEvents.onDrop]. A failed transport
* command (play/pause/seek/next) does NOT drop on its own: a locked phone's
* WiFi power-save can stall a single command's socket I/O for a second or
* two while the renderer is perfectly reachable, so commands retry on
* transient IO failure and otherwise defer to the poll loop. The factory
* wraps the [onDrop] callback into a SharedFlow consumed by the NowPlaying
* wraps that callback into a SharedFlow consumed by the NowPlaying
* surface as a snackbar.
*
* Queue mode: OutputPickerController loads the full queue into Sonos's
@@ -71,10 +72,29 @@ class MinstrelForwardingPlayer(
private val remoteState: RemotePlayerState,
private val castNetworkLock: CastNetworkLock,
private val networkStatus: NetworkStatusController,
private val onDrop: (routeName: String) -> Unit,
private val onStalled: (trackId: String) -> Unit = {},
private val events: RemoteEvents = RemoteEvents(),
) : ForwardingPlayer(delegate) {
/**
* The ways remote playback reports trouble outward. Grouped rather than
* passed as three more constructor lambdas: they share a lifetime, they
* all end up as flows on [PlayerFactory], and the list grows every time
* the renderer finds a new way to disappoint us.
*/
data class RemoteEvents(
/** A route stopped answering and playback fell back to the phone. */
val onDrop: (routeName: String) -> Unit = {},
/** A track could not be got playing again; surfaces to the user. */
val onStalled: (trackId: String) -> Unit = {},
/** The renderer's queue is short of ours and needs rebuilding. */
val onQueueTruncated: () -> Unit = {},
/**
* A raw poll reading, emitted only when it differs from the previous
* one. Diagnostics-only; see [TransportObservation].
*/
val onTransport: (TransportObservation) -> Unit = {},
)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val handler = Handler(delegate.applicationLooper)
private var pollJob: Job? = null
@@ -97,6 +117,16 @@ class MinstrelForwardingPlayer(
// visibly jumps backwards immediately after a drag, then forwards again.
@Volatile private var lastSeekIssuedAtMs: Long = 0L
// Last-read renderer queue length + when we read it. See [queueStateFor]:
// a stopped renderer is polled once a second and its queue does not change
// by itself, so re-asking every tick is pure round-trips.
@Volatile private var cachedNrTracks: Int = 0
@Volatile private var lastMediaInfoAtMs: Long = 0L
// Previous raw transport reading, so [TransportObservation]s are emitted
// on change rather than once a second forever. Null until the first poll.
@Volatile private var lastObservedTransport: Pair<TransportState, Boolean>? = null
// Wake channel for the poll loop. requestImmediatePoll() trySend's a Unit;
// pollLoop's select{} races the delay against this channel so the next
// pollOnce can fire immediately instead of waiting up to POLL_INTERVAL_MS.
@@ -488,17 +518,35 @@ class MinstrelForwardingPlayer(
// without this the radio power-saves on a locked screen and the
// poll below starves -- see [CastNetworkLock].
castNetworkLock.acquire()
// Pause the wrapped ExoPlayer so we are not playing local audio
// simultaneously with the remote renderer. handler.post targets the
// application looper, so this runs on the same thread that processes
// our override calls -- no race with the pause() override branching
// to SOAP (holder.active is already non-null by the time this post
// fires, but delegate.pause() bypasses the override entirely).
handler.post { delegate.pause() }
// STOP the wrapped ExoPlayer -- not pause. pause() is only
// playWhenReady=false: ExoPlayer's LoadControl keeps loading, so a
// paused-but-prepared player goes on downloading the current track
// (~50s of buffer). During a cast that means the phone pulls the
// same file the renderer is streaming, over the same WiFi, and
// re-arms on every track change via syncLocalCursorToRemote's
// seekTo. At FLAC bitrates that is a second full-rate download
// competing with the speaker for air, starting exactly when a new
// track does. stop() ends the loading; Media3 keeps the media
// items, the current index and the position, so cursor sync and
// the handoff back are unaffected, and getPlaybackState() already
// reports STATE_READY while remote so no external reader sees IDLE.
//
// handler.post targets the application looper, so this runs on the
// same thread that processes our override calls -- no race with the
// pause() override branching to SOAP (holder.active is already
// non-null by the time this post fires, but delegate.stop()
// bypasses the override entirely).
handler.post { delegate.stop() }
pollJob = scope.launch { pollLoop(active) }
} else {
castNetworkLock.release()
remoteState.reset()
lastObservedTransport = null
// The delegate was stopped for the cast, so it is IDLE and would
// ignore a play(). Re-prepare it for local playback. Safe when the
// queue is empty, and it does not start playback on its own --
// playWhenReady is still false until something calls play().
handler.post { delegate.prepare() }
// The next cast starts with a clean attempt budget; a stall on the
// route we just left says nothing about the next one.
stallWatchdog.reset()
@@ -516,7 +564,7 @@ class MinstrelForwardingPlayer(
} else if (remoteState.recordPollFailure()) {
if (networkStatus.state.value == ServerHealth.Healthy) {
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
handler.post { onDrop(active.routeName) }
handler.post { events.onDrop(active.routeName) }
return
}
networkDropSuppressed = suppressDropForNetwork(active, networkDropSuppressed)
@@ -594,6 +642,7 @@ class MinstrelForwardingPlayer(
}
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
}
observeTransport(transport, info)
checkForStall(active, info.trackUri, transport)
notifyRemoteStateChanged()
}
@@ -613,12 +662,15 @@ class MinstrelForwardingPlayer(
transport: TransportInfo,
) {
val decision = stallWatchdog.onPoll(
RemoteStallWatchdog.Poll(
trackUri = trackUri,
state = transport.state,
statusOk = transport.statusOk,
playIntent = remoteState.lastPlayIntent,
positionMs = remoteState.positionMs,
nowMs = SystemClock.elapsedRealtime(),
queue = queueStateFor(active, transport),
),
)
when (decision) {
is RemoteStallWatchdog.Decision.Resume -> {
@@ -638,6 +690,17 @@ class MinstrelForwardingPlayer(
Timber.w(it, "UPnP stall: resume attempt failed on %s", active.routeName)
}
}
is RemoteStallWatchdog.Decision.RepairQueue -> {
Timber.w(
"UPnP queue truncated on %s: renderer ended at its last track " +
"while %d local tracks remain; repair attempt %d",
active.routeName, delegate.mediaItemCount, decision.attempt,
)
// The renderer isn't broken -- it played everything it was
// given. Rebuilding the queue is the fix; the controller owns
// queue loading, so ask it rather than duplicating that here.
handler.post { events.onQueueTruncated() }
}
RemoteStallWatchdog.Decision.GiveUp -> {
Timber.w(
"UPnP stall on %s: giving up after repeated resume attempts",
@@ -645,12 +708,80 @@ class MinstrelForwardingPlayer(
)
// Tell the user and the admin inbox. Silence here would be the
// original bug: playback simply ends and nobody finds out.
trackIdFromStreamUri(trackUri)?.let { handler.post { onStalled(it) } }
trackIdFromStreamUri(trackUri)?.let { handler.post { events.onStalled(it) } }
}
RemoteStallWatchdog.Decision.None -> Unit
}
}
/**
* Publish this poll's raw transport reading if it differs from the last.
*
* Change-gated on purpose: steady playback is one reading repeated once a
* second, which is worth nothing and would fill the ring buffer. What is
* worth capturing is the renderer LEAVING a state — which during normal
* playback happens a couple of times per track, and during the fault the
* operator describes should happen repeatedly within a few seconds.
*/
private fun observeTransport(transport: TransportInfo, info: PositionInfo) {
val key = transport.state to transport.statusOk
if (key == lastObservedTransport) return
lastObservedTransport = key
events.onTransport(
TransportObservation(
state = transport.state.name,
statusOk = transport.statusOk,
trackNumber = info.track,
positionMs = info.relTimeMs,
playIntent = remoteState.lastPlayIntent,
atElapsedMs = SystemClock.elapsedRealtime(),
),
)
}
/**
* How the renderer's queue compares to ours, for [RemoteStallWatchdog].
*
* Only asked when the transport is actually stopped or reporting an error:
* while it plays, the answer changes nothing and GetMediaInfo would be a
* third SOAP round-trip every second. Even then the result is cached for
* [MEDIA_INFO_TTL_MS], because a stopped renderer gets polled once a
* second and its queue length does not change on its own.
*
* A renderer that reports NrTracks=0 is telling us nothing usable (some
* don't implement it) -- that reads as UNKNOWN, never as "empty queue",
* so an unhelpful renderer keeps the old resume-and-seek behaviour rather
* than being told its queue is broken.
*/
@Suppress("ReturnCount") // one early return per verdict reads better than nesting
private suspend fun queueStateFor(
active: ActiveUpnp,
transport: TransportInfo,
): RemoteStallWatchdog.QueueState {
val stalled = transport.state == TransportState.STOPPED || !transport.statusOk
if (!stalled) return RemoteStallWatchdog.QueueState.UNKNOWN
val now = SystemClock.elapsedRealtime()
if (now - lastMediaInfoAtMs > MEDIA_INFO_TTL_MS) {
lastMediaInfoAtMs = now
cachedNrTracks = runCatching { active.avTransport.getMediaInfo().nrTracks }
.onFailure { Timber.w(it, "UPnP GetMediaInfo failed on %s", active.routeName) }
.getOrDefault(0)
}
val nrTracks = cachedNrTracks
val rendererTrack = remoteState.trackNumber
if (nrTracks <= 0 || rendererTrack <= 0) return RemoteStallWatchdog.QueueState.UNKNOWN
if (rendererTrack < nrTracks) return RemoteStallWatchdog.QueueState.HAS_MORE
// On its last track. Whether that is a problem depends entirely on
// whether we have tracks it never received.
return if (delegate.mediaItemCount > nrTracks) {
RemoteStallWatchdog.QueueState.TRUNCATED
} else {
RemoteStallWatchdog.QueueState.COMPLETE
}
}
/**
* Align the paused local delegate cursor to the track the renderer is
* actually playing, so the un-overridden current-item getters
@@ -743,6 +874,10 @@ class MinstrelForwardingPlayer(
const val POLL_INTERVAL_MS = 1_000L
const val NON_PLAYING_CONFIRM = 2
const val SEEK_ACK_WINDOW_MS = 2_000L
// How long a GetMediaInfo queue-length reading stays good for. The
// watchdog needs three agreeing polls (~3s) before it acts, so one
// read comfortably covers a decision without asking every tick.
const val MEDIA_INFO_TTL_MS = 5_000L
// Safety upper bound on how long the polling tick will wait for
// Sonos to ack a user transport. The common case clears event-driven
// when Sonos's reported Track matches the wrapped player; this only
@@ -77,6 +77,13 @@ class PlayerController @Inject constructor(
* during UPnP playback shows "Disconnected from <name>" to the user.
*/
val dropEvents: SharedFlow<String> = playerFactory.dropEvents
/**
* Raw UPnP transport readings from [PlayerFactory.transportEvents], for
* the diagnostics reporter. Read-only tap — nothing in the playback path
* consumes it.
*/
val transportEvents: SharedFlow<TransportObservation> = playerFactory.transportEvents
private val sessionToken =
SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java))
@@ -84,6 +84,29 @@ class PlayerFactory @Inject constructor(
)
val stallEvents: SharedFlow<String> = stallEventsInternal.asSharedFlow()
// Fires when the renderer is found to have reached the end of a queue
// shorter than ours -- i.e. part of the queue load never landed. The
// controller owns queue loading, so it collects this and rebuilds.
// Same one-is-enough buffering: repeated notices are the same problem.
private val queueRepairInternal = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val queueRepairEvents: SharedFlow<Unit> = queueRepairInternal.asSharedFlow()
// Raw renderer transport readings, change-gated. Unlike the flows above
// this one carries a SEQUENCE — the diagnostics flap detector needs
// several readings in a row to tell oscillation from a normal track
// transition — so it buffers more than one and drops oldest under
// pressure rather than collapsing to the latest.
private val transportInternal = MutableSharedFlow<TransportObservation>(
replay = 0,
extraBufferCapacity = TRANSPORT_EVENT_BUFFER,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val transportEvents: SharedFlow<TransportObservation> = transportInternal.asSharedFlow()
fun build(): Player {
val exo = buildExoPlayer()
return MinstrelForwardingPlayer(
@@ -92,8 +115,12 @@ class PlayerFactory @Inject constructor(
remoteState = remoteState,
castNetworkLock = CastNetworkLock(context),
networkStatus = serverHealth,
events = MinstrelForwardingPlayer.RemoteEvents(
onDrop = { name -> emitDrop(name) },
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
onQueueTruncated = { queueRepairInternal.tryEmit(Unit) },
onTransport = { transportInternal.tryEmit(it) },
),
)
}
@@ -146,6 +173,12 @@ class PlayerFactory @Inject constructor(
.build(),
)
private companion object {
// Enough readings to hold a whole flap episode plus the normal
// transitions around it; the detector's window is only a few seconds.
const val TRANSPORT_EVENT_BUFFER = 32
}
private fun emitDrop(routeName: String) {
dropEventsInternal.tryEmit(routeName)
}
@@ -29,6 +29,23 @@ import com.fabledsword.minstrel.player.output.upnp.TransportState
* [RETRY_SPACING_MS]. A genuinely unplayable file must not become an
* infinite retry loop against the renderer.
*
* A stop is not always a fault, and not always the same fault. Three
* different things arrive here looking identical — the transport says
* STOPPED and we wanted to be playing:
*
* 1. The stream died mid-track. Re-play and seek back. ([Decision.Resume])
* 2. The renderer reached the end of a queue *shorter than ours*, because
* part of the load never landed. Nothing is broken; it is playing
* exactly what it was given. Repairing the queue is the fix, and
* re-playing the finished track is not. ([Decision.RepairQueue])
* 3. The renderer reached the end of the queue and so did we. Playback is
* simply over. ([Decision.None])
*
* Case 3 matters as much as the others: without [QueueState] every cast
* session would end with the watchdog retrying the last track three times
* and then reporting a `stalled` error for a listening session that
* finished perfectly normally.
*
* Pure decision state, no coroutines and no SOAP: the caller owns the poll
* loop and performs the transport calls, this only says what should happen.
* That keeps the awkward part — counting, keying and giving up — testable
@@ -36,6 +53,46 @@ import com.fabledsword.minstrel.player.output.upnp.TransportState
*/
class RemoteStallWatchdog {
/**
* What the renderer's queue looks like relative to ours, as of this poll.
* The caller derives it from GetMediaInfo's NrTracks against the local
* queue; it only needs to be accurate when the transport is not playing.
*/
enum class QueueState {
/**
* The renderer didn't report a usable count, or it is playing and the
* question is moot. Treated as "assume a real stall" — the old
* behaviour, which is right when we know nothing.
*/
UNKNOWN,
/** The renderer still has tracks after the current one. */
HAS_MORE,
/**
* The renderer is on its last track but our queue has tracks it never
* received — the load was truncated.
*/
TRUNCATED,
/** Renderer is on its last track and so are we: playback is over. */
COMPLETE,
}
/**
* One poll's worth of observation. Grouped into a type rather than passed
* as a long parameter list so adding a fact doesn't reshuffle call sites.
*/
data class Poll(
val trackUri: String,
val state: TransportState,
val statusOk: Boolean,
val playIntent: Boolean,
val positionMs: Long,
val nowMs: Long,
val queue: QueueState = QueueState.UNKNOWN,
)
sealed interface Decision {
/** Nothing to do. */
data object None : Decision
@@ -47,6 +104,14 @@ class RemoteStallWatchdog {
*/
data class Resume(val attempt: Int, val resumeAtMs: Long) : Decision
/**
* The renderer ran off the end of a queue we failed to fully load.
* The caller should append the tail it never got and resume at the
* next track — re-playing the current one would just replay a track
* the listener already heard.
*/
data class RepairQueue(val attempt: Int) : Decision
/** Attempts are exhausted. Report it and stop trying for this track. */
data object GiveUp : Decision
}
@@ -59,36 +124,22 @@ class RemoteStallWatchdog {
private var gaveUp: Boolean = false
/**
* Feed one poll result in, get the action out.
*
* @param trackUri the renderer's current track URI — identity for the
* per-track attempt budget, so moving to the next track forgives a
* previous one's failures.
* @param statusOk the transport's own status flag: false means the
* renderer is reporting an error rather than merely being stopped.
* @param playIntent the operator's last play/pause intent.
* @param nowMs a monotonic clock (SystemClock.elapsedRealtime), passed in
* so tests can drive time.
* Feed one poll result in, get the action out. See [Poll] for the inputs;
* `nowMs` is a monotonic clock (SystemClock.elapsedRealtime), passed in so
* tests can drive time.
*/
@Suppress("ReturnCount") // early returns per state are clearer than nesting
fun onPoll(
trackUri: String,
state: TransportState,
statusOk: Boolean,
playIntent: Boolean,
positionMs: Long,
nowMs: Long,
): Decision {
if (trackUri != trackKey) {
fun onPoll(poll: Poll): Decision {
if (poll.trackUri != trackKey) {
// New track: a fresh attempt budget, and no inherited stall state.
trackKey = trackUri
trackKey = poll.trackUri
resetStall()
attempts = 0
gaveUp = false
lastPlayingPositionMs = 0L
}
if (!playIntent) {
if (!poll.playIntent) {
// Stopped because we asked. Not a stall, and the next genuine one
// should start from a clean budget.
resetStall()
@@ -97,8 +148,8 @@ class RemoteStallWatchdog {
return Decision.None
}
if (state == TransportState.PLAYING && statusOk) {
lastPlayingPositionMs = positionMs
if (poll.state == TransportState.PLAYING && poll.statusOk) {
lastPlayingPositionMs = poll.positionMs
resetStall()
// A track that recovered and is playing again has earned back its
// budget; a later, unrelated stall on the same track should get
@@ -107,7 +158,7 @@ class RemoteStallWatchdog {
return Decision.None
}
val stalled = state == TransportState.STOPPED || !statusOk
val stalled = poll.state == TransportState.STOPPED || !poll.statusOk
if (!stalled) {
// PAUSED (someone else's doing) or TRANSITIONING/UNKNOWN (in
// flight). Neither is a stall; drop the streak so a mid-track
@@ -116,6 +167,14 @@ class RemoteStallWatchdog {
return Decision.None
}
// The queue simply ended. Not a fault, so it must not consume the
// attempt budget or raise an error — the listener heard everything
// they queued.
if (poll.queue == QueueState.COMPLETE) {
resetStall()
return Decision.None
}
stoppedStreak += 1
if (stoppedStreak < STALL_CONFIRM_POLLS) return Decision.None
if (gaveUp) return Decision.None
@@ -124,11 +183,15 @@ class RemoteStallWatchdog {
gaveUp = true
return Decision.GiveUp
}
if (attempts > 0 && nowMs - lastAttemptAtMs < RETRY_SPACING_MS) return Decision.None
if (attempts > 0 && poll.nowMs - lastAttemptAtMs < RETRY_SPACING_MS) return Decision.None
attempts += 1
lastAttemptAtMs = nowMs
return Decision.Resume(attempt = attempts, resumeAtMs = lastPlayingPositionMs)
lastAttemptAtMs = poll.nowMs
return if (poll.queue == QueueState.TRUNCATED) {
Decision.RepairQueue(attempt = attempts)
} else {
Decision.Resume(attempt = attempts, resumeAtMs = lastPlayingPositionMs)
}
}
/** Forget everything — call when the route changes or playback is torn down. */
@@ -0,0 +1,38 @@
package com.fabledsword.minstrel.player
/**
* One reading of what a UPnP renderer says it is doing, taken by the poll
* loop and emitted only when it differs from the previous reading.
*
* Exists for diagnostics. The operator reports the Sonos rapidly
* play-pause-play-pausing at the start of a track, and nothing in the
* diagnostics could see it: `player_state` records source / loading / error
* but not whether we are playing, `track_change` needs the queue index to
* move, and the heartbeat samples once every 45 seconds. A symptom that
* lasts a few seconds and changes no index fell straight through all three.
*
* This is the closest observation point we have to the renderer's own truth
* — the raw GetTransportInfo reading, before the two-poll confirmation and
* the UI's smoothing have had a chance to hide the wobble.
*
* **It samples at the poll cadence (1 Hz).** If the real oscillation is
* faster than that, what lands here is an aliased jagged sequence rather
* than the true waveform. That still answers the question that matters —
* whether the renderer is steadily PLAYING or repeatedly leaving that state
* — but it cannot measure the true period. If a captured episode comes back
* looking clean, the next instrument is burst sampling, not this one.
*/
data class TransportObservation(
/** [com.fabledsword.minstrel.player.output.upnp.TransportState] name. */
val state: String,
/** CurrentTransportStatus: false means the renderer reports an error. */
val statusOk: Boolean,
/** The renderer's 1-based queue position at this reading. */
val trackNumber: Int,
/** The renderer's reported position within the track. */
val positionMs: Long,
/** Whether the operator's last intent was to be playing. */
val playIntent: Boolean,
/** Monotonic stamp, so a consumer can measure gaps between readings. */
val atElapsedMs: Long,
)
@@ -10,11 +10,9 @@ import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerFactory
import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.StreamTokenProvider
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
import com.fabledsword.minstrel.player.output.upnp.SoapClient
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportState
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
import com.fabledsword.minstrel.player.output.upnp.bareUdn
@@ -61,7 +59,7 @@ data class RouteSnapshot(
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
* wired, Bluetooth)
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
* [StreamTokenProvider.mint], drive the discovered renderer with
* [SonosQueueLoader], drive the discovered renderer with
* AVTransport.SetAVTransportURI + Play, pause local playback so
* audio yields to the network speaker
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
@@ -79,7 +77,7 @@ class OutputPickerController @Inject constructor(
private val upnpDiscovery: UpnpDiscoveryController,
private val playerController: PlayerController,
private val playerFactory: PlayerFactory,
private val streamTokens: StreamTokenProvider,
private val sonosQueue: SonosQueueLoader,
private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val okHttp: OkHttpClient,
@@ -173,6 +171,7 @@ class OutputPickerController @Inject constructor(
playerFactory.dropEvents.collect { handleRemoteDrop() }
}
scope.launch { observeQueueChangesForSonosResync() }
scope.launch { observeQueueRepairRequests() }
scope.launch { observeIdleRevertWhileUpnp() }
scope.launch { observeSelectedRouteDisappearance() }
}
@@ -255,7 +254,7 @@ class OutputPickerController @Inject constructor(
* setMediaItems override clears holder.active + sets target so the
* imminent play() call drops (drops via isLoadingUpnp() = true). Then
* this collector observes the uiState.queue change and re-runs
* loadQueueOnSonos to push the new tracks to Sonos.
* SonosQueueLoader.load to push the new tracks to Sonos.
*
* Discrimination: selectUpnp's initial-load path doesn't change
* uiState.queue (the queue was already populated before route
@@ -286,6 +285,71 @@ class OutputPickerController @Inject constructor(
}
}
/**
* Rebuild the renderer's queue when playback stopped because the renderer
* ran off the end of a queue shorter than ours.
*
* [SonosQueueLoader] tolerates individual AddURIToQueue failures and
* gives up appending after a few consecutive ones
* -- Sonos rate-limits burst adds. Until this existed that left a short
* queue on the renderer and nothing to notice it: the renderer played what
* it had and stopped, and the app went on believing there were forty
* tracks left. [MinstrelForwardingPlayer] now compares GetMediaInfo's
* NrTracks against the local queue and asks for this.
*
* A full reload, not an incremental diff: the renderer's copy is known to
* be wrong, and the diff path reasons from what we *think* it holds, which
* is exactly the assumption that failed. The load re-seeks to the
* current track and plays, so recovery lands where the listener was.
*/
private suspend fun observeQueueRepairRequests() {
playerFactory.queueRepairEvents.collect {
val routeId = selectedUpnpRouteIdInternal.value
?: activeUpnpHolder.active.value?.routeId
if (routeId == null) {
Timber.w("Sonos queue repair skipped: no UPnP route selected")
return@collect
}
val state = playerController.uiState.value
if (state.queue.isEmpty()) {
Timber.w("Sonos queue repair skipped: local queue is empty")
return@collect
}
// Resume on the track AFTER the current one. The renderer stopped
// because it finished the last track it had; the local cursor is
// synced to that track, so reloading at it would replay something
// the listener just heard. The next one is what they never got.
val resumeAt = (state.queueIndex + 1).coerceAtMost(state.queue.size - 1)
repairSonosQueue(routeId, state.queue, resumeAt)
}
}
private suspend fun repairSonosQueue(
routeId: String,
queue: List<TrackRef>,
currentIndex: Int,
) = selectUpnpMutex.withLock {
val upnpRoute = upnpDiscovery.routes.value.firstOrNull { it.id == routeId }
val transport = upnpDiscovery.transportFor(routeId)
if (upnpRoute == null || transport == null) {
Timber.w("Sonos queue repair: route or transport gone for %s", routeId)
return@withLock
}
val outputRoute = OutputRoute.fromUpnpRoute(upnpRoute)
Timber.w(
"Sonos queue repair: reloading %d tracks on %s (resuming at index %d)",
queue.size, outputRoute.name, currentIndex,
)
runCatching {
sonosQueue.load(transport, outputRoute, queue, currentIndex)
}.onFailure { e ->
// Leave the route active: the renderer is reachable enough to have
// told us its queue length, so dropping to local would be a harsher
// remedy than letting the next stall re-decide.
Timber.w(e, "Sonos queue repair failed on %s", outputRoute.name)
}
}
/**
* Bring Sonos's native queue back in sync with the local queue after a
* mutation. Tries an incremental SOAP diff first (RemoveTrackRangeFromQueue
@@ -313,7 +377,7 @@ class OutputPickerController @Inject constructor(
return@withLock
}
val handledIncrementally = runCatching {
tryIncrementalResync(transport, oldIds, newQueue)
sonosQueue.tryIncrementalResync(transport, oldIds, newQueue)
}.getOrElse { e ->
Timber.w(e, "Sonos incremental resync errored; falling back to full reload")
false
@@ -337,7 +401,7 @@ class OutputPickerController @Inject constructor(
val rendering = renderingClientFor(routeId)
Timber.w("Sonos resync: full reload of %d tracks on %s", newQueue.size, outputRoute.name)
runCatching {
loadQueueOnSonos(transport, outputRoute, newQueue, newCurrentIndex)
sonosQueue.load(transport, outputRoute, newQueue, newCurrentIndex)
activeUpnpHolder.set(
ActiveUpnp(
routeId = routeId,
@@ -354,98 +418,6 @@ class OutputPickerController @Inject constructor(
}
}
/**
* Diff-based incremental Sonos queue sync. Returns true when the new
* queue can be produced from the old one with a remove-then-insert at
* the same middle slice -- the common-prefix and common-suffix portions
* stay untouched, and the current Sonos track must lie in the preserved
* prefix (otherwise the diff would orphan playback). Returns false to
* signal the caller to fall back to a full reload.
*/
private suspend fun tryIncrementalResync(
transport: AVTransportClient,
oldIds: List<String>,
newQueue: List<TrackRef>,
): Boolean {
val newIds = newQueue.map { it.id }
if (oldIds == newIds) return true
val prefixLen = commonPrefixLength(oldIds, newIds)
val suffixLen = commonSuffixLength(
oldIds.subList(prefixLen, oldIds.size),
newIds.subList(prefixLen, newIds.size),
)
val removedCount = oldIds.size - prefixLen - suffixLen
val addedCount = newIds.size - prefixLen - suffixLen
// Sonos's current track number is 1-based; compare against the
// preserved-prefix range as 0-based. If the current track is in
// the removed slice, incremental can't preserve playback -- caller
// falls back to full rebuild.
val currentSonosIdx0 = remoteState.trackNumber - 1
val canApply = currentSonosIdx0 in 0 until prefixLen
if (canApply) {
applyQueueDiff(transport, newQueue, prefixLen, removedCount, addedCount)
} else {
Timber.w(
"Sonos incremental: current track %d not in preserved prefix [0,%d); full rebuild",
currentSonosIdx0,
prefixLen,
)
}
return canApply
}
private suspend fun applyQueueDiff(
transport: AVTransportClient,
newQueue: List<TrackRef>,
prefixLen: Int,
removedCount: Int,
addedCount: Int,
) {
if (removedCount > 0) {
Timber.w(
"Sonos incremental: RemoveTrackRangeFromQueue start=%d count=%d",
prefixLen + 1,
removedCount,
)
transport.removeTrackRangeFromQueue(
startingIndex = prefixLen + 1,
numberOfTracks = removedCount,
)
}
if (addedCount == 0) return
Timber.w(
"Sonos incremental: AddURIToQueue x%d starting at position %d",
addedCount,
prefixLen + 1,
)
for (i in 0 until addedCount) {
val ref = newQueue[prefixLen + i]
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = prefixLen + i + 1,
)
if (i > 0) delay(EXTEND_THROTTLE_MS)
}
}
private fun commonPrefixLength(a: List<String>, b: List<String>): Int {
val limit = minOf(a.size, b.size)
for (i in 0 until limit) {
if (a[i] != b[i]) return i
}
return limit
}
private fun commonSuffixLength(a: List<String>, b: List<String>): Int {
val limit = minOf(a.size, b.size)
for (i in 0 until limit) {
if (a[a.size - 1 - i] != b[b.size - 1 - i]) return i
}
return limit
}
/**
* Called when the active UPnP route drops unexpectedly (the poll loop's
@@ -539,7 +511,7 @@ class OutputPickerController @Inject constructor(
* 1. Pause local so the user doesn't keep hearing local audio.
* 2. Set target early so ForwardingPlayer drops transport taps
* while the 17-second queue load is in progress.
* 3. Wire active LAST (after loadQueueOnSonos) so SOAP commands
* 3. Wire active LAST (after the queue load) so SOAP commands
* are never routed to a half-loaded Sonos queue.
*/
private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock {
@@ -580,7 +552,7 @@ class OutputPickerController @Inject constructor(
// taps don't hit Sonos's stale state from a prior session.
activeUpnpHolder.setTarget(effectiveRoute.id)
runCatching {
loadQueueOnSonos(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
sonosQueue.load(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
// Wire active LAST -- SOAP path is now safe to use.
activeUpnpHolder.set(
ActiveUpnp(
@@ -697,108 +669,6 @@ class OutputPickerController @Inject constructor(
return if (i >= 0) segments.getOrNull(i + 1) else null
}
private suspend fun loadQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
queue: List<TrackRef>,
currentIndex: Int,
) {
Timber.w("UPnP select: clear queue on %s", route.name)
transport.removeAllTracksFromQueue()
val initialEnd = (currentIndex + 1).coerceAtMost(queue.size)
val initialBatch = queue.subList(0, initialEnd)
Timber.w(
"UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
initialBatch.size, currentIndex, queue.size,
)
initialBatch.forEachIndexed { idx, ref ->
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = idx + 1,
)
}
val coordinatorUdn = route.id.bareUdn()
val queueUri = "x-rincon-queue:$coordinatorUdn#0"
Timber.w("UPnP select: SetAVTransportURI %s", queueUri)
transport.setAVTransportURI(queueUri, "")
Timber.w("UPnP select: Seek to track %d", currentIndex + 1)
transport.seekToTrack(currentIndex + 1)
Timber.w("UPnP select: Play")
transport.play()
Timber.w("UPnP select: initial done; backgrounding remainder")
val remaining = queue.drop(initialEnd)
if (remaining.isNotEmpty()) {
scope.launch { extendQueueOnSonos(transport, route, remaining, initialEnd) }
}
}
/**
* Background-append tracks after activation. Runs concurrently with
* Sonos playback. Cancels if the user disconnects from this route
* (active.routeId changes or becomes null). Tolerates individual
* AddURIToQueue failures — log and continue so some tracks loaded
* is better than zero tracks loaded.
*/
private suspend fun extendQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
tracks: List<TrackRef>,
startPosition: Int,
) {
Timber.w(
"UPnP extend: appending %d tracks starting at position %d",
tracks.size, startPosition + 1,
)
var consecutiveFailures = 0
var succeeded = 0
var aborted = false
for ((i, ref) in tracks.withIndex()) {
if (aborted) break
if (activeUpnpHolder.active.value?.routeId != route.id) {
Timber.w("UPnP extend: cancelled at offset %d (route changed)", i)
aborted = true
} else {
val outcome = runCatching {
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = startPosition + i + 1,
)
}
if (outcome.isSuccess) {
consecutiveFailures = 0
succeeded += 1
// Throttle the burst so we don't tickle Sonos's burst-add
// rejection -- logcat 2026-06-04 showed 33 consecutive
// failures clustered at ~10ms intervals once offset 39 was
// reached, which looks like a rate-limit kicking in. The
// delay is small enough that extending 100 tracks adds
// only ~5s to background work that's already async.
delay(EXTEND_THROTTLE_MS)
} else {
consecutiveFailures += 1
val e = outcome.exceptionOrNull()
val detail = (e as? SoapFaultException)?.let {
"code=${it.code} desc=${it.description}"
} ?: e?.message
Timber.w(e, "UPnP extend: append failed at offset %d -- %s", i, detail)
if (consecutiveFailures >= EXTEND_ABORT_AFTER_FAILURES) {
Timber.w(
"UPnP extend: aborting after %d consecutive failures",
consecutiveFailures,
)
aborted = true
}
}
}
}
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, tracks.size)
}
private fun renderingClientFor(routeId: String): RenderingControlClient? {
val rcUrl = upnpDiscovery.routes.value
@@ -830,9 +700,6 @@ class OutputPickerController @Inject constructor(
}
private companion object {
const val EXTEND_ABORT_AFTER_FAILURES = 3
const val EXTEND_THROTTLE_MS = 50L
// 5 minutes of continuous non-playing on a UPnP route before we
// revert to the phone speaker, so a stale Sonos selection can't make
// a later "tap play" do nothing.
@@ -0,0 +1,316 @@
package com.fabledsword.minstrel.player.output
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.StreamTokenProvider
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.bareUdn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Owns the shape of a Sonos renderer's native queue: loading it, growing it,
* diffing it against local queue mutations, and — the part that was missing —
* confirming the renderer actually took what we sent.
*
* Split out of [OutputPickerController], which is about *which route is
* selected*. How many tracks the renderer is holding is a separate concern
* with its own failure modes, and it had grown large enough to hide one:
* every write here is a SOAP call that can fail individually, and until
* [verifyQueueLength] nothing ever read the result back.
*/
@Singleton
class SonosQueueLoader @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
private val streamTokens: StreamTokenProvider,
private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
) {
suspend fun load(
transport: AVTransportClient,
route: OutputRoute,
queue: List<TrackRef>,
currentIndex: Int,
) {
Timber.w("UPnP select: clear queue on %s", route.name)
transport.removeAllTracksFromQueue()
val initialEnd = (currentIndex + 1).coerceAtMost(queue.size)
val initialBatch = queue.subList(0, initialEnd)
Timber.w(
"UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
initialBatch.size, currentIndex, queue.size,
)
initialBatch.forEachIndexed { idx, ref ->
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = idx + 1,
)
}
val coordinatorUdn = route.id.bareUdn()
val queueUri = "x-rincon-queue:$coordinatorUdn#0"
Timber.w("UPnP select: SetAVTransportURI %s", queueUri)
transport.setAVTransportURI(queueUri, "")
Timber.w("UPnP select: Seek to track %d", currentIndex + 1)
transport.seekToTrack(currentIndex + 1)
Timber.w("UPnP select: Play")
transport.play()
Timber.w("UPnP select: initial done; backgrounding remainder")
val remaining = queue.drop(initialEnd)
// Verify even when there is no tail to append: the initial batch is
// sent the same way and can be dropped the same way.
scope.launch {
if (remaining.isNotEmpty()) {
extendQueueOnSonos(transport, route, remaining, initialEnd)
}
verifyQueueLength(transport, route, queue)
}
}
/**
* Background-append tracks after activation. Runs concurrently with Sonos
* playback. Cancels if the user disconnects from this route (active.routeId
* changes or becomes null). Correctness of the result is [verifyQueueLength]'s
* job, not this function's.
*/
private suspend fun extendQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
tracks: List<TrackRef>,
startPosition: Int,
) {
Timber.w(
"UPnP extend: appending %d tracks starting at position %d",
tracks.size, startPosition + 1,
)
val succeeded = appendTracksToQueue(transport, route, tracks, startPosition)
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, tracks.size)
}
/**
* Confirm the renderer holds as many tracks as we sent, and append the
* tail it dropped.
*
* [appendTracksToQueue] tolerates individual failures and gives up after
* [EXTEND_ABORT_AFTER_FAILURES] consecutive ones, because Sonos rate-limits
* burst adds (logcat 2026-06-04: 33 consecutive failures once offset 39 was
* reached). That is the right call in the moment — some tracks loaded beats
* none — but it used to be the end of the story, and the renderer was left
* holding a queue shorter than ours with nothing aware of it. It then
* played what it had and stopped, which looked exactly like playback dying
* for no reason.
*
* Sonos appends sequentially, so a short queue means a missing tail: taking
* `fullQueue.drop(nrTracks)` is the gap. Bounded at [VERIFY_ROUNDS] passes
* so a renderer that refuses to grow can't spin here forever.
*/
@Suppress("ReturnCount") // each bail-out is a distinct reason to stop verifying
private suspend fun verifyQueueLength(
transport: AVTransportClient,
route: OutputRoute,
fullQueue: List<TrackRef>,
) {
repeat(VERIFY_ROUNDS) { round ->
if (activeUpnpHolder.active.value?.routeId != route.id) return
val nrTracks = runCatching { transport.getMediaInfo().nrTracks }
.getOrElse { e ->
Timber.w(e, "UPnP verify: GetMediaInfo failed on %s", route.name)
return
}
// 0 means the renderer told us nothing usable, not that its queue
// is empty. Guessing "empty" here would re-send the whole queue to
// a renderer that is playing it perfectly well.
if (nrTracks <= 0) {
Timber.w("UPnP verify: no usable NrTracks from %s; skipping", route.name)
return
}
if (nrTracks >= fullQueue.size) {
Timber.w("UPnP verify: renderer holds %d tracks, queue intact", nrTracks)
return
}
val missing = fullQueue.drop(nrTracks)
Timber.w(
"UPnP verify: %s holds %d of %d tracks; appending %d missing (round %d)",
route.name, nrTracks, fullQueue.size, missing.size, round + 1,
)
appendTracksToQueue(transport, route, missing, nrTracks)
}
Timber.w("UPnP verify: gave up repairing queue length on %s", route.name)
}
/**
* Append [tracks] at [startPosition] (0-based), returning how many landed.
* Tolerates individual AddURIToQueue failures — log and continue so some
* tracks loaded is better than zero tracks loaded — and stops early after
* [EXTEND_ABORT_AFTER_FAILURES] consecutive ones.
*/
private suspend fun appendTracksToQueue(
transport: AVTransportClient,
route: OutputRoute,
tracks: List<TrackRef>,
startPosition: Int,
): Int {
var consecutiveFailures = 0
var succeeded = 0
var aborted = false
for ((i, ref) in tracks.withIndex()) {
if (aborted) break
if (activeUpnpHolder.active.value?.routeId != route.id) {
Timber.w("UPnP extend: cancelled at offset %d (route changed)", i)
aborted = true
} else {
val outcome = runCatching {
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = startPosition + i + 1,
)
}
if (outcome.isSuccess) {
consecutiveFailures = 0
succeeded += 1
// Throttle the burst so we don't tickle Sonos's burst-add
// rejection -- logcat 2026-06-04 showed 33 consecutive
// failures clustered at ~10ms intervals once offset 39 was
// reached, which looks like a rate-limit kicking in. The
// delay is small enough that extending 100 tracks adds
// only ~5s to background work that's already async.
delay(EXTEND_THROTTLE_MS)
} else {
consecutiveFailures += 1
val e = outcome.exceptionOrNull()
val detail = (e as? SoapFaultException)?.let {
"code=${it.code} desc=${it.description}"
} ?: e?.message
Timber.w(e, "UPnP extend: append failed at offset %d -- %s", i, detail)
if (consecutiveFailures >= EXTEND_ABORT_AFTER_FAILURES) {
Timber.w(
"UPnP extend: aborting after %d consecutive failures",
consecutiveFailures,
)
aborted = true
}
}
}
}
return succeeded
}
/**
* Diff-based incremental Sonos queue sync. Returns true when the new
* queue can be produced from the old one with a remove-then-insert at
* the same middle slice -- the common-prefix and common-suffix portions
* stay untouched, and the current Sonos track must lie in the preserved
* prefix (otherwise the diff would orphan playback). Returns false to
* signal the caller to fall back to a full reload.
*/
suspend fun tryIncrementalResync(
transport: AVTransportClient,
oldIds: List<String>,
newQueue: List<TrackRef>,
): Boolean {
val newIds = newQueue.map { it.id }
if (oldIds == newIds) return true
val prefixLen = commonPrefixLength(oldIds, newIds)
val suffixLen = commonSuffixLength(
oldIds.subList(prefixLen, oldIds.size),
newIds.subList(prefixLen, newIds.size),
)
val removedCount = oldIds.size - prefixLen - suffixLen
val addedCount = newIds.size - prefixLen - suffixLen
// Sonos's current track number is 1-based; compare against the
// preserved-prefix range as 0-based. If the current track is in
// the removed slice, incremental can't preserve playback -- caller
// falls back to full rebuild.
val currentSonosIdx0 = remoteState.trackNumber - 1
val canApply = currentSonosIdx0 in 0 until prefixLen
if (canApply) {
applyQueueDiff(transport, newQueue, prefixLen, removedCount, addedCount)
} else {
Timber.w(
"Sonos incremental: current track %d not in preserved prefix [0,%d); full rebuild",
currentSonosIdx0,
prefixLen,
)
}
return canApply
}
private suspend fun applyQueueDiff(
transport: AVTransportClient,
newQueue: List<TrackRef>,
prefixLen: Int,
removedCount: Int,
addedCount: Int,
) {
if (removedCount > 0) {
Timber.w(
"Sonos incremental: RemoveTrackRangeFromQueue start=%d count=%d",
prefixLen + 1,
removedCount,
)
transport.removeTrackRangeFromQueue(
startingIndex = prefixLen + 1,
numberOfTracks = removedCount,
)
}
if (addedCount == 0) return
Timber.w(
"Sonos incremental: AddURIToQueue x%d starting at position %d",
addedCount,
prefixLen + 1,
)
for (i in 0 until addedCount) {
val ref = newQueue[prefixLen + i]
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = prefixLen + i + 1,
)
if (i > 0) delay(EXTEND_THROTTLE_MS)
}
}
private fun commonPrefixLength(a: List<String>, b: List<String>): Int {
val limit = minOf(a.size, b.size)
for (i in 0 until limit) {
if (a[i] != b[i]) return i
}
return limit
}
private fun commonSuffixLength(a: List<String>, b: List<String>): Int {
val limit = minOf(a.size, b.size)
for (i in 0 until limit) {
if (a[a.size - 1 - i] != b[b.size - 1 - i]) return i
}
return limit
}
private companion object {
// Abort the append loop after this many consecutive AddURIToQueue
// failures; Sonos rate-limits burst adds and a wall of failures means
// it has stopped accepting, not that the next one might land.
const val EXTEND_ABORT_AFTER_FAILURES = 3
const val EXTEND_THROTTLE_MS = 50L
// Verify/repair passes after a queue load. Two: one to catch the
// common case (a rate-limit burst dropped a chunk), one to catch a
// repair that itself got rate-limited. Beyond that the renderer is
// refusing for a reason retrying won't fix, and the stall watchdog
// becomes the backstop.
const val VERIFY_ROUNDS = 2
}
}
@@ -211,6 +211,34 @@ class AVTransportClient(
)
}
/**
* What the renderer believes it is holding: how many tracks are in its
* queue, and the URI the transport is pointed at.
*
* We load the queue with AddURIToQueue and, until this existed, never
* read it back — so a partially-applied load was invisible. Sonos
* rate-limits burst adds (logcat 2026-06-04: 33 consecutive failures once
* offset 39 was reached), and [OutputPickerController]'s extend loop gives
* up after a few of those and leaves a short queue behind. The renderer
* then plays what it actually has and stops, correctly, at an end the app
* did not know existed.
*
* NrTracks is the cheap authoritative answer, so queue truncation becomes
* something we can detect and repair rather than infer.
*/
suspend fun getMediaInfo(): MediaInfo {
val result = soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "GetMediaInfo",
args = mapOf("InstanceID" to "0"),
)
return MediaInfo(
nrTracks = result["NrTracks"]?.toIntOrNull() ?: 0,
currentUri = result["CurrentURI"].orEmpty(),
)
}
suspend fun getTransportInfo(): TransportInfo {
val result = soap.call(
controlUrl = controlUrl,
@@ -298,6 +326,14 @@ data class PositionInfo(
val trackDurationMs: Long,
)
/**
* [nrTracks] is the renderer's own count of its queue — 0 when it reports
* nothing, which callers must read as "unknown", never as "empty". A
* renderer that does not implement GetMediaInfo usefully must not be
* mistaken for one with an empty queue.
*/
data class MediaInfo(val nrTracks: Int, val currentUri: String)
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN }
/**
@@ -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),
)
/**
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,128 @@
package com.fabledsword.minstrel.diagnostics
import com.fabledsword.minstrel.player.TransportObservation
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
/**
* The rule deciding when renderer transport changes are worth recording as an
* episode. Worth pinning because both failure directions are costly: too eager
* and every track transition writes a summary that buries the real one, too
* shy and the operator's stutter goes unmeasured for another month.
*/
class TransportFlapDetectorTest {
private fun obs(state: String, atMs: Long, track: Int = 1, posMs: Long = 0L) =
TransportObservation(
state = state,
statusOk = true,
trackNumber = track,
positionMs = posMs,
playIntent = true,
atElapsedMs = atMs,
)
/**
* PLAYING -> TRANSITIONING -> PLAYING is what a queue advance looks like.
* It happens on every single track and must never be recorded as a fault.
*/
@Test
fun `an ordinary track transition is not an episode`() {
val d = TransportFlapDetector()
assertNull(d.onChange(obs("PLAYING", 0)))
assertNull(d.onChange(obs("TRANSITIONING", 1_000)))
assertNull(d.onChange(obs("PLAYING", 2_000)))
}
@Test
fun `four changes inside the window is an episode`() {
val d = TransportFlapDetector()
d.onChange(obs("PLAYING", 0))
d.onChange(obs("STOPPED", 500))
d.onChange(obs("PLAYING", 1_000))
// assertNotNull returns the value, so the asserts below need no cast.
val episode = assertNotNull(d.onChange(obs("STOPPED", 1_500)))
assertEquals(4, episode.size)
assertEquals(
listOf("PLAYING", "STOPPED", "PLAYING", "STOPPED"),
episode.map { it.state },
)
}
/**
* Changes spread thinly are normal listening — a few track advances over
* a couple of minutes must not accumulate into a false episode.
*/
@Test
fun `changes spread beyond the window never accumulate`() {
val d = TransportFlapDetector()
repeat(20) { i ->
assertNull(d.onChange(obs("PLAYING", i * 10_000L)))
}
}
/** The window slides: old readings age out rather than counting forever. */
@Test
fun `readings older than the window are dropped`() {
val d = TransportFlapDetector()
d.onChange(obs("PLAYING", 0))
d.onChange(obs("STOPPED", 1_000))
// Long gap — the two above are now stale.
assertNull(d.onChange(obs("PLAYING", 30_000)))
assertNull(d.onChange(obs("STOPPED", 30_500)))
// Only three fresh readings so far.
assertNull(d.onChange(obs("PLAYING", 31_000)))
assertNotNull(d.onChange(obs("STOPPED", 31_500)))
}
/**
* A fault that persists produces a change every poll. Without the cooldown
* every one of them would write a summary, which is exactly the noise that
* makes a diagnostics dump unreadable.
*/
@Test
fun `a sustained fault reports one episode, not one per reading`() {
val d = TransportFlapDetector()
var episodes = 0
repeat(40) { i ->
if (d.onChange(obs(if (i % 2 == 0) "PLAYING" else "STOPPED", i * 500L)) != null) {
episodes++
}
}
assertEquals(1, episodes)
}
/** Past the cooldown, a fresh episode is worth recording again. */
@Test
fun `a later episode reports again once the cooldown has passed`() {
val d = TransportFlapDetector()
repeat(4) { d.onChange(obs("PLAYING", it * 500L)) }
val second = (0 until 4).map { d.onChange(obs("STOPPED", 90_000 + it * 500L)) }
assertEquals(1, second.count { it != null })
}
@Test
fun `reset forgets the window and the cooldown`() {
val d = TransportFlapDetector()
repeat(4) { d.onChange(obs("PLAYING", it * 500L)) }
d.reset()
repeat(3) { d.onChange(obs("PLAYING", 3_000 + it * 500L)) }
// A 4th change after reset is a new episode, cooldown notwithstanding.
assertNotNull(d.onChange(obs("STOPPED", 5_000)))
}
/** The episode is a snapshot — later readings must not mutate it. */
@Test
fun `a returned episode is not mutated by later readings`() {
val d = TransportFlapDetector()
d.onChange(obs("PLAYING", 0))
d.onChange(obs("STOPPED", 500))
d.onChange(obs("PLAYING", 1_000))
val episode = assertNotNull(d.onChange(obs("STOPPED", 1_500)))
val sizeAtCapture = episode.size
repeat(5) { d.onChange(obs("PLAYING", 2_000 + it * 500L)) }
assertEquals(sizeAtCapture, episode.size)
}
}
@@ -19,15 +19,35 @@ class RemoteStallWatchdogTest {
playIntent: Boolean = true,
positionMs: Long = 0L,
nowMs: Long = 0L,
) = onPoll(trackUri, state, statusOk, playIntent, positionMs, nowMs)
queue: RemoteStallWatchdog.QueueState = RemoteStallWatchdog.QueueState.UNKNOWN,
) = onPoll(
RemoteStallWatchdog.Poll(
trackUri = trackUri,
state = state,
statusOk = statusOk,
playIntent = playIntent,
positionMs = positionMs,
nowMs = nowMs,
queue = queue,
),
)
/** Drive [n] stopped polls and return the last decision. */
private fun RemoteStallWatchdog.stopFor(
n: Int,
nowMs: Long = 0L,
queue: RemoteStallWatchdog.QueueState = RemoteStallWatchdog.QueueState.UNKNOWN,
trackUri: String = uri,
): RemoteStallWatchdog.Decision {
var last: RemoteStallWatchdog.Decision = RemoteStallWatchdog.Decision.None
repeat(n) { last = poll(state = TransportState.STOPPED, nowMs = nowMs) }
repeat(n) {
last = poll(
trackUri = trackUri,
state = TransportState.STOPPED,
nowMs = nowMs,
queue = queue,
)
}
return last
}
@@ -174,6 +194,106 @@ class RemoteStallWatchdogTest {
assertEquals(60_000L, again.resumeAtMs)
}
// A stopped renderer can mean three different things. Before QueueState
// they were indistinguishable, and all three were treated as a stall.
/**
* The regression that mattered most: reaching the end of the queue is how
* every listening session ends. Treating it as a stall meant retrying the
* last track three times and then raising a `stalled` error for playback
* that finished perfectly normally.
*/
@Test
fun `reaching the end of the queue is not a stall`() {
val w = RemoteStallWatchdog()
repeat(20) {
assertIs<RemoteStallWatchdog.Decision.None>(
w.stopFor(1, nowMs = it * 1_000L, queue = RemoteStallWatchdog.QueueState.COMPLETE),
)
}
}
/** And it must not quietly spend the budget it never needed. */
@Test
fun `a completed queue leaves the attempt budget untouched`() {
val w = RemoteStallWatchdog()
w.stopFor(5, queue = RemoteStallWatchdog.QueueState.COMPLETE)
// Same track, now genuinely stalled: full budget, first attempt.
val decision = w.stopFor(3, nowMs = 30_000L)
assertIs<RemoteStallWatchdog.Decision.Resume>(decision)
assertEquals(1, decision.attempt)
}
/**
* The bug behind all of this: the renderer stopped because it reached the
* end of a queue we failed to fully load. Re-playing the finished track is
* the wrong remedy — the queue is what's broken.
*/
@Test
fun `running off the end of a truncated queue asks for a repair`() {
val w = RemoteStallWatchdog()
val decision = w.stopFor(3, queue = RemoteStallWatchdog.QueueState.TRUNCATED)
assertIs<RemoteStallWatchdog.Decision.RepairQueue>(decision)
assertEquals(1, decision.attempt)
}
/** A renderer with tracks left that stopped anyway really has stalled. */
@Test
fun `stopping mid-queue is still a stall`() {
val w = RemoteStallWatchdog()
val decision = w.stopFor(3, queue = RemoteStallWatchdog.QueueState.HAS_MORE)
assertIs<RemoteStallWatchdog.Decision.Resume>(decision)
}
/**
* A renderer that doesn't report NrTracks usefully must keep the old
* behaviour rather than being told its queue is fine or broken.
*/
@Test
fun `an unknown queue state falls back to resuming`() {
val w = RemoteStallWatchdog()
assertIs<RemoteStallWatchdog.Decision.Resume>(
w.stopFor(3, queue = RemoteStallWatchdog.QueueState.UNKNOWN),
)
}
/** Repairs are bounded by the same budget, so a renderer that will not
* grow its queue stops being asked. */
@Test
fun `repairs are capped and then it gives up`() {
val w = RemoteStallWatchdog()
val truncated = RemoteStallWatchdog.QueueState.TRUNCATED
assertIs<RemoteStallWatchdog.Decision.RepairQueue>(
w.stopFor(3, nowMs = 0L, queue = truncated),
)
assertIs<RemoteStallWatchdog.Decision.RepairQueue>(
w.stopFor(1, nowMs = 5_000L, queue = truncated),
)
assertIs<RemoteStallWatchdog.Decision.RepairQueue>(
w.stopFor(1, nowMs = 10_000L, queue = truncated),
)
assertIs<RemoteStallWatchdog.Decision.GiveUp>(
w.stopFor(1, nowMs = 15_000L, queue = truncated),
)
}
/**
* A successful repair adds tracks, so the renderer moves on to one it had
* never seen. That is a new track, which restores the budget by the same
* rule any other track change does.
*/
@Test
fun `a repair that works hands the next track a full budget`() {
val w = RemoteStallWatchdog()
assertIs<RemoteStallWatchdog.Decision.RepairQueue>(
w.stopFor(3, queue = RemoteStallWatchdog.QueueState.TRUNCATED),
)
w.poll(trackUri = other, state = TransportState.PLAYING, nowMs = 6_000L)
val next = w.stopFor(3, nowMs = 30_000L, trackUri = other)
assertIs<RemoteStallWatchdog.Decision.Resume>(next)
assertEquals(1, next.attempt)
}
@Test
fun `reset forgets everything`() {
val w = RemoteStallWatchdog()
@@ -183,6 +183,52 @@ class AVTransportClientTest {
}
}
@Test
fun `getMediaInfo parses NrTracks and CurrentURI`() = runTest {
server.enqueue(
MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:GetMediaInfoResponse
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<NrTracks>42</NrTracks>
<MediaDuration>0:00:00</MediaDuration>
<CurrentURI>x-rincon-queue:RINCON_ABC#0</CurrentURI>
</u:GetMediaInfoResponse>
</s:Body>
</s:Envelope>""".trimIndent(),
),
)
val info = client.getMediaInfo()
assertEquals(42, info.nrTracks)
assertEquals("x-rincon-queue:RINCON_ABC#0", info.currentUri)
}
/**
* A renderer that omits NrTracks reads as 0, which callers must treat as
* "unknown". Parsing it as anything else would let an unhelpful renderer
* be mistaken for one with an empty queue — and the repair path would
* then re-send the whole queue to a device playing it perfectly well.
*/
@Test
fun `getMediaInfo reports zero when the renderer omits NrTracks`() = runTest {
server.enqueue(
MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:GetMediaInfoResponse
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<CurrentURI>http://x/y.mp3</CurrentURI>
</u:GetMediaInfoResponse>
</s:Body>
</s:Envelope>""".trimIndent(),
),
)
assertEquals(0, client.getMediaInfo().nrTracks)
}
private fun emptyResponse(action: String): MockResponse = MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
@@ -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
}
}
-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" }
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+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;
}