docs(android): M12 phase 3 as landed

§5.3 rewritten against what the build measured rather than what the plan
estimated, plus a "Phase 3 as landed" section and the  in §8.

Three corrections to the drafted section:

- The APK estimate was wrong by more than 3×. "Roughly 1.5-2.5 MB" becomes a
  measured before/after table: 5,031,411 B → 13,574,703 B, +8.15 MiB. The
  section now records that Merriweather is 6.08 MiB of that on its own, and the
  two cheaper options the org lead costed and declined, so the number is not
  re-litigated from scratch later.
- Family lookup must be global, never scoped to the role's option list. Two of
  the three presets publish a font their own role's dropdown does not offer,
  because resolveThemeTokens copies preset tokens verbatim — the same bypass
  §5.4 already records for --shadow-card.
- Italics mirror the website's four rather than being skipped, and every
  bundled family supplies the four weights the scale asks for (with IM Fell
  English's single weight and Cinzel's untouched 500/600/700 as the two
  documented exceptions), because a family is not confined to the role its
  dropdown lives in.

Android-app: RunicGateway/Android-app#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-08 05:46:02 -05:00
parent ea8c5b9a42
commit d95632db04

View File

@@ -239,21 +239,57 @@ is constructed server-side and the only part that carries the choice:
<anything unrecognized> → the role's shipped family <anything unrecognized> → the role's shipped family
``` ```
**The lookup is one global map, never scoped to the role's option list**
settled in phase 3, and the same trap §5.4 hit with `--shadow-card`. The server
validates an *admin-entered* font against `FONT_OPTIONS[role]`, but a preset's
tokens are copied verbatim by `resolveThemeTokens` and never pass through that
list: `modern` publishes `--display: 'Work Sans', Arial, sans-serif`, which the
display dropdown does not offer, and `fantasy` publishes
`--sans: 'EB Garamond', Georgia, serif`, which the sans dropdown does not either.
A per-role lookup would have missed the display face of one preset and the label
face of the other.
The three roles map onto `Type.kt`'s existing three groups verbatim: The three roles map onto `Type.kt`'s existing three groups verbatim:
`--display` → the Cinzel display/headline/title block, `--serif` → the `AppSerif` `--display` → the Cinzel display/headline/title block, `--serif` → the `AppSerif`
body block, `--sans` → the `AppSans` label block. Sizes, weights and tracking do body block, `--sans` → the `AppSans` label block. Sizes, weights and tracking do
not move — only the family. not move — only the family.
**IM Fell English has no bold weight** (the website doc records the same). A Because a family is not confined to the role its dropdown lives in, **every
`FontWeight.Bold` request against it must resolve to its single weight rather bundled family supplies all four weights the scale asks for** — 400 (body), 500
than synthesize; check what Compose does here on device and pin the behavior in and 700 (labels), 600 (display) — pinned through `FontVariation` on the variable
the phase's notes. faces. Two exceptions, both upstream facts rather than choices:
APK cost: roughly **1.52.5 MB** across seven families, variable-axis where Google - **IM Fell English has one weight per style.** Its 400 face answers all four
Fonts publishes one (Cinzel, EB Garamond, Merriweather, Playfair Display, Inter, requests and Android synthesises the bold; the website's dropdown labels it
Work Sans, Source Sans 3) and single-weight for IM Fell English. Measure the "(no bold weight)" for the same reason. What that synthesis actually looks like
release APK before and after, and record both numbers in the PR — R8 does not is an AC-5 observation, not something a unit test can pin.
shrink `res/font/`. - **Cinzel keeps the 500/600/700 it shipped with in M5.** It is the only family
the server offers in the display role alone, so nothing can ask it for 400 and
adding an instance would have edited M5's type for no reachable case.
**Italics mirror the website's set**, decided in phase 3: EB Garamond, IM Fell
English, Merriweather and Playfair Display carry a true italic, exactly the four
`client/index.html` requests one for. Inter, Work Sans, Source Sans 3 and Cinzel
are upright-only and Compose skews them — which is what the app already did for
every family before this milestone, and what the web does for its own
upright-only faces. The app draws italic in two places.
APK cost, **measured, not estimated** — the drafted "roughly 1.52.5 MB" was
wrong by more than 3×. Unsigned release APK, R8 full-mode + resource shrink, on
`edge` at the phase 2 merge:
| | added (compressed) | release APK |
|---|---|---|
| before phase 3 | — | 5,031,411 B — 4.80 MiB |
| after phase 3 | 8,543,292 B — 8.15 MiB | 13,574,703 B — **12.94 MiB** |
**Merriweather is 6.08 MiB of the 8.15** — upstream ships it as a three-axis
`[opsz,wdth,wght]` variable font with a full charset, 4.6 MB per style, and it
deflates only 31% where the others manage 5060%. Bundling it verbatim anyway was
the org lead's call, taken with the cheaper options costed: Google's own static
400/700 builds would have held the whole app near 6.8 MiB, and not bundling it at
all near 6.2 MiB, at the price of a serif option that silently does nothing on
Android. R8 does not shrink `res/font/`; the APK's deflate is the only saving.
### 5.4 Shadow depth ### 5.4 Shadow depth
@@ -514,7 +550,7 @@ PR against `main`.
| **0** ✅ | **Contract & appearance store** | `SettingsDto` gains `theme` and `nav_public`; `SiteAppearance` replaces the bare `BrandDto` in `AppViewModel`; second-stage JSON parse; resume refresh (§5.5). **No visual change** — this phase must be invisible | | **0** ✅ | **Contract & appearance store** | `SettingsDto` gains `theme` and `nav_public`; `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 | | **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) | | **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 | | **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 | | **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 | | **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) | | **6** | **Public nav: sections & added links** | Drawer groups, `pruneNav` port, link path validation, native-route resolution + Custom Tab fallback (§6.3) |
@@ -660,6 +696,49 @@ proven not to have moved anything.
in phase 8, where the new shadow should be looked at with the flat build beside in phase 8, where the new shadow should be looked at with the flat build beside
it. it.
### Phase 3 as landed
- **The APK nearly tripled, and that was a decision rather than a discovery.**
§5.3 now carries the measured before/after and the two cheaper options that
were costed and declined. The one number worth remembering: Merriweather is
75% of the payload, because upstream publishes it as a three-axis variable font
that barely compresses. Anyone revisiting the app's size should start there and
nowhere else.
- **The per-role font list is not the set of values a role can hold.** Two of the
three presets publish a font their own role's dropdown does not offer (§5.3).
The resolution map is therefore global, keyed by the lowercased first family
name, and the tests assert both preset cases by name so a future per-role
"tidy-up" fails loudly.
- **`Typography` implements `equals`** — like phase 2's `Shapes` and 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, so a stray edit to a size or a letter-spacing in `Type.kt` fails there
rather than quietly redefining what "shipped" means.
- **No `LocalShardTypeface`, deliberately.** The palette and the structure each
needed a composition local for the parts `MaterialTheme` cannot carry; the
families need none. Every text style in the app comes from
`MaterialTheme.typography`, and the two places that override anything
(`NotificationsScreen`'s hint, `BlockRenderer`'s italic title) override the
*style*, not the family. `FontFamily.Monospace` on the recovery-codes screen
stays fixed, as a semantic choice rather than a themed one.
- **`Type.kt`'s `val Typography` became `shardTypography(faces)`**, which is the
whole migration: the three families were referenced from that one file and
nowhere else, so unlike phase 1's colour imports and phase 2's 24 `Card(` sites
there was no call-site sweep at all.
- **Licences live in `app/licenses/`**, one `*-OFL.txt` per family, never under
`res/font/` — aapt rejects a `.txt` there, the M5 gotcha. Three of the seven
carry a Reserved Font Name (Merriweather, Playfair Display, Source Sans 3),
which is a further reason the binaries are taken verbatim rather than subsetted
or instanced locally.
- Tests: `ShardTypefaceTest` (15 — the shipped families and the pre-M12 scale, the
`runic-gateway` no-op, both preset bypasses by name, all thirteen shortlist
options, first-name parsing against quoting/casing/whitespace, per-field
fallback, a colours-only theme, blank and comma-only stacks, and a themed scale
proved to differ from the shipped one *only* in its families). **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.
### Phase 7, cancelled ### Phase 7, cancelled
Reading `nav_admin` / `nav_player` is **not scheduled**. The measurement in §6.4 Reading `nav_admin` / `nav_player` is **not scheduled**. The measurement in §6.4