The structure half of the admin's Appearance page. ShardStructure.resolve() turns the four --radius-* tokens and --shadow-card into a Material shape scale, a pill shape and a card elevation; RunicGatewayTheme feeds the scale to MaterialTheme and the other two to a LocalShardStructure, mirroring phase 1's palette split. Radii are applied as a ratio, never as a literal. The app's Shapes came from the M5 mockup and the website's from theme.css, and the two scales differ - copying the web value in would have restyled an untouched app on day one. Each field is scaled by resolved / runic-gateway baseline instead, so the shipped theme and an explicit runic-gateway both give ratio 1.0 and are provable no-ops. Three things the spec did not survive contact with: Card depth is not a no-op, and that is the org lead's decision. Material3's filled Card is Level0 and FeatureCard drew none of the shadow its own docs claimed, so the app has been flat since M5 - while the preset it was drawn from selects the "Default" shadow. Section 5.4 is applied as written rather than rebased on the flat baseline, which would have collapsed three of the admin's four choices onto 0dp. Every card gains 4dp; sections 2, 5.4 and AC-1 record it. The shadow is matched by nearest blur, not by exact string. The fantasy preset publishes a --shadow-card that SHADOW_OPTIONS does not contain, because a preset's tokens are copied verbatim and never pass through the admin dropdown - an exact match would have missed the one preset whose point is a heavier shadow. --radius-pill is resolved as a literal px, because CircleShape is a percentage and has no shipped dp for a ratio to scale. It reaches exactly one composable: the app's other two CircleShape uses are 8dp status dots, and a dot stays a dot. ShardCard exists because Material's theme cannot carry elevation - Card takes it as a default argument. All 24 Card( call sites across 20 files moved to the wrapper, which is mechanical because every one of them passed only a modifier. A Card( outside ThemeComponents.kt is now, by construction, an unthemable card. Shapes does implement equals (unlike ColorScheme), so the structural no-op proof is one assertion against a verbatim copy of the pre-M12 scale. 13 new tests, 386 green, lintDebug and assembleDebug clean. 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.