fix(scroll): refuse an unreadable capture before scroll top/bottom decides
Summary
scroll top/scroll bottom no longer report "Already at bottom" on a capture they could not read. A missing, empty or sparse tree now fails with scroll_edge_capture_unreadable (with captureRefusal: no-capture | sparse-tree) before any edge verdict or gesture.
Following the shape in the issue, both scroll loops now run on one runScrollPasses({ observe, refuse, shouldStop, passLimit, ... }) in capture-kit. selectors depends on capture-kit, so capture-kit can't import sparseCaptureQuality. The loop therefore takes a refuse hook, and the daemon defines the refusal once (scroll-capture-refusal.ts) for both scroll --until and the edge scrolls.
One ordering change: the edge loop now checks its 40-pass budget before the stuck window, as --until already did. This only differs if a surface first reads as stuck on exactly pass 40.
6 files, no scope expansion. Fixes #2468.
Validation
pnpm check:affected --run --head 62eb122: format, lint, typecheck, layering, fallow, build and vitest-related all pass (exactCommit=true).- New tests:
runScrollPassesunit cases (refusal before the stop condition, budget, stuck window, budget wins ties) andscroll bottomdaemon cases (no tree, empty tree, sparse tree, unreadable mid-loop). They failed before the change. - Live, Pixel_7_API_36 emulator, Settings:
scroll bottomtakes 1 pass to the bottom on bothorigin/main(4db1b7d) and this branch, in two runs each. - Not live-tested: the unreadable-capture path itself, which isn't reproducible on demand.
- Seen on both main and this branch:
scroll topright afterscroll bottomon Settings fails withscroll_edge_no_progress. Out of scope here; I can file it separately.