diff --git a/docs/superpowers/specs/2026-08-16-live-sync-updates-design.md b/docs/superpowers/specs/2026-08-16-live-sync-updates-design.md
new file mode 100644
index 0000000..1c35739
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-16-live-sync-updates-design.md
@@ -0,0 +1,187 @@
+# Live Sync Updates (HTMX) — Design
+
+Date: 2026-08-16
+Status: Draft for user review
+
+## 1. Goal
+
+Replace the full-page navigation that currently happens after clicking
+"Sync now" / "Sync all now" with an in-place update: the affected rider
+row(s) refresh with their new status, and a short toast reports the
+outcome — without leaving the dashboard or account page.
+
+## 2. Scope
+
+### In scope
+
+- Vendoring htmx (v2.0.10, self-hosted, no CDN) as the swap mechanism.
+- Dashboard: per-rider "Sync now" and "Sync all now".
+- Account page (self-service): "Sync now".
+- A toast notification system (one at a time, auto-dismissing) built on
+ htmx out-of-band swaps.
+- Removing the HTTP 409 special case for "sync already running" — it
+ becomes a normal toast instead of a distinct error page/status.
+
+### Out of scope (unchanged in this pass)
+
+- Activity retry button (`/activities/{id}/retry`) — still navigates to
+ the old `fragments/sync_result.html` page.
+- Garmin MFA form (`/users/{id}/garmin-mfa`) — still navigates to the old
+ page; MFA failure often needs a fresh code anyway, so the extra step is
+ less costly there.
+- Live-updating the "Recent sync runs" table on the account/user detail
+ pages — a completed sync's new row only appears after the next full
+ page load.
+- Any change to `SyncManager`, `SyncOutcome`, or scheduler behavior.
+
+## 3. Architecture
+
+### htmx
+
+`app/web/static/htmx.min.js` (vendored, v2.0.10) is loaded in `base.html`
+via ``, alongside the
+existing `app.js`.
+
+### The "always return current state" rule
+
+Every htmx-driven POST route in scope returns two things in one response
+body:
+
+1. The current, freshly-reloaded state of its own primary swap target
+ (even on a no-op path like "sync already running", or on the
+ `/sync-all` form itself, which always re-renders unchanged). This
+ makes every swap safe/idempotent — the target is never replaced with
+ nothing.
+2. Exactly one out-of-band toast fragment (`fragments/toast.html`,
+ `hx-swap-oob="true"` on `#toast-container`) describing what happened.
+
+`/sync-all` additionally emits one out-of-band row update
+(`fragments/user_row.html` rendered with `oob=True`) per rider whose
+outcome carries a known `user_id` — riders unaffected by that run (e.g.
+disabled) are left alone.
+
+### Shared row partial
+
+`app/web/templates/fragments/user_row.html` renders one
+`
...
`, taking
+`row` (a `UserDashboardRow`), `csrf_token`, and `oob` (default `False`,
+adds `hx-swap-oob="true"` to the root element when `True`). `dashboard.html`
+`{% include %}`s it once per row in its existing loop (`oob` omitted,
+defaults to `False`) instead of inlining the `
` markup — this is the
+only change to the existing loop, so the initial page render is
+byte-for-byte equivalent to today's markup plus the new `hx-*` attributes
+on the row and its form.
+
+### Shared account status partial
+
+`app/web/templates/fragments/account_status.html` renders the
+`
...
` block (Status,
+MyWhoosh state, Garmin state, Action reason) that today lives inline in
+`account/detail.html`. Same include pattern.
+
+### New repository method
+
+`UserRepository.dashboard_row(user_id: int) -> UserDashboardRow | None`
+in `app/db/repositories.py` — the existing `dashboard_rows()` loop body is
+extracted into a private `_build_dashboard_row(user: SyncUser) -> UserDashboardRow`
+helper that both `dashboard_rows()` and the new `dashboard_row(user_id)`
+call, so there is exactly one place that assembles a row.
+
+### Route changes
+
+`app/web/operations.py`:
+
+- `manual_sync` (`POST /users/{user_id}/sync`): on success, on
+ `SyncAlreadyRunning`, and on any other outcome, always ends by opening a
+ fresh session, calling `UserRepository(session).dashboard_row(user_id)`,
+ and rendering `fragments/user_row.html` (`oob=False`, since this row IS
+ the primary `hx-target`) followed by a toast whose message/level depend
+ on the outcome. Always returns HTTP 200 now (no more 409).
+- `manual_sync_all` (`POST /sync-all`): re-renders the trigering `