9 Commits
Author SHA1 Message Date
Spriteandclaude d6d02f788c 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
2026-09-09 17:45:09 +00:00
Spriteandclaude 93f972b7d1 Distinguish can't-scan, can't-look-up, and genuinely-not-found
Implemented by a Sonnet worker (tasks/G-diagnostics.txt) against the
three-way contract added to SPEC in 1969b74; independently re-verified by
the orchestrator rather than accepted on the worker's own report.

The app previously conflated three outcomes. A barcode that failed the
ISBN-13 checksum produced NOTHING — no sheet, no message — which is
indistinguishable from a dead camera. A lookup that failed at the
transport layer (Google Books answers HTTP 429 to keyless callers) was
reported as "No match found", telling the user a book does not exist when
the app never managed to ask. Only the third case was ever honest.

Now: each client returns SourceResult{Found,NotFound,Failed} from a pure
classify() so the status-code matrix is testable offline without a
MockWebServer; MetadataRepository.combine folds those into
LookupResult{Found,NotFound,Unavailable}, where NotFound requires EVERY
source to have answered authoritatively. A rejected barcode raises a
throttled banner on the camera screen, sharing ScanCodeFilter's existing
debounce so a non-book barcode sitting in frame shows the message once
instead of flickering per analyzed frame. A failed lookup gets its own
sheet with Retry / Enter by hand / Skip that never claims the book is
unknown. The metadata OkHttpClient finally has a call timeout.

Orchestrator's own addition: the manual-ISBN dialog silently discarded an
unparseable entry — the same silent failure on the same screen, missed
because it sat just outside the worker's brief. It now marks the field in
error and disables Look up until the checksum passes.

assembleDebug + verifyPaparazziDebug exit 0; 138 tests, 1 skipped, 0
failures (was 107). Boundary check clean: no build files, no data/local,
data/remote, data/repo, ui/settings, ui/locations or ui/detail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 12:44:29 +00:00
Spriteandclaude 1ba22a9f36 The two books that failed are in Open Library after all
The user supplied the failing ISBNs: 9781883937386 (The Hittite Warrior)
and 9781883937676 (Shadow Hawk), both Bethlehem Books. Both resolve
against the source the app already queries — title, author, publisher,
page count, cover art — and both real responses parse correctly through
the app's own OpenLibraryClient. Captured as fixtures with a regression
test, because these are the specific books that motivated the research.

That kills the coverage hypothesis for these two and demotes Harvard,
which holds neither: its 93% in the sample table is inflated by
construction (the sample was drawn from Harvard) and misleading in
exactly the direction that matters — it is a research library and does
not carry small-press children's historical fiction.

The failure is upstream of the metadata sources. Documented the three
candidates; the leading one is that the barcode never decoded into a
valid ISBN-13, which ScanCodeFilter drops silently with no UI feedback
whatsoever. Revised the recommendation accordingly: make the app say
what happened before adding any source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDcPottghJXEvfYKqFM7zf
2026-09-09 10:36:33 +00:00
Spriteandclaude 356f639cdd Fix six on-device issues found in the first real phone test
The cover one is the interesting bug. BookCover branched on
`painter.state`, but in coil3 that is a StateFlow<State>, not a State —
so every `is AsyncImagePainter.State.X` arm was always false. A `when`
used as a statement needs no else, so it compiled clean and drew
NOTHING: no cover, no placeholder, no error icon. That is why a
successfully looked-up book showed as a bare title floating in space.
Collect the flow before matching on it.

Two more cover defects sat behind that one:
- OpenLibraryDtos synthesized covers.openlibrary.org/b/isbn/{isbn}-L.jpg
  unconditionally. For an edition with no art that URL answers 200 with a
  43-byte 1x1 transparent GIF (verified against the live service), which
  an image loader calls a successful load. So even with the state bug
  fixed it would have painted an invisible cover and never fallen back.
  Now the URL comes from OL's own `cover` object, which is present only
  when art actually exists.
- Because that URL was never blank, MetadataMerger's "fill blanks from
  the other source" rule could never reach Google Books' thumbnail. With
  OL reporting null, the fallback works, and MetadataRepository adds the
  by-ISBN URL as a genuine last resort — with `default=false`, so a miss
  is a 404 the loader can report instead of a blank image.

The placeholder itself is now drawn in every non-success state (loading
included, where it previously drew an empty box) and reads as a book:
mahogany spine strip, gold hairline, letterpress panel.

Also:
- SyncStatusBar owns its navigation-bar inset and takes wider horizontal
  padding, so it clears a phone's rounded display corners; it moves into
  Scaffold's bottomBar slot so its height reaches the content padding.
- SetupScreen takes safeDrawingPadding outside verticalScroll, so the IME
  shrinks the viewport instead of covering Password, plus Next/Next/Done
  IME actions.
- Library card titles drop to a 20sp line height with the author given
  its own 4dp gap: at titleSmall's 24sp leading a wrapped title left the
  author closer to the last title line than the title lines were to each
  other, so the byline read as part of the title.
- The scan sheet now names the ISBN it just read and says "Searching…",
  so a decoded barcode is legible as decoded and the user can lower the
  book instead of holding it to the camera at a bare spinner.

assembleDebug + assembleRelease exit 0; 106 unit tests, 0 failures;
verifyPaparazziDebug green against re-recorded snapshots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDcPottghJXEvfYKqFM7zf
2026-09-09 09:55:53 +00:00
Spriteandclaude 5455df2d61 Wave 4 (F3): settings email, five screens' screenshots, release signing, README
Completes wave 4. Verified by the orchestrator, not self-reported:
assembleDebug / testDebugUnitTest / assembleRelease all exit 0;
102 tests, 1 skipped, 0 failures, 0 errors.

- Settings showed the PocketBase user id instead of the signed-in email,
  because login never persisted the email. AuthRepository now writes it to
  SettingsStore on success and sign-out clears it; SettingsUiState carries
  userEmail in place of userId. AuthRepositoryTest asserts both directions.
- Paparazzi coverage for the five screens library was missing: setup, detail,
  scan, locations, settings, each light + dark, populated rather than empty.
  Scan cannot show a live camera under Paparazzi, so its tests render the
  reticle overlay and the result bottom sheet over a static backdrop.
- Release signing via an optional gitignored app/keystore.properties. Without
  it assembleRelease still works and comes out debug-signed, so the build is
  not owner-only. R8 deliberately left off; nothing has proven Room, Retrofit,
  kotlinx-serialization and ML Kit survive it.
- Top-level README: shared-library model, offline-first architecture, the
  push-then-pull last-write-wins conflict rule SPEC requires be documented
  here, build/deploy/install steps, and honest current limitations.

The signed APK and the keystore are intentionally not committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TyzeWmdTqi7U85iYNGy7P
2026-09-08 21:28:11 +00:00
claude cf36b27457 Wave 4 (partial): live-sync harness + Library screenshots; fix authors null decode
Both wave-4 workers hit the 5h session limit ~11 minutes in and were then lost
to a sprite suspend. This commit preserves the work that landed before that,
independently verified green (assembleDebug + testDebugUnitTest, 94 tests).

F1-livesync:
- LiveSyncTest + server/live-sync-test.sh: end-to-end exercise against a real
  PocketBase (auth, push with client ids, pull, tombstones, cover round-trip).
  Gated behind LIVE_SYNC=1 so the normal test task stays green with no server.
- Fix: BookDto.authors must be nullable. PocketBase serializes an unset `json`
  field as literal null (unlike text/number, which come back ""/0), so decoding
  any real response with empty authors threw. Found by the live test; no fake
  had ever reproduced it.
- Fix: cover upload derived its media type from the filename instead of
  hardcoding image/jpeg.

F2-release:
- ScreenFixtures + LibraryScreenPaparazziTest: library populated and empty,
  light and dark (4 PNGs).

Still owed by wave 4: screenshots for the other five screens, release keystore
+ signed APK, top-level README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mTs3kQXQsQwonXpEq7aEw
2026-09-06 18:56:55 +00:00
claude a9d4ad8096 Wave 3: all six screens (E1 shell/setup/locations/settings, E2 library/detail/scan)
Nav graph, setup with distinct URL-vs-credential errors, locations tree with reorder
and bulk move, settings; library grid with search/sort/filter, detail with soft-delete
undo, continuous scan with duplicate-ISBN warning.

Verified by orchestrator: assembleDebug exit 0; testDebugUnitTest exit 0,
91 tests, 0 failures, 0 errors (68 -> 91).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 10:48:54 +00:00
claude d1a73a1193 Wave 2: data layer (C) + metadata/scanning (D)
Room entities/DAOs/DB, PocketBase Retrofit client + auth interceptor, SyncEngine
(push-then-pull, LWW, tombstones, client-generated ids), SettingsStore, AppContainer.
Open Library + Google Books merge, ISBN validation, CameraX + ML Kit scanner plumbing.

Verified by orchestrator: assembleDebug exit 0; testDebugUnitTest exit 0, 68 tests,
0 failures, 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bThmkmyUUdqQpy3MXFFe5
2026-09-06 03:24:04 +00:00
claude 6c17e42037 Baseline: wave 1A server complete, wave 1B Android scaffold + design system green
assembleDebug, testDebugUnitTest, and recordPaparazziDebug all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bThmkmyUUdqQpy3MXFFe5
2026-09-06 01:58:37 +00:00