feat(brand): draw the shard's logo and hero (M12 phase 4) #37

Merged
whitlocktech merged 1 commits from feat/m12-phase-4-brand-assets into edge 2026-08-08 11:52:05 +00:00
Member

Phase 4 of M12 (docs/android/THEMING_AND_NAV.md §5.6) — the brand assets, after the theme's three thirds (colors, structure, fonts). Targets edge; the milestone reaches main as one edgemain merge.

brand.logo and brand.hero have ridden in BrandDto since M1 and neither has ever been drawn — the app spells the instance out in text everywhere the website shows a mark. This renders them on the three surfaces §5.6 names: the logo above the name in the drawer header (32dp), the logo in place of the uppercased title in the top bar (24dp), and the hero as a band above Home's title block.

Nothing new is fetched and nothing new is bundled. LocalAssetResolver (M1) already turns a site-relative /uploads/… path into an absolute URL and Coil (M5) is already a dependency, so this phase adds no APK cost at all — one new file and three call sites.

The empty slot is enforced by layout, not by a conditional at each site

§5.6's rule is that an empty slot renders nothing — not a placeholder, not a reserved gap. The way that holds without a brand?.hero != null check leaking into HomeScreen: 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) instead of a sibling Spacer, and gets both cases right without knowing which it got.

A failed load is an empty slot too — no broken-image icon, no retry. The flag is remember(url), so a resume refresh (§5.5) that swaps the logo gets a fresh attempt rather than inheriting the old one's failure for the life of the process.

Three things §5.6 left open, all settled by the org lead before code

1. The top bar falls back to the text. It is the one place where "empty" is not "nothing": the logo is the title there, so a 404 — or a logo that can't be fetched because the shard is down — would strand the app in an unnamed shell until the next resume refresh. "The same as no asset" resolves to the text, because the text is what an instance with no logo shows. There is deliberately no fallback while the load is in flight: drawing text first would flash text → logo on every navigation for one frame, since Coil serves the second and later reads from memory.

2. The hero is a fixed 180dp band, cropped, not the intrinsic aspect 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 its own rule — and the website's default hero is a square emblem, so an uploaded square is a case to expect rather than an edge one. At the intrinsic aspect that square is a ~360dp block that pushes the status card off the first screenful. Cropped, a wide banner and a square give the same frame.

3. It sits inside Home's existing 20dp padding, clipped to shapes.medium, so the hero follows --radius-card like every other surface phase 2 gave the admin (§5.2) — rather than going full-bleed, which would have meant restructuring HomeScreen's single padded Column.

Also worth noting the app takes no fallback hero image: where the website substitutes its own emblem for an unset hero, the app draws nothing, because §2 outranks the mirror.

Accessibility

The logo is decorative wherever the name is also on screen (the drawer, where the name is the very next line) and named only where it stands alone (the top bar, whose contentDescription is the instance name). Describing the drawer's would have a screen reader say the name twice — the same call the website's alt='' makes. The hero is always decorative.

Tests, and what they honestly cannot cover

BrandAssetsTest — 9 cases: every shape "not set" arrives in (null, "", whitespace), the default BrandDto, SiteAppearance.NONE, site-relative and absolute paths, and a resolver returning null or blank. That last pair is why the blank check runs on both sides of the resolver: BrandDto defaults every asset field to "" (the server publishes the empty string for "not set"), and a resolver with no base URL configured may hand a path straight back, so a blank slipping through either side would put a zero-size image request in the layout instead of no image.

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 the whole reason brandAssetUrl is pulled out of the composables. Everything else is AC-5's to catch, and this is the phase with the most riding on that walk: a cropped hero, the fallback path, and a logo's contrast against the top bar are all things only a screen shows.

Docs: RunicGateway/docs#116.


  • AI-assisted: written with Claude Code (Claude Opus 5)
Phase 4 of M12 (`docs/android/THEMING_AND_NAV.md` §5.6) — the **brand assets**, after the theme's three thirds (colors, structure, fonts). Targets `edge`; the milestone reaches `main` as one `edge` → `main` merge. `brand.logo` and `brand.hero` have ridden in `BrandDto` since M1 and **neither has ever been drawn** — the app spells the instance out in text everywhere the website shows a mark. This renders them on the three surfaces §5.6 names: the logo above the name in the drawer header (32dp), the logo in place of the uppercased title in the top bar (24dp), and the hero as a band above Home's title block. Nothing new is fetched and nothing new is bundled. `LocalAssetResolver` (M1) already turns a site-relative `/uploads/…` path into an absolute URL and Coil (M5) is already a dependency, so **this phase adds no APK cost at all** — one new file and three call sites. ### The empty slot is enforced by layout, not by a conditional at each site §5.6's rule is that an empty slot renders **nothing** — not a placeholder, not a reserved gap. The way that holds without a `brand?.hero != null` check leaking into `HomeScreen`: 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)` instead of a sibling `Spacer`, and gets both cases right without knowing which it got. A failed load is an empty slot too — no broken-image icon, no retry. The flag is `remember(url)`, so a resume refresh (§5.5) that swaps the logo gets a fresh attempt rather than inheriting the old one's failure for the life of the process. ### Three things §5.6 left open, all settled by the org lead before code **1. The top bar falls back to the text.** It is the one place where "empty" is not "nothing": the logo *is* the title there, so a 404 — or a logo that can't be fetched because the shard is down — would strand the app in an unnamed shell until the next resume refresh. "The same as no asset" resolves to the text, because the text is what an instance with no logo shows. There is deliberately **no fallback while the load is in flight**: drawing text first would flash text → logo on every navigation for one frame, since Coil serves the second and later reads from memory. **2. The hero is a fixed 180dp band, cropped**, not the intrinsic aspect 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 its own rule — and the website's *default* hero is a square emblem, so an uploaded square is a case to expect rather than an edge one. At the intrinsic aspect that square is a ~360dp block that pushes the status card off the first screenful. Cropped, a wide banner and a square give the same frame. **3. It sits inside Home's existing 20dp padding, clipped to `shapes.medium`**, so the hero follows `--radius-card` like every other surface phase 2 gave the admin (§5.2) — rather than going full-bleed, which would have meant restructuring `HomeScreen`'s single padded `Column`. Also worth noting the app takes **no fallback hero image**: where the website substitutes its own emblem for an unset hero, the app draws nothing, because §2 outranks the mirror. ### Accessibility The logo is **decorative wherever the name is also on screen** (the drawer, where the name is the very next line) and **named only where it stands alone** (the top bar, whose `contentDescription` is the instance name). Describing the drawer's would have a screen reader say the name twice — the same call the website's `alt=''` makes. The hero is always decorative. ### Tests, and what they honestly cannot cover `BrandAssetsTest` — 9 cases: every shape "not set" arrives in (null, `""`, whitespace), the default `BrandDto`, `SiteAppearance.NONE`, site-relative and absolute paths, and a resolver returning null or blank. That last pair is why the blank check runs on **both** sides of the resolver: `BrandDto` defaults every asset field to `""` (the server publishes the empty string for "not set"), and a resolver with no base URL configured may hand a path straight back, so a blank slipping through either side would put a zero-size image request in the layout instead of no image. **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 the whole reason `brandAssetUrl` is pulled out of the composables. Everything else is AC-5's to catch, and this is the phase with the most riding on that walk: a cropped hero, the fallback path, and a logo's contrast against the top bar are all things only a screen shows. Docs: RunicGateway/docs#116. --- - [x] AI-assisted: written with Claude Code (Claude Opus 5)
wtclaude added 1 commit 2026-08-08 11:15:12 +00:00
`brand.logo` and `brand.hero` have ridden in `BrandDto` since M1 and neither
has ever been drawn — the app spells the instance out in text everywhere the
website shows a mark. Phase 4 renders them on the three surfaces §5.6 names:
the logo above the name in the drawer header, the logo in place of the
uppercased title in the top bar, and the hero as a band above Home's title
block.

Nothing new is fetched. `LocalAssetResolver` already turns a site-relative
`/uploads/…` path into an absolute URL and Coil is already a dependency, so
this phase is entirely presentation.

The rule that governs the file is §5.6's: an empty slot renders nothing — not
a placeholder, not a reserved gap. Every size modifier hangs off the image
itself, so when the image is not composed neither is its padding, and a caller
that wants space below a hero passes `Modifier.padding` instead of a sibling
`Spacer`. A failed load is an empty slot: no broken-image icon, no retry.

The top bar is the one place where "empty" is not "nothing". The logo replaces
the title there, so a 404 would strand the app in an unnamed shell until the
next resume refresh; it falls back to the text, which is what empty already
showed. There is no fallback while the load is in flight — drawing the text
first would flash text to logo on every navigation for one frame.

The hero is a fixed 180dp band, cropped, rather than the intrinsic aspect the
app's other images draw at. The website's hero is a CSS background driven by
`hero_layout`, which the app does not port, and the website's default hero is a
square emblem — at the intrinsic aspect an uploaded square would be a ~360dp
block that pushes the status card off the first screenful. It clips to
`shapes.medium`, so it follows `--radius-card` like every other surface.

The logo carries a content description only in the top bar, where it stands
alone; beside the name in text it is decorative, the same call the website's
`alt=''` makes.

410 unit tests green (401 + 9), `lintDebug` and `assembleDebug` clean. The
drawing itself is out of reach for JVM tests — the app carries no Robolectric,
so a composable body cannot run — but the decision of *whether* to draw is
pure, and `brandAssetUrl` is pulled out so it can be pinned.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit b95fc45548 into edge 2026-08-08 11:52:05 +00:00
whitlocktech deleted branch feat/m12-phase-4-brand-assets 2026-08-08 11:52:06 +00:00
Sign in to join this conversation.
No description provided.