feat(mobile-sync): skip offline attempts, coalesce triggers, never hang, show progress track/spec-mobile-sync-behaviour
Automatic sync fired on every interval tick and every foreground return regardless of network state, so an offline device produced a failure toast for nothing and ran two syncs back to back when a foreground return landed just after a tick. A sync with no route to the server never returned at all: the reqwest clients on the sync path had no timeout, so the invoke stayed pending, the JS finally never ran, and the gear and corner dot span indefinitely. A long push showed a motionless "Syncing..." for minutes. The decision to run is now a pure function, sync-gate.js shouldAutoSync(), returning the reason it declined so the caller can stay silent. Offline is checked before the debounce, so a device that is both offline and inside the window reports 'offline'. The debounce is leading edge: the first trigger runs and any automatic trigger within 10 s of the last attempt is dropped. Both the interval tick and the visibilitychange handler go through it; a manual sync bypasses it and always runs, while still stamping the attempt time so an automatic trigger arriving just after it coalesces. A skipped attempt shows nothing and sets no state. Returning online fires no sync of its own — the next tick or foreground return picks it up. Network state and the last-attempt time live in refs, because the interval and visibility handlers are registered once per effect run and would otherwise read the values captured at registration. sync_http_client() gives every client reached from sync_all_internal a 10 s connect timeout and a 60 s request timeout. That includes ensure_profiles_registered(), which both pull_from_server() and push_to_server() call before any other HTTP work — it is the first request on the sync path and the one that actually hangs, so the spinning gear would have survived fixing only the other two. Both timeouts are per request, so a push of thousands of items is unaffected. The webhook per-item pushes and the legacy bulk sync_to_webhook() are not reached from sync_all_internal and keep their existing clients. navigator.onLine reads true on a captive network, which is why the timeouts are the backstop rather than the detection being trusted on its own. For progress, loadSyncStatus() is re-read once a second while a sync runs with settings open — a poll because the Rust push loop marks items synced inside a single invoke and emits no per-item event, so there is no transition to subscribe to. The button reads "Syncing... (N pending)" and the pending line stays visible during a run even at 0, so it does not vanish as the queue drains. loadSyncStatus() also moves into the finally of pull and push, as sync_all already did, so the count refreshes after a failed attempt too.