Second on-device feedback round: eight fixes, and a measured retry policy

The ghost bookcase was an inset bug, not a data bug. LocationsScreen's list
branch dropped the Scaffold's innerPadding while its empty-state branch applied
it, so the first bookcase row rendered under the top app bar and was invisible.
Both of the user's bookcases were always real; they just could not see the first
one, so they made a second. Every other screen was checked for the same class of
bug — Locations was the only one.

Metadata lookup is now designed against a measurement rather than a guess
(docs/METADATA-SOURCES.md § "Measured again 2026-09-09"):

  - Google Books keyless is dead for everyone, permanently. The user's
    residential-IP test returned a quota error naming a shared anonymous PROJECT,
    not an IP, so the earlier "your phone may well get answers" guess is wrong and
    is now marked CORRECTED in place. Because combine() turns any Failed-with-no-
    Found into Unavailable, that standing failure meant every Open Library hiccup
    surfaced as "one or more sources couldn't be reached". The API key is
    deliberately deferred by the user; this commit leaves the source broken.

  - Our own timeouts were manufacturing failures. Over 30 live requests, 13%
    failed — all fast TLS resets under 2.5s — while successes ran to a median of
    4.3s and a max of 22.0s. Two of 26 successes exceeded the old 12s callTimeout,
    so ~8% of lookups that were about to work were cancelled and reported as
    unreachable. Timeouts are now 25s/20s/20s.

That asymmetry (cheap failures, expensive successes) is what RetryPolicy encodes.
It retries TRANSPORT and SERVER_ERROR with a 250ms/750ms jittered backoff, and
deliberately does not retry TIMEOUT (the budget is already spent) or RATE_LIMITED
(hammering a quota is how an intermittent block becomes a permanent one — this
project's IP has already been refused outright once during research).

SourceResult.Failed now carries a FailureKind alongside its human reason, and the
reason names the specific failure ("tls connection reset, 3 attempts") instead of
a generic "network error". That string was already threaded to the UI and dropped
on the floor; LookupFailedSheet now renders it. It is the only diagnostic channel
we have from a real phone, so nothing may parse it.

Also from the same feedback round:
  - Grouped ModalBottomSheet shelf picker, replacing two near-duplicate flat
    dropdowns that listed every bookcase x shelf pair. Sections per bookcase,
    empty bookcases say so, and the most recently used shelf is pinned on top.
  - The recent shelf persists across sessions (SettingsStore.LAST_SHELF_ID) and is
    cleared on sign-out. It is offered, never pre-selected: the user weighed that
    and chose one tap over the risk of silently mis-shelving a book.
  - Locations dialogs and the manual-ISBN dialog auto-focus their first field.
  - The library filter menu offers "Add a bookcase to enable filtering" instead of
    a lone "All books" that is already the active state and cannot be changed.
  - The Locations button is Material Symbols' "shelves" (a bookcase) instead of
    Warehouse (a barn). material-icons-extended 1.7.8 has no bookcase glyph.
  - The scan sheet drops "you can lower the book" — the ISBN echo already says it.

assembleDebug exit 0; testDebugUnitTest 172 tests, 1 skipped, 0 failures (was
138); verifyPaparazziDebug exit 0; assembleRelease exit 0, signed with the real
release key; zero "always 'false'" warnings on a --rerun-tasks rebuild.

Three soft spots are recorded in docs/HANDOFF.md and are NOT verified: the
ghost-bookcase Paparazzi snapshot renders a lookalike of the screen rather than
the screen, the auto-focus calls swallow their own failure and no emulator exists
here, and the picker opens as a sheet stacked on the save sheet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSnVqWdiQNEcPFRq1hGZAi
This commit is contained in:
Sprite
2026-09-09 17:45:09 +00:00
co-authored by claude
parent dd3a61fc33
commit d6d02f788c
44 changed files with 1740 additions and 127 deletions
+137
View File
@@ -0,0 +1,137 @@
You are a Sonnet worker on the Bookshelf Android app (~/bookshelf). Read
`docs/SPEC.md` first — it is the authoritative product contract and it wins over
anything you infer from the code. Do not restate it, do not let it drift.
## Ground rules (violating these fails the wave)
- Build ONLY with `./tasks/gw <task>` — never `./gradlew`. A second worker shares
this Gradle project dir and concurrent invocations clobber each other. `tasks/gw`
is a flock-serialized wrapper.
- Run builds in the FOREGROUND. Never background a Gradle build and end your turn
saying you'll report later — `claude -p` kills background tasks and you will
never report at all. Builds take up to 10 minutes; just wait.
- You own EXACTLY these files:
app/app/src/main/java/org/modg/bookshelf/ui/locations/LocationsScreen.kt
app/app/src/main/java/org/modg/bookshelf/ui/library/LibraryScreen.kt
app/app/src/main/res/drawable/ic_shelves.xml (new file, create it)
app/app/src/test/** (tests you add)
Touch NOTHING else. Specifically forbidden: any build file
(`app/build.gradle.kts`, `gradle/libs.versions.toml`, `settings.gradle.kts`),
`data/**`, `ui/scan/**`, `ui/detail/**`, `ui/components/**`, `ui/nav/**`,
`ui/settings/**`, `ui/setup/**`, `AppContainer.kt`. Another worker and the
orchestrator own those RIGHT NOW and are editing them concurrently.
- Do not change any public composable signature. `ui/nav/BookshelfNavHost.kt`
calls these screens and you may not edit it.
## Task 1 — the ghost-bookcase bug (highest priority, a real user-facing defect)
`LocationsScreen.kt` line ~97. The Scaffold hands `content` an `innerPadding` that
accounts for the top app bar. The empty-state branch applies it; the list branch
does NOT:
) { innerPadding ->
PaperSurface(...) {
if (state.bookcases.isEmpty()) {
EmptyState(modifier = Modifier.padding(innerPadding), ...) // correct
} else {
LazyColumn(contentPadding = PaddingValues(bottom = 96.dp)) { // BUG
So the first bookcase row renders UNDERNEATH the app bar and is invisible. A user
created a bookcase, could not see it, created a second one, and ended up with two
real bookcases and no idea why. Fix it by folding `innerPadding` into the
LazyColumn's `contentPadding` so the existing 96.dp bottom inset is PRESERVED and
added to, not replaced — the bottom inset is what keeps the last row clear of the
FAB. Something equivalent to:
contentPadding = PaddingValues(
top = innerPadding.calculateTopPadding(),
bottom = innerPadding.calculateBottomPadding() + 96.dp,
)
Using `contentPadding` rather than `Modifier.padding` is deliberate: it keeps the
list scrolling under the bar instead of clipping the scroll area.
VERIFY THIS SPECIFICALLY: add a Paparazzi snapshot of `LocationsScreen` in a state
with exactly ONE bookcase, and confirm in the rendered PNG that the bookcase row is
fully visible below the app bar. A one-bookcase list is the exact case that was
broken and it must be the case you prove fixed. If the existing Paparazzi harness
makes rendering this screen with seeded state impractical, say so plainly in your
report rather than skipping it silently.
## Task 2 — auto-focus the first field in the location dialogs
In `LocationsScreen.kt`, `BookcaseEditDialog` (~line 276) and `ShelfEditDialog`
(~line 299) each open with an unfocused `OutlinedTextField`. The first field
should take focus and raise the keyboard when the dialog appears. Use a
`FocusRequester` + `LaunchedEffect(Unit) { focusRequester.requestFocus() }`.
Bookcase dialog: focus "Name" (not "Note"). Shelf dialog: focus "Label".
Guard the requestFocus call so it cannot throw if the node isn't attached yet.
## Task 3 — library filter empty state
`LibraryScreen.kt` ~line 204. The filter DropdownMenu always offers "All books"
first, then a flat list of bookcases and shelves. When there are NO bookcases and
NO shelves, the menu contains only "All books" — which is already the active state
and cannot be changed, so it is a menu with nothing in it.
When `bookcases` and `shelves` are both empty, replace the menu contents with a
single DISABLED item reading "Add a bookcase to enable filtering". Keep the
toolbar filter icon visible and enabled (it is how the feature is discovered) —
only the menu's contents change. When locations DO exist, behaviour is unchanged.
## Task 4 — replace the Warehouse icon with a real bookcase
`LibraryScreen.kt` line ~100 uses `Icons.Outlined.Warehouse` for the button that
opens Locations. It renders as a barn and reads wrong. `material-icons-extended`
1.7.8 has no bookcase glyph (I checked all 1932 outlined icons), so use Material
Symbols' `shelves`, which is a bookcase frame with shelves and books on them.
Create `app/app/src/main/res/drawable/ic_shelves.xml` with EXACTLY this content.
This is the SVG path verbatim from Google's CDN. Do not re-derive it, do not
"simplify" it, and do not convert its relative (lowercase) commands to absolute
ones — Android's pathData parser accepts SVG syntax as-is. Material Symbols ship with
`viewBox="0 -960 960 960"` — a negative Y origin that Android `<vector>` has no
equivalent for — and the `<group android:translateY="960">` is what compensates.
Removing it renders an empty icon.
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Symbols "shelves" (Apache 2.0). Source viewBox is
"0 -960 960 960"; Android has no viewport origin, so the group
translate is load-bearing. Do not flatten it. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<group android:translateY="960">
<path
android:fillColor="#FF000000"
android:pathData="M120-40v-880h80v80h560v-80h80v880h-80v-80H200v80h-80Zm80-480h80v-160h240v160h240v-240H200v240Zm0 320h240v-160h240v160h80v-240H200v240Zm160-320h80v-80h-80v80Zm160 320h80v-80h-80v80Z" />
</group>
</vector>
Then swap the icon at the call site:
Icon(painterResource(R.drawable.ic_shelves), contentDescription = "Bookcases & shelves")
Keep the existing contentDescription text. `Icon` applies its own tint over a
Painter exactly as it does over an ImageVector, so the icon still picks up the
theme colour — do NOT hardcode a colour at the call site. You will need imports
for `androidx.compose.ui.res.painterResource` and `org.modg.bookshelf.R`, and the
`Icons.Outlined.Warehouse` import becomes unused — remove it.
## Verify before you report (all in the FOREGROUND)
./tasks/gw assembleDebug
./tasks/gw testDebugUnitTest
./tasks/gw recordPaparazziDebug
git status --porcelain
- assembleDebug and testDebugUnitTest must exit 0. The test count is 138 today and
must not go DOWN.
- Grep your build output for the string `always 'false'`. That Kotlin warning class
silently blanked every book cover in this app for months by making a `when`
branch dead code that still compiled. Zero hits on files you touched.
- `git status --porcelain` must show ONLY the files you own. If it shows others,
you have broken the boundary — report it, do not revert someone else's work.
- Do not commit. The orchestrator commits after verifying.
## Report
Finish with a plain report: what you changed per task, the exact exit codes and
test counts, whether the one-bookcase Paparazzi render actually proves task 1, and
anything you could NOT do. Do not claim success you did not verify — several
previous workers on this project over-claimed and were caught.