From 317dc310faaa571b652128a21e1480c0c140a8bb Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 8 Aug 2026 06:14:36 -0500 Subject: [PATCH] docs(android): M12 phase 4 as landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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 --- android/THEMING_AND_NAV.md | 80 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/android/THEMING_AND_NAV.md b/android/THEMING_AND_NAV.md index bc63fb9..ef8d8a1 100644 --- a/android/THEMING_AND_NAV.md +++ b/android/THEMING_AND_NAV.md @@ -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