docs(android): build contract for admin theming & navigation parity (M12)
The website merged runtime admin theming, brand assets and nav overrides to main (website#126 / docs#109). The app reads exactly one field of it -- brand.accent -- and renders a hardcoded APP_MENU, so an admin who re-skins the site and restructures the header sees none of it on the phone. Adds docs/android/THEMING_AND_NAV.md as the design of record for M12, and the PLAN.md §9 entry that anchors it. Plan only: no app code, no backend work. Everything consumed is already live on website/main. The points that shaped it: - The app's ui/theme/Color.kt palette is already, value for value, the runic-gateway preset -- M5 was drawn from the same theme.css the preset was later extracted from. So "an untouched instance is unchanged" carries over as a testable ColorScheme equality assertion, not an approximation. - Radii apply as a ratio against that baseline, not as literal dp. The app's Shapes came from the M5 mockup and genuinely differ (medium 12dp vs --radius-card 10px); a literal mapping would restyle the untouched app the day this ships, and copying the app's scale into the server would be a second source of truth. - Fonts are bundled, not downloadable: the Play Store font provider makes a de-Googled device fall back silently. Seven families join the bundled Cinzel. - Nav overrides are keyed by website paths, so the app needs a path -> route table -- the one new cross-repo coupling here. An override for a path the app does not surface in its menu is ignored: a nav override may never introduce navigation. - The gates are untouched. MenuAccess and MenuEntry.feature still run after the merge, so hidden:false cannot un-hide what a role or the shard's visibility config withholds. - Read the resolved theme/brand fields, never the raw theme_visual/brand_assets rows that ride along in the same payload -- re-deriving a palette from them would be a second resolveThemeTokens in Kotlin, guaranteed to drift. Nine phases into a fresh edge in both repos, reaching main as one edge -> main merge, the same shape the website side used. Phase 0 must change nothing on screen. Phase 7 (the authenticated navs) is marked optional: nav_player reaches two app rows and nav_admin two, which is a thin return for a new authenticated fetch and its cache teardown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TgfKv5cz5pbY3dPeofSE5a
This commit is contained in:
@@ -990,6 +990,55 @@ push, and Play (M6–M8) follow the designed app.
|
||||
Visibility, Spawn Atlas and Cliloc import — alongside the hero/CMS block editor, Discord-bot
|
||||
config, uo-link config and OAuth-provider setup.
|
||||
|
||||
13. **M12 — Admin theming & navigation parity** (post-v1; scoped 2026-08-08). The website merged
|
||||
runtime admin theming, brand assets and nav overrides to `main` (website#126 / docs#109). The app
|
||||
reads exactly one field of it — `brand.accent` — and renders a hardcoded `APP_MENU`, so an admin
|
||||
who re-skins the site and restructures the header sees none of it on the phone. This milestone
|
||||
makes the app a full consumer of that contract.
|
||||
|
||||
**Design of record: [`THEMING_AND_NAV.md`](./THEMING_AND_NAV.md)** — the token map, the phase
|
||||
list and the locked decisions live there rather than here, mirroring how the website side kept
|
||||
[`../website/THEMING_AND_NAV.md`](../website/THEMING_AND_NAV.md) separate from its own plan.
|
||||
|
||||
**No backend work.** Everything consumed is already live on `website/main`:
|
||||
`GET /public/settings` gained `theme` (the full resolved token map) and `nav_public`, its `brand`
|
||||
block now returns *effective* values, and `GET /api/v1/settings/nav` serves the admin/player
|
||||
overrides to any authenticated account.
|
||||
|
||||
The points that shaped the plan, and that a reader of this file should know without opening it:
|
||||
|
||||
- **The app's palette is already the `runic-gateway` preset**, value for value — M5 was drawn
|
||||
from the same `theme.css` the preset was later extracted from. So the website's governing
|
||||
invariant (an untouched instance renders byte-for-byte as before) carries over as a *testable
|
||||
equality assertion* on the resolved `ColorScheme`, not an approximation.
|
||||
- **Radii apply as a ratio, not as literal dp.** The app's `Shapes` came from the M5 mockup and
|
||||
genuinely differ from the web tokens (`medium` 12dp vs `--radius-card` 10px); a literal mapping
|
||||
would restyle the untouched app the day this ships. A ratio against the `runic-gateway`
|
||||
baseline makes an untouched instance a provable no-op while still tracking the admin's intent.
|
||||
- **Fonts are bundled, not downloadable.** Seven families join the already-bundled Cinzel
|
||||
(~1.5–2.5 MB, against a 4.2 MB signed release). Downloadable fonts were rejected: they need the
|
||||
Play Store provider, so a de-Googled device silently falls back.
|
||||
- **Nav overrides are keyed by *website* paths**, so the app needs a path → route table — the one
|
||||
new cross-repo coupling here. Two asymmetries are decided rather than papered over: an override
|
||||
for a path the app does not surface in its menu (champs / guilds / governors / houses, which
|
||||
live behind the Shard hub) is **ignored**, because a nav override may never *introduce*
|
||||
navigation; and the app's own entries with no web counterpart keep their coded order.
|
||||
- **The gates are untouched.** `MenuAccess` and `MenuEntry.feature` still run *after* the merge,
|
||||
so `hidden: false` cannot un-hide what a role or the shard's visibility config withholds — the
|
||||
same boundary the website's §7 draws.
|
||||
- **The authenticated navs are thinner than they look.** `nav_player` reaches two app rows and
|
||||
`nav_admin` two (`/player`, `/account`, `/admin`, `/admin/moderation`); the sidebar's other
|
||||
~18 rows are admin *configuration* the app excludes, and two of the app's four staff entries
|
||||
are aggregates with no single web row. That is why they honor `label` and `hidden` only — and
|
||||
why that phase is scheduled **last and marked optional**, so it can be dropped on its merits
|
||||
once the rest is working.
|
||||
- **Excluded**, in the same class as M10's and M11's exclusions: the admin *configuration* panels
|
||||
themselves. The app does not gain Appearance or Navigation editors; it is a consumer.
|
||||
|
||||
Nine phases into a fresh `edge` in both repos, reaching `main` as one `edge` → `main` merge —
|
||||
the same shape the website side used. Phase 0 (the contract and the appearance store) carries a
|
||||
hard rule: it must change nothing on screen.
|
||||
|
||||
### Deferred (not a milestone)
|
||||
|
||||
- **`/api/mobile` facade migration + app-version floor** — briefly planned as its own milestone
|
||||
|
||||
502
android/THEMING_AND_NAV.md
Normal file
502
android/THEMING_AND_NAV.md
Normal file
@@ -0,0 +1,502 @@
|
||||
# Android: honoring admin-configurable theming & navigation
|
||||
|
||||
> Build contract for the Android client's half of the feature shipped in
|
||||
> [`docs/website/THEMING_AND_NAV.md`](../website/THEMING_AND_NAV.md).
|
||||
> Same workflow as the website side: design → phased build → verify.
|
||||
> Milestone **M12**; see [`PLAN.md`](./PLAN.md) §9.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
The website merged runtime admin theming, brand assets and navigation overrides
|
||||
to `main` (website#126 / docs#109). An admin who re-skins the site from
|
||||
Admin → Appearance and restructures the header from Admin → Navigation currently
|
||||
sees **none of it on the phone**: the app reads exactly one field, `brand.accent`,
|
||||
and renders a hardcoded `APP_MENU`.
|
||||
|
||||
This milestone makes the app a full consumer of that contract:
|
||||
|
||||
1. **Theme** — the whole resolved color palette, the corner-radius scale, the
|
||||
shadow depth, and the font choice.
|
||||
2. **Brand assets** — the uploaded logo and hero, which the app has modeled in
|
||||
`BrandDto` since M1 and has never rendered.
|
||||
3. **Navigation** — the public header's labels, order, hidden entries, dropdown
|
||||
sections and admin-added links, plus the label/hidden overrides for the
|
||||
player and staff surfaces.
|
||||
|
||||
## 2. Core principle: the shipped app is the default, always
|
||||
|
||||
The website's governing invariant is that an instance with no settings rows
|
||||
renders byte-for-byte as it did before the feature existed. **The app inherits
|
||||
that invariant unchanged**, and it is unusually cheap to honor here because of a
|
||||
fact worth stating plainly:
|
||||
|
||||
> **The app's `ui/theme/Color.kt` palette is already, value for value, the
|
||||
> `runic-gateway` preset.** All fifteen themable tokens match. The M5 design pass
|
||||
> was drawn from the same `theme.css` the preset was later extracted from.
|
||||
|
||||
So the fallback for every color is not a "close enough" approximation — it is the
|
||||
identical value. A shard with no `theme_visual` row must produce a `ColorScheme`
|
||||
that is `==` to today's `ShardColorScheme`, and that is a testable claim, not an
|
||||
aspiration. It is locked by a test (§7, AC-1).
|
||||
|
||||
The same asymmetry the server uses applies on the client: **forgiving on read.**
|
||||
A token that is missing, malformed, or unknown falls back field-by-field to the
|
||||
shipped value. A bad `--accent` must not discard a good `--bg` beside it, and a
|
||||
settings call that fails is the same state as "no overrides" — never an error
|
||||
screen, never a half-painted theme.
|
||||
|
||||
## 3. What the server already publishes
|
||||
|
||||
No backend work. Everything below is live on `website/main` today.
|
||||
|
||||
| Source | Field | Shape |
|
||||
|---|---|---|
|
||||
| `GET /public/settings` | `theme` | `Record<cssVar, string>` — 15 colors, 4 radii, `--shadow-card`, 3 font stacks. **Absent** when no row exists |
|
||||
| `GET /public/settings` | `brand.accent` / `.logo` / `.hero` / `.favicon` | Already **effective** values (override → env). The app reads `accent` today |
|
||||
| `GET /public/settings` | `nav_public` | Raw JSON **string**: a bare items map, or `{items, sections, links}` |
|
||||
| `GET /api/v1/settings/nav` | `nav_admin`, `nav_player` | Raw JSON strings. Gate is `requireAuth`, **no role check** — a player may read it |
|
||||
|
||||
Two shapes to get right on the wire:
|
||||
|
||||
- `nav_public` is a **JSON string inside a JSON object**, because `settings.value`
|
||||
is `TEXT`. It is parsed a second time, exactly as the web client's
|
||||
`parseJsonSetting` does.
|
||||
- `theme` being **absent** and `theme` being `{}` are the same thing to the app,
|
||||
and both mean "shipped defaults". The server never emits an empty map
|
||||
(`resolveThemeTokens` returns `null` instead), but the app must not depend on
|
||||
that.
|
||||
|
||||
**The trap in this payload: read the resolved fields, never the raw rows.**
|
||||
`theme_visual` and `brand_assets` are in `PUBLIC_KEYS`, so their raw JSON strings
|
||||
ride along in the same response as `theme` and `brand`. They are *inputs* — a
|
||||
preset id and a sparse custom overlay — and re-deriving a palette from them would
|
||||
be a second implementation of `resolveThemeTokens`, in Kotlin, guaranteed to
|
||||
drift the first time a preset changes. The app consumes `theme` and `brand`,
|
||||
which the server has already layered `:root ← preset ← custom` for it, and models
|
||||
neither raw key. `nav_public` is the one raw row the app does read, because there
|
||||
is no resolved counterpart — the merge is the *client's* job on the web too.
|
||||
|
||||
## 4. Locked decisions
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Theme depth | **Colors, radii, shadow and fonts** — the full token set, not accent-only |
|
||||
| Fonts | **Bundle the families**, do not use downloadable fonts — see §5.3 |
|
||||
| Radii | Applied as a **ratio against the `runic-gateway` baseline**, not as literal dp — see §5.2 |
|
||||
| Semantic color | `--mode-live` / `--mode-maint` and the app's success/warning/danger pills stay **fixed**, never themed. Mirrors the server's `FIXED_TOKENS` |
|
||||
| Light mode | Still **out of scope**. Every v1 preset is dark; the website's Parchment preset was cancelled (website §8 phase 9). The app stays dark-only, and `Theme.kt` keeps its single `darkColorScheme` |
|
||||
| Favicon | **No app surface.** Ignored, and not modeled |
|
||||
| Added links | A path matching a known app route opens the **native screen**; anything else hands off to a **Custom Tab** — see §6.3 |
|
||||
| `nav_admin` / `nav_player` | **`label` and `hidden` only.** No order, no group — see §6.4 |
|
||||
| Refresh | On connect, on **process start**, and on **resume** alongside the existing role re-validation — see §5.5 |
|
||||
| Failure posture | Forgiving on read, field by field. A failed settings call renders the shipped app, never an error |
|
||||
|
||||
## 5. Theme
|
||||
|
||||
### 5.1 Colors — the 15-token map
|
||||
|
||||
Every themable token has exactly one home in the app palette. This table is the
|
||||
contract; `ui/theme/Color.kt`'s current constants are its right-hand column.
|
||||
|
||||
| CSS token | App constant | Shipped value | Material role(s) |
|
||||
|---|---|---|---|
|
||||
| `--bg` | `ShardSurface` | `#0E1318` | `surface`, `surfaceContainerLow` |
|
||||
| `--bg-deep` | `ShardPage` | `#0B0F14` | `background` |
|
||||
| `--panel-a` | `ShardCardTop` | `#192231` | feature-card gradient top |
|
||||
| `--panel-b` | `ShardCardBottom` | `#141A21` | feature-card gradient bottom |
|
||||
| `--panel-flat` | `ShardElevated` | `#11161D` | `surfaceVariant`, `surfaceContainer`, `surfaceContainerHigh` |
|
||||
| `--line` | `ShardOutline` | `#2A3544` | `outline` |
|
||||
| `--line-soft` | `ShardDivider` | `#1D2733` | `outlineVariant` |
|
||||
| `--accent` | `ShardAccent` | `#7F99BD` | `secondary`, `tertiary` |
|
||||
| `--accent-bright` | `ShardCta` | `#CDD9E8` | `primary`, `onSecondaryContainer` |
|
||||
| `--ink` | `ShardHeading` | `#EEF3F8` | brightest headings |
|
||||
| `--head` | `ShardHeadingDim` | `#E6EDF6` | heading on surface |
|
||||
| `--text` | `ShardBody` | `#C4CDD8` | `onBackground`, `onSurface` |
|
||||
| `--muted` | `ShardMuted` | `#AEB8C4` | `onSurfaceVariant` |
|
||||
| `--dim` | `ShardFaint` | `#6F7D8E` | meta / faint labels |
|
||||
| `--blue` | `ShardPillBg` | `#13243C` | `secondaryContainer` |
|
||||
|
||||
`ShardOnCta` (`#0B0F14`) is **derived**, not themed: it is text drawn on the
|
||||
`--accent-bright` fill, and it tracks `--bg-deep`. This mirrors the server's
|
||||
derived-token rule for `--panel-grad` — a value expressed in terms of another
|
||||
token must never be frozen as a literal, or a future light preset inherits a dark
|
||||
one and looks broken.
|
||||
|
||||
**Two consumers, one resolution.** Ten of these fifteen have a Material role;
|
||||
five do not — `ShardCardTop`, `ShardCardBottom`, `ShardHeading`, `ShardHeadingDim`
|
||||
and `ShardFaint`. So the resolved palette is a single `ShardPalette` data class,
|
||||
provided two ways:
|
||||
|
||||
- fed into `darkColorScheme(...)` for the Material roles;
|
||||
- exposed as a `LocalShardPalette` CompositionLocal for the rest.
|
||||
|
||||
Today those non-Material colors are imported as top-level `val`s straight from
|
||||
`Color.kt`. That surface was measured before scoping the phase, and it is
|
||||
**smaller than it looks** — two files, sixteen imports:
|
||||
|
||||
| file | imports | themable | semantic (stay fixed) |
|
||||
|---|---|---|---|
|
||||
| `ui/components/ThemeComponents.kt` | 14 | `ShardCardTop`, `ShardCardBottom`, `ShardElevated`, `ShardFaint`, `ShardOutline`, `ShardPillBg`, `ShardPillFg` | the 7 success/warning/danger constants |
|
||||
| `ui/shard/ShardComponents.kt` | 2 | — | `ShardSuccess`, `ShardSuccessDot` |
|
||||
|
||||
So the migration is **one file and seven constants**; nothing else in the app
|
||||
reaches past `MaterialTheme.colorScheme`. That is the M5 design's premise paying
|
||||
off — the ~20 screens take the new palette through the `ColorScheme` swap with no
|
||||
per-screen work, which is exactly why this milestone is affordable.
|
||||
|
||||
It is still the phase's correctness risk rather than its bulk: leaving a direct
|
||||
`ShardCardTop` import behind is a card that stays blue on a Fantasy shard, and
|
||||
nothing fails to compile. A grep for `ui.theme.Shard` imports outside
|
||||
`ui/theme/` — expected to return only the semantic constants once phase 1
|
||||
lands — is the cheap check, and belongs in the phase's PR description.
|
||||
|
||||
**Accent handling changes.** `RunicGatewayTheme(accent: Color?)` currently copies
|
||||
one color onto `primary`/`secondary`/`tertiary`. That was a reasonable stand-in
|
||||
for a one-field contract and is now wrong twice over: it puts `--accent` on
|
||||
`primary`, which the table above assigns to `--accent-bright`, and it ignores the
|
||||
other fourteen. It is replaced by `RunicGatewayTheme(appearance: SiteAppearance)`.
|
||||
`brand.accent` remains the fallback for `--accent` when `theme` is absent but the
|
||||
env accent is set — which is exactly the pre-feature branding path, and must keep
|
||||
working.
|
||||
|
||||
### 5.2 Radii — a ratio, not a literal
|
||||
|
||||
The app's `Shapes` came from the M5 mockup, not from `theme.css`, and the two
|
||||
scales genuinely differ:
|
||||
|
||||
| | web | app |
|
||||
|---|---|---|
|
||||
| input / chip | `--radius-input` 8px | `extraSmall`/`small` 8dp |
|
||||
| card | `--radius-card` 10px | `medium` **12**dp |
|
||||
| panel | `--radius-panel` 12px | `large` **16**dp |
|
||||
| — | — | `extraLarge` 24dp |
|
||||
| pill | `--radius-pill` 999px | `CircleShape` at call sites |
|
||||
|
||||
A literal mapping would restyle the untouched app the moment this milestone
|
||||
ships — `medium` 12→10, `large` 16→12 — which §2 forbids. Copying the app's
|
||||
scale into the server is worse: a second source of truth.
|
||||
|
||||
**Decision: apply the radii as a ratio.** For each of the four fields compute
|
||||
`resolved ÷ runic-gateway baseline`, then scale the app's own shipped dp value by
|
||||
it. Consequences, all of them wanted:
|
||||
|
||||
- an untouched instance, or one that explicitly picks `runic-gateway`, gives
|
||||
ratio `1.0` for all four and is a **provable no-op**;
|
||||
- Fantasy (`--radius-panel: 3px`) → ratio `0.25` → `large` 16dp → 4dp: sharp
|
||||
corners, at the app's own scale;
|
||||
- Modern (8px) → `0.667` → 12dp;
|
||||
- `extraLarge` has no web counterpart and follows `--radius-panel`'s ratio, since
|
||||
it is the panel family;
|
||||
- `--radius-pill` at 999 keeps `CircleShape`; below ~50% of baseline it resolves
|
||||
to a rounded rect, so an admin who squares the site off squares off the app's
|
||||
chips too.
|
||||
|
||||
Round to whole dp and clamp at 0.
|
||||
|
||||
### 5.3 Fonts — bundled, mapped by first family
|
||||
|
||||
The shortlist is 12 options across three roles, spanning **eight** families:
|
||||
|
||||
- **serif** — EB Garamond, Merriweather, Playfair Display, IM Fell English, Georgia*
|
||||
- **display** — Cinzel, Playfair Display, EB Garamond, IM Fell English
|
||||
- **sans** — Inter, Work Sans, Source Sans 3, Helvetica Neue / Arial*
|
||||
|
||||
\* system stacks with no webfont; on Android these resolve to the platform
|
||||
`FontFamily.Serif` / `FontFamily.SansSerif`, which is what the app uses today.
|
||||
|
||||
**Cinzel is already bundled** (`res/font/cinzel_variable.ttf`, M5). Seven more
|
||||
are added: EB Garamond, Merriweather, Playfair Display, IM Fell English, Inter,
|
||||
Work Sans, Source Sans 3. All SIL OFL; each needs its license file under
|
||||
`app/licenses/`, **not** under `res/font/` (aapt rejects a `.txt` there — the M5
|
||||
gotcha).
|
||||
|
||||
Downloadable fonts were rejected: they need the Play Store font provider, so a
|
||||
de-Googled device silently falls back, and every text style gains an async
|
||||
loading state.
|
||||
|
||||
Resolution is by **the first family name in the stack**, which is how the value
|
||||
is constructed server-side and the only part that carries the choice:
|
||||
|
||||
```
|
||||
"'EB Garamond', Georgia, serif" → EBGaramond
|
||||
"Cinzel, Georgia, serif" → Cinzel
|
||||
"Georgia, \"Times New Roman\", serif" → FontFamily.Serif (system)
|
||||
"\"Helvetica Neue\", Arial, sans-serif" → FontFamily.SansSerif (system)
|
||||
<anything unrecognized> → the role's shipped family
|
||||
```
|
||||
|
||||
The three roles map onto `Type.kt`'s existing three groups verbatim:
|
||||
`--display` → the Cinzel display/headline/title block, `--serif` → the `AppSerif`
|
||||
body block, `--sans` → the `AppSans` label block. Sizes, weights and tracking do
|
||||
not move — only the family.
|
||||
|
||||
**IM Fell English has no bold weight** (the website doc records the same). A
|
||||
`FontWeight.Bold` request against it must resolve to its single weight rather
|
||||
than synthesize; check what Compose does here on device and pin the behavior in
|
||||
the phase's notes.
|
||||
|
||||
APK cost: roughly **1.5–2.5 MB** across seven families, variable-axis where Google
|
||||
Fonts publishes one (Cinzel, EB Garamond, Merriweather, Playfair Display, Inter,
|
||||
Work Sans, Source Sans 3) and single-weight for IM Fell English. Measure the
|
||||
release APK before and after, and record both numbers in the PR — R8 does not
|
||||
shrink `res/font/`.
|
||||
|
||||
### 5.4 Shadow depth
|
||||
|
||||
`--shadow-card` is one of four closed values. Compose has no CSS box-shadow, so
|
||||
it maps to card elevation:
|
||||
|
||||
| stored value | elevation |
|
||||
|---|---|
|
||||
| `none` | 0dp |
|
||||
| `0 8px 20px rgba(0,0,0,0.25)` (Soft) | 2dp |
|
||||
| `0 14px 34px rgba(0,0,0,0.3)` (Default) | 4dp |
|
||||
| `0 18px 44px rgba(0,0,0,0.45)` (Deep) | 8dp |
|
||||
|
||||
Matched by exact string against the server's `SHADOW_OPTIONS`; anything else is
|
||||
the shipped default. Applied to `FeatureCard` and the Material `Card` defaults.
|
||||
|
||||
### 5.5 When the appearance is (re-)read
|
||||
|
||||
Today `AppViewModel.loadBrand()` calls `GET /public/settings` **once**, on
|
||||
process start or on connect, and holds a `BrandDto`. That becomes a
|
||||
`SiteAppearance` — `{brand, theme, navPublic}` — held in the same place and
|
||||
refreshed:
|
||||
|
||||
- **on connect** and **on process start** (as today);
|
||||
- **on resume**, beside the existing `sessionViewModel.revalidate()`. An admin
|
||||
changing the theme on a laptop and picking the phone up should see it, and the
|
||||
app already pays for a resume round-trip.
|
||||
|
||||
The authenticated `GET /api/v1/settings/nav` is fetched only when the session is
|
||||
signed in, and re-fetched when the session changes — the same lifecycle
|
||||
`ShardFeaturesRepository` already uses (M11). Signing out drops the cached admin
|
||||
and player overrides.
|
||||
|
||||
Every one of these is best-effort. A failed refresh keeps the last good
|
||||
appearance; there is no loading state and no error surface.
|
||||
|
||||
### 5.6 Brand assets — the logo and the hero
|
||||
|
||||
`brand.logo` and `brand.hero` have been in `BrandDto` since M1 and have **never
|
||||
been rendered**; the app draws `brand.name` as text everywhere the website draws
|
||||
a logo. Nothing new is needed to fetch them — they already arrive resolved, and
|
||||
`AppViewModel.resolveAsset` / `LocalAssetResolver` already turn a site-relative
|
||||
`/uploads/…` path into an absolute URL. Coil is already a dependency.
|
||||
|
||||
Two surfaces, chosen to mirror the website's without inventing new layout:
|
||||
|
||||
- **the drawer header**, above the instance name that sits there today;
|
||||
- **the top bar**, replacing the uppercased name when a logo exists.
|
||||
|
||||
And the hero on **Home**, above the title block, which is the one screen with a
|
||||
hero-shaped space.
|
||||
|
||||
The M5 `BrandLogo` rule carries over: **render nothing when the slot is empty.**
|
||||
Not a placeholder, not a reserved gap — an instance with no uploaded logo must
|
||||
lay out exactly as it does today, which is §2 applied to assets. On the centered
|
||||
surfaces the website stacks the logo *above* rather than beside, for the same
|
||||
reason: a row would change the block's height on instances that have no logo.
|
||||
|
||||
An asset that fails to load is the same as no asset. No broken-image icon, no
|
||||
retry.
|
||||
|
||||
## 6. Navigation
|
||||
|
||||
### 6.1 The hard constraint carries over
|
||||
|
||||
The website's §7 constraint is a security boundary and it survives verbatim here,
|
||||
with one clarification the app makes concrete: **an override is presentation.**
|
||||
|
||||
The app's two gates — `MenuAccess` against the session, and `MenuEntry.feature`
|
||||
against `GET /public/shard/features` (M11) — run **after** the override merge and
|
||||
are unchanged by it. An override cannot introduce an app route, cannot touch
|
||||
`access` or `feature`, and `hidden: false` never un-hides an entry the caller's
|
||||
role or the shard's visibility config would otherwise withhold. Hiding is
|
||||
subtractive, exactly as `applyNavOverrides` has it.
|
||||
|
||||
### 6.2 Path → app route
|
||||
|
||||
The public nav is keyed by **website** paths. The app needs a mapping table, and
|
||||
it is the one new piece of cross-repo coupling this milestone introduces — so it
|
||||
lives in one file with the website's `NAV` array quoted beside it.
|
||||
|
||||
| website `to` | app route | note |
|
||||
|---|---|---|
|
||||
| `/` | `Routes.HOME` | |
|
||||
| `/site/news` | `Routes.NEWS` | |
|
||||
| `/site/five-on-friday` | `Routes.news(FIVE_ON_FRIDAY)` | the app's News screen already has all four categories as tabs — these three select one |
|
||||
| `/site/newsletter` | `Routes.news(NEWSLETTER)` | |
|
||||
| `/site/screenshots` | `Routes.news(SCREENSHOTS)` | |
|
||||
| `/wiki` | `Routes.WIKI` | |
|
||||
| `/site/shard` | `Routes.SHARD` | `feature: status` |
|
||||
| `/site/champs` | `Routes.SHARD_CHAMPS` | **not in `APP_MENU` today** — reached via the Shard hub |
|
||||
| `/site/guilds` | `Routes.SHARD_GUILDS` | as above |
|
||||
| `/site/governors` | `Routes.SHARD_GOVERNORS` | as above |
|
||||
| `/site/houses` | `Routes.SHARD_HOUSES` | as above |
|
||||
| `/site/rules` | `Routes.SHARD_RULES` | |
|
||||
| `/site/atlas` | `Routes.ATLAS` | |
|
||||
| `/site/leaderboards` | `Routes.SHARD_LEADERBOARDS` | |
|
||||
| `/site/market` | `Routes.SHARD_MARKET` | |
|
||||
| `/site/about` | `Routes.page("about")` | |
|
||||
|
||||
Three asymmetries to resolve rather than paper over:
|
||||
|
||||
- **`Routes.NEWS` takes no category argument today.** It gains an optional one so
|
||||
the three category entries can land on the right tab. This is a small route
|
||||
change with its own test, not a nav concern.
|
||||
- **Four web entries have no `APP_MENU` row** (champs / guilds / governors /
|
||||
houses — the app puts them behind the Shard hub, which is the better phone
|
||||
shape and stays). An override for one of them therefore has a mapped route but
|
||||
no menu entry. **Rule: an override for a path the app does not surface in its
|
||||
menu is ignored**, exactly as the web drops an override for an unknown `to`.
|
||||
It is *not* an invitation to add the entry — the hub is a deliberate design
|
||||
choice, and a nav override may not introduce navigation.
|
||||
- **Ten app entries have no `nav_public` counterpart** — Contact, Account and
|
||||
Notifications, the three player groups, and the four staff rows. They are
|
||||
unaffected by `nav_public` and keep their coded order, appended after the
|
||||
overridden public block in the drawer. (They already sit below the public
|
||||
entries today, so this is the current layout, not a new one.) A few of them are
|
||||
instead reachable through `nav_admin` / `nav_player` — but fewer than you would
|
||||
expect, which is §6.4's subject.
|
||||
|
||||
### 6.3 Sections and added links
|
||||
|
||||
`nav_public` may carry `sections` and `links` (website phase 10). Both land in
|
||||
the drawer:
|
||||
|
||||
- **A section** renders as a drawer group with its label as a header and its
|
||||
members indented beneath — the drawer's natural idiom. The website's
|
||||
click-to-open dropdown does not translate and is not copied; a drawer is
|
||||
already a vertical list.
|
||||
- **`pruneNav`'s rule is ported and is load-bearing**: a section whose every
|
||||
member is hidden by the role or feature gate must not render as an empty
|
||||
header. The app's port drops it.
|
||||
- **An added link** carries no gate and always shows, matching the web. Its `to`
|
||||
is validated the same way the web validates it on read — must start with a
|
||||
single `/`, no `//`, no whitespace or quote characters — and a value failing
|
||||
that is dropped rather than rendered.
|
||||
|
||||
An added link **opens natively when its path maps to an app route**, and hands
|
||||
off to a Custom Tab otherwise. The patterns the app can resolve:
|
||||
|
||||
```
|
||||
/ → HOME
|
||||
/site/news|five-on-friday|newsletter|screenshots → NEWS (category)
|
||||
/site/news/<idOrSlug> → POST
|
||||
/wiki → WIKI
|
||||
/wiki/<slug> → WIKI_PAGE
|
||||
/site/<shard surface> → the mapped shard route (per §6.2)
|
||||
/site/about, /page/<slug> → PAGE
|
||||
/contact → CONTACT
|
||||
anything else → WebHandoff (Custom Tab), M3's existing hand-off
|
||||
```
|
||||
|
||||
A native match still passes through the app's own gates: an added link to
|
||||
`/site/market` on a shard that does not publish the market lands on the Market
|
||||
screen's honest "not published here" state (M11's `FEATURE_UNAVAILABLE`), which
|
||||
is what typing the URL on the web does too. The link itself is not gated — that
|
||||
is the website's decision and the app does not second-guess it.
|
||||
|
||||
### 6.4 `nav_admin` and `nav_player` — label and hidden only
|
||||
|
||||
Both are bare maps and neither carries sections or links. The app honors
|
||||
**`label` and `hidden`, and ignores `order` and `group`.**
|
||||
|
||||
The reason is that the app's rows are a small and *differently shaped* subset —
|
||||
and the actual overlap was measured before scoping the phase, because it turned
|
||||
out to be thinner than the milestone assumed:
|
||||
|
||||
| website `to` | app route | |
|
||||
|---|---|---|
|
||||
| `/player` | `PLAYER_CHARACTERS` | ✅ |
|
||||
| `/account` | `ACCOUNT` | ✅ |
|
||||
| `/account/appeals` | — | the app has no appeals screen at all |
|
||||
| `/admin` | `ADMIN_DASHBOARD` | ✅ |
|
||||
| `/admin/moderation` | `ADMIN_MODERATION` | ✅ |
|
||||
| — | `ADMIN_CONTENT` | an app-side aggregate of the website's separate Posts / Pages / Wiki / Activity rows |
|
||||
| — | `ADMIN_SUPPORT` | likewise; the nearest web row is `/admin/moderation/appeals`, which is not the same screen |
|
||||
| — | `PLAYER_VENDORS`, `PLAYER_HOUSES` | no player-portal row on the web |
|
||||
| — | `NOTIFICATIONS`, `CONTACT` | app-only surfaces |
|
||||
|
||||
**So `nav_player` reaches two app rows and `nav_admin` reaches two.** The website
|
||||
sidebar's other ~18 rows are admin *configuration* the app deliberately excludes
|
||||
(M10/M11), and two of the app's four staff entries are aggregates with no single
|
||||
web row to be renamed from.
|
||||
|
||||
That is the whole case for label-and-hidden-only. Reordering two rows against a
|
||||
foreign order of twenty-two is noise, and `group` names sections the app does not
|
||||
render. Renaming "Characters" or hiding it is still a real intent that should
|
||||
reach the phone, and four rows' worth of it is worth one cached call.
|
||||
|
||||
**It is also the case for questioning whether phase 7 is worth building at all.**
|
||||
Four rows is a thin return for a new authenticated fetch, a session-keyed cache
|
||||
and its teardown. It is scheduled last precisely so that decision can be taken
|
||||
with the rest of the milestone already working — dropping it costs nothing that
|
||||
phases 0–6 depend on. Unmapped keys are ignored either way.
|
||||
|
||||
**A label override replaces a `@StringRes`.** `MenuEntry.labelRes` is an int; the
|
||||
resolved entry carries `label: String?` beside it and the drawer prefers it. That
|
||||
means an admin's label is **not localized** — it is one string for every locale,
|
||||
which is what an admin typing a label means, and matches the website.
|
||||
|
||||
## 7. Acceptance criteria
|
||||
|
||||
- **AC-1 — the no-op proof.** With `theme` absent, `nav_public` absent and no
|
||||
`brand_assets`, the resolved `ColorScheme`, `Shapes`, `Typography` and drawer
|
||||
entry list are **equal** to today's shipped values. A unit test asserts the
|
||||
full `ColorScheme` equality, not a spot check.
|
||||
- **AC-2 — per-field fallback.** A `theme` map carrying one valid token and four
|
||||
malformed ones applies the one and falls back on the four.
|
||||
- **AC-3 — the gates still hold.** An override marking a feature-gated or
|
||||
role-gated entry `hidden: false` shows nothing to a caller who fails that gate.
|
||||
A section whose members are all gated out does not render.
|
||||
- **AC-4 — degradation.** With the settings call failing, the app renders the
|
||||
shipped theme and the coded menu, with no error surface.
|
||||
- **AC-5 — on-device.** Two passes on the AVD against a local website:
|
||||
- one against an instance themed **Fantasy**, with a reordered and sectioned
|
||||
nav, one added link of each kind (native-mapped and Custom-Tab), and an
|
||||
uploaded logo and hero;
|
||||
- one against an **untouched** instance, confirming AC-1 by eye as well as by
|
||||
test — this is the pass that catches a token a screen never read.
|
||||
|
||||
The role dimension reuses the existing five-rung walk (`anonymous`,
|
||||
`logged_in`, `player`, `staff`, `admin`) from [`../link/v3.md`](../link/v3.md)
|
||||
§11, since §6.1's whole claim is that the override merge does not disturb the
|
||||
gates.
|
||||
|
||||
## 8. Build phases
|
||||
|
||||
Every phase targets **`edge`** in `Android-app/` and `docs/`, cut fresh from
|
||||
`main` in both. The feature reaches `main` as **one `edge` → `main` merge** when
|
||||
all phases are done — the same shape the website side used. Do not open a phase
|
||||
PR against `main`.
|
||||
|
||||
| # | Phase | Ships |
|
||||
|---|---|---|
|
||||
| **0** | **Contract & appearance store** | `SettingsDto` gains `theme: Map<String,String>?` and `nav_public: String?`; `SiteAppearance` replaces the bare `BrandDto` in `AppViewModel`; second-stage JSON parse; resume refresh (§5.5). **No visual change** — this phase must be invisible |
|
||||
| **1** | **Colors** | `ShardPalette` + `LocalShardPalette`; all direct `Color.kt` imports migrated; `RunicGatewayTheme(appearance)`; AC-1 + AC-2 tests |
|
||||
| **2** | **Radii & shadow** | Ratio-scaled `Shapes` (§5.2), elevation map (§5.4) |
|
||||
| **3** | **Fonts** | Seven bundled families + licenses; stack → `FontFamily` resolution; `Type.kt` takes its three families from the resolved theme. APK size recorded |
|
||||
| **4** | **Brand assets** | Logo in the drawer header and top bar, hero on Home (§5.6). Coil + `LocalAssetResolver` already exist; renders nothing when unset |
|
||||
| **5** | **Public nav: label / order / hidden** | The path→route table (§6.2), `Routes.news(category)`, the merge, drawer wiring. AC-3 |
|
||||
| **6** | **Public nav: sections & added links** | Drawer groups, `pruneNav` port, link path validation, native-route resolution + Custom Tab fallback (§6.3) |
|
||||
| **7** | **Authenticated navs** | `GET /api/v1/settings/nav` behind a session-keyed repository; label/hidden for the four mapped rows (§6.4). **Optional — reconsider before starting it** |
|
||||
| **8** | **Docs, coverage & cutover** | This doc's "as landed" notes and any amendments the build forces, the `PLAN.md` §9 M12 entry refreshed, Sonar coverage for the new modules, AC-5 on-device walk, then `edge` → `main` |
|
||||
|
||||
Phase 0 is the one with a hard rule attached: **it must change nothing on
|
||||
screen.** Everything after it is additive on top of a store that is already
|
||||
proven not to have moved anything.
|
||||
|
||||
## 9. Out of scope
|
||||
|
||||
- **Light mode / a light preset.** The website cancelled its Parchment phase; the
|
||||
app stays dark-only.
|
||||
- **Favicon.** No app surface.
|
||||
- **Editing any of this from the app.** The M10 staff surface does not include
|
||||
Appearance or Navigation, and this milestone does not add them. The app is a
|
||||
consumer.
|
||||
- **A theme preview.** Out of scope on the web too.
|
||||
- **Per-screen restyling.** If a screen looks wrong under a warm preset, that is
|
||||
a token the screen should have been reading and did not — fix the call site,
|
||||
do not add a special case.
|
||||
Reference in New Issue
Block a user