docs(android): M12 phase 4 as landed

§5.6 gains the three decisions the drafted section left open — the top bar's
text fallback, the hero's fixed cropped band, and the accessibility split
between a decorative logo and a named one — each with the reasoning that
settled it, so none is re-litigated from scratch. Adds the 32dp/24dp logo
heights, the six-times-height width cap, and the note that the app takes no
fallback hero image where the website substitutes its own emblem.

"Phase 4 as landed" records how the empty-slot rule is enforced by layout
rather than by a conditional at each call site, why the blank check runs on
both sides of the resolver, and the honest limit of the phase's tests: the
drawing cannot be tested here at all, since the app has no Robolectric and no
androidTest source set, so only the decision of whether to draw is pure.

Phase 4 ticked in §8.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-08 06:14:36 -05:00
parent 6526f32a42
commit 317dc310fa

View File

@@ -348,12 +348,16 @@ a logo. Nothing new is needed to fetch them — they already arrive resolved, an
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.
- **the drawer header**, above the instance name that sits there today, at 32dp;
- **the top bar**, replacing the uppercased name when a logo exists, at 24dp.
And the hero on **Home**, above the title block, which is the one screen with a
hero-shaped space.
Both logo slots cap their width at **six times their height**, mirroring the
website's `maxWidth: height * 6`, so a long wordmark scales down rather than
pushing the drawer header or the top bar's title out of shape.
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
@@ -363,6 +367,35 @@ 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.
**The top bar is the one place where "empty" is not "nothing".** Everywhere else
the empty slot draws literally nothing, because something else on the surface
already says the instance's name. In the top bar the logo *is* the title, so a
404 — or a logo that can't be fetched because the shard is down — would leave
the app in an unnamed shell until the next resume refresh. There "the same as no
asset" resolves to the text, since the text is what an instance with no logo
shows. There is deliberately **no fallback while the load is still in flight**:
drawing the text first would flash text → logo on every navigation for the sake
of one frame, as Coil serves the second and later reads from its memory cache.
**The hero is a fixed 180dp band, cropped**, rather than the intrinsic aspect
ratio the app's other images (`PostScreen`, `BlockRenderer`) draw at. The
website's hero is a CSS background driven by `hero_layout`, which the app does
not port, so the app needs a rule of its own — and the website's *default* hero
is a square emblem (`/assets/img/runic-emblem.png`), so an uploaded square is a
case to expect rather than an edge one. At the intrinsic aspect that square
would be a ~360dp block that pushes the status card off the first screenful;
cropped to a band, a wide banner and a square give the same frame above the
title. It clips to `shapes.medium`, so the hero follows `--radius-card` like
every other surface the admin can round off (§5.2). Note that the app takes **no
fallback image**: where the website substitutes its own emblem for an unset
hero, the app draws nothing, because §2 outranks the mirror.
Accessibility follows the website's split: the logo is **decorative wherever the
name is also on screen in text** (the drawer header, where the name is the very
next line) and **named only where it stands alone** (the top bar). Describing
the drawer's would have a screen reader say the instance's name twice — the same
call the website's `alt=''` makes. The hero is always decorative.
## 6. Navigation
### 6.1 The hard constraint carries over
@@ -551,7 +584,7 @@ PR against `main`.
| **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 |
| **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** | ❌ **cancelled** — was: `GET /api/v1/settings/nav` behind a session-keyed repository; label/hidden for the four mapped rows (§6.4). See "Phase 7, cancelled" below |
@@ -739,6 +772,47 @@ proven not to have moved anything.
device — that is AC-5, in phase 8, where IM Fell English's synthesised bold is
the thing to look at.
### Phase 4 as landed
- **The empty slot is enforced by layout, not by a conditional at each site.**
Every size and spacing modifier hangs off the image itself, so when the image
is not composed neither is its padding. A caller that wants space below the
hero passes `Modifier.padding(bottom = 16.dp)` rather than a sibling `Spacer`,
and both cases — asset and no asset — come out right without the caller
knowing which it got. This is the detail that makes "renders nothing when
unset" hold without a `brand?.hero != null` check leaking into `HomeScreen`.
- **Three §5.6 questions the spec left open, settled by the org lead before
code**: the top bar falls back to the text rather than going blank; the hero
is a fixed cropped band rather than its intrinsic aspect; the hero sits inside
Home's existing 20dp padding with themed corners rather than going full-bleed.
All three are written into §5.6 above with their reasoning, so they are not
re-litigated from scratch.
- **A failed load is keyed on the URL.** `remember(url)` resets the failure flag
when a resume refresh (§5.5) swaps the logo, so an instance that fixes a broken
upload recovers on the next refresh instead of inheriting the old failure for
the life of the process.
- **The blank check runs on both sides of the resolver.** `BrandDto` defaults
every asset field to `""` rather than null — the server publishes the empty
string for "not set" — and a resolver with no base URL configured may hand a
path straight back. Null out of `brandAssetUrl` is the "draw nothing" signal,
so a blank slipping through either side would put a zero-size image request in
the layout instead of no image at all.
- **No new dependency, no new asset, no APK cost.** Unlike phase 3 this phase
adds nothing to the package: `LocalAssetResolver` (M1) and Coil (M5) were both
already there, and `BrandDto` has carried `logo` and `hero` since M1 without a
reader. The whole phase is one new file plus three call sites.
- Tests: `BrandAssetsTest` (9 — every shape "not set" arrives in, the default
`BrandDto`, `SiteAppearance.NONE`, site-relative and absolute paths, and a
resolver that returns null or blank). **410 unit tests green** (401 + 9),
`lintDebug` and `assembleDebug` clean. **The drawing itself is untested and
cannot be tested here** — the app carries no Robolectric and has no
`androidTest` source set, so a composable body cannot run in a JVM test. Only
the decision of *whether* to draw is pure, which is why `brandAssetUrl` is
pulled out of the composables at all. Everything else about this phase is
AC-5's to catch, and it is the phase with the most riding on that walk: a
cropped hero, a synthesised bold, and a logo's contrast against the top bar
are all things only a screen shows.
### Phase 7, cancelled
Reading `nav_admin` / `nav_player` is **not scheduled**. The measurement in §6.4