android / Build + lint + test (push) Successful in 4m8s
The veil raised eagerly: any trigger over a warm cache put it up before knowing whether the refresh would change anything. So every launch cost ~1-2s of opaque panel even when the pull returned exactly what was already cached — which, now that the section swap is atomic and the index flow dedups on ids, produces no visible churn to hide at all. The veil was covering nothing and only delaying first paint. The raise is now reactive: it fires when the content key actually differs from what was already on screen, and never for a no-op refresh. The baseline is the first state that HAS content, not the first state at all — over a warm cache the cached rows paint a moment after the session starts, and counting that first paint as "a change" would veil every launch, which is the thing being fixed. Cost of reacting rather than anticipating: the veil arrives one emission after the change, so a single atomic swap shows through. Everything messier that follows it — tile hydration, then artwork — still lands behind it. That leaves a hole this closes too: a manual pull where nothing changed would now produce no veil, no movement, nothing whatsoever, which reads as broken. So sessions report an outcome — CHANGED / UNCHANGED / FAILED — and Home surfaces it as "Already up to date" or "Couldn't check for updates". Only for refreshes a person actually asked for. "Already up to date" on every launch, every 03:00 rebuild and every reconnect would be worse than silence, so VeilSessionResult carries a userInitiated bit and background sessions stay quiet. The bit is tracked separately from the request token because the request channel is CONFLATED: coalescing drops the older token, and a user's pull must not be swallowed by a background trigger arriving on its heels. The surfaced failure is a deliberate narrowing of the earlier "silent on give up" call, which is now read as being about background refreshes: for a pull the user deliberately triggered, silence looks broken, and staying silent while the success case speaks would be incoherent. Recovery is unaffected either way. Pull-to-refresh now waits for whichever successor actually arrives — the veil, or the snackbar — via finishedSessions, instead of only ever waiting on the veil and timing out for 2s on an unchanged pull. The Error-state Retry goes through the controller as well, so it gets the retries and reports its outcome; over an empty cache there's no content to protect, so no veil appears. HomeViewModel.refresh() is gone, replaced by retry() and refreshFromPull() — the two things that actually exist. Tests: two changed meaning and are rewritten rather than patched. A failed pull writes nothing, so the veil no longer stands over the retries — it goes up when a retry finally lands. And "waits for content to paint" became "cached content painting is not mistaken for a change", which is the baseline subtlety above. Added coverage for UNCHANGED, FAILED, the cold-load CHANGED case, and the conflation of a user request with a background one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>