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
138 lines
7.6 KiB
Plaintext
138 lines
7.6 KiB
Plaintext
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.
|