Phase 3 of M12 (docs/android/THEMING_AND_NAV.md §5.3) — the fonts third of the admin's Appearance page, after phase 1's colors and phase 2's structure. ShardTypeface.resolve(theme) maps the three font stacks onto three FontFamily values and shardTypography(faces) draws the M5 type scale in them. Only the family moves: every size, weight, line height and tracking is the M5 value, so an unthemed instance reproduces the pre-M12 scale exactly. Resolution is pure, so every assertion is a plain JVM test with no Compose rule. Seven families are bundled beside the existing Cinzel (EB Garamond, Merriweather, Playfair Display, IM Fell English, Inter, Work Sans, Source Sans 3), taken verbatim from google/fonts the way M5 took Cinzel, each with its SIL OFL licence under app/licenses/. Italics for the four families client/index.html requests one for; the rest are skewed, as they were before. Three things worth knowing: 1. The per-role font list is not the set of values a role can hold. The server validates admin-entered fonts against FONT_OPTIONS[role], but a preset's tokens are copied verbatim by resolveThemeTokens and never pass through it — `modern` publishes --display: 'Work Sans' and `fantasy` publishes --sans: 'EB Garamond', neither of which its own dropdown offers. The lookup is therefore one global map keyed by the lowercased first family name, and both preset cases are asserted by name so a per-role "tidy-up" fails loudly. Same trap phase 2 hit with --shadow-card, in a different token group. 2. The APK nearly tripled, and that was a decision, not a discovery. Measured unsigned release, R8 + resource shrink: 5,031,411 B (4.80 MiB) before, 13,574,703 B (12.94 MiB) after — +8.15 MiB against a drafted estimate of 1.5-2.5 MB. Merriweather alone is 6.08 MiB of that, because upstream ships it as a three-axis [opsz,wdth,wght] variable font that deflates only 31%. The org lead chose to bundle it verbatim with the cheaper options costed: Google's own static 400/700 builds would have held the app near 6.8 MiB, and dropping it near 6.2 MiB at the price of a serif option that silently does nothing on Android. 3. Typography implements equals — like phase 2's Shapes, unlike phase 1's ColorScheme, checked the same way in the material3 1.3.0 bytecode. AC-1's type half is one comparison against a verbatim copy of the pre-M12 scale held in the test. No LocalShardTypeface: unlike the palette and the structure, MaterialTheme carries the families completely, and the two composables that override anything override the style rather than the family. Type.kt's `val Typography` becoming a function is the whole migration — the three families were referenced from that one file and nowhere else. Tests: ShardTypefaceTest (15). 401 unit tests green (386 + 15), lintDebug and assembleDebug clean. Not exercised on device — that is AC-5, in phase 8, where IM Fell English's synthesised bold is the thing to look at. Docs: RunicGateway/docs#TBD Co-Authored-By: Claude <noreply@anthropic.com>
Runic Gateway — Android app
A native Android client for a Runic Gateway shard's public site + player self-service. It is
purely an API client of the website backend — it never talks to the link/ sidecar or the game
shard directly, and it ships none of the shard/sidecar wiring. It surfaces the same content and
player features as the website's browser client, minus every administrative/management console.
The authoritative design contract is docs/android/PLAN.md
in the RunicGateway/docs repo. The authoritative API reference is the committed OpenAPI spec at
website/server/swagger/swagger-output.json.
Status
M0 — repo scaffold. Gradle + Compose + Hilt skeleton with CI (lint + unit test + debug build). The functional Kotlin pass (M1–M4) and the design pass (M5) follow — see the plan's milestones (§9).
Stack
| Concern | Choice |
|---|---|
| Language / UI | Kotlin + Jetpack Compose (Material 3) |
| Navigation | Navigation-Compose, single-activity |
| HTTP | Retrofit + OkHttp, kotlinx.serialization |
| Async | Coroutines + Flow |
| DI | Hilt |
| Prefs / base URL | Jetpack DataStore |
| Tokens at rest | EncryptedSharedPreferences |
| Images | Coil |
| Min SDK | Android 10 (API 29) |
| Target / compile SDK | 35 |
Dependency and plugin versions are pinned in gradle/libs.versions.toml.
Build
Requires JDK 17 and the Android SDK (ANDROID_HOME / local.properties).
./gradlew assembleDebug # build a debug APK -> app/build/outputs/apk/debug/
./gradlew test # JVM unit tests
./gradlew lint # Android lint
./gradlew installDebug # install on a connected device/emulator
The app self-configures its server URL on first run (PLAN.md §3), so a single build works against any shard's website — there is no compiled-in API host.
CI
.gitea/workflows/pr-checks.yml gates PRs into main with ./gradlew lint test assembleDebug on the
org's self-hosted runner (JDK 17 + Android SDK). Debug builds are auto-signed, so the gate needs no
secrets. This pipeline is verified green end-to-end on the runner (M0). A signed release APK
attached to a Gitea release comes at M6.
The workflow carries a few runner-specific accommodations (each explained in comments in the file), because this self-hosted runner differs from a stock GitHub runner:
- JDK 17 is installed via
apt(notactions/setup-java) — the runner can't resolveapi.adoptium.net, while the Ubuntu mirrors are reachable. - SDK packages are installed explicitly via
sdkmanager, withset +o pipefailsoyesdying ofSIGPIPEdoesn't fail the step. gradlewischmod +x'd in the run step — the runner's checkout does not preserve the git executable bit, so./gradlewalone fails with "Permission denied".
Contributing
See CONTRIBUTING.md. AI-assisted contributions must be disclosed (org
policy): tick the PR box naming the tool and add a Co-Authored-By trailer to AI-authored commits.
Licensed GPL-3.0-or-later.