diff --git a/docs/superpowers/specs/2026-06-08-session-expiry-ux-design.md b/docs/superpowers/specs/2026-06-08-session-expiry-ux-design.md
new file mode 100644
index 0000000..47a0dbe
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-08-session-expiry-ux-design.md
@@ -0,0 +1,194 @@
+# Session-Expiry Soft Redirect + Auth Feedback — Design
+
+**Date:** 2026-06-08
+**Status:** Approved (brainstorming) — ready for implementation planning.
+**Issue:** #48 (Frontend UX: session-expiry handling loses in-progress work; auth feedback gaps).
+
+## Context
+
+The session-expiry path is the most damaging daily failure mode for a long-session data-entry tool.
+Today a 401 from any API call runs `redirectToLogin()` (`web/src/api/auth-redirect.ts`) →
+`window.location.assign("/login")`, a **full page reload**: it tears down the React app and query
+cache, gives the login page no reason ("session expired"), and after re-login drops the user on
+`/objects` rather than where they were. Login also hardcodes `navigate("/objects")` and ignores the
+attempted destination, and the Sign out control has no pending state.
+
+**Already fixed since the audit (verified):** `require-auth.tsx` now renders ``
+during the `useMe` fetch (not the blank `
` the audit cited), so the "blank screen on
+load" problem is gone and is **out of scope**. Logout **error** feedback also already exists: `useLogout`
+carries no `meta.suppressErrorToast`, so the global `MutationCache.onError` (`api/query-client.ts`)
+already shows an error toast on logout failure — the only remaining logout gap is a pending/disabled
+state.
+
+**The crux:** the 401 handler lives in an openapi-fetch `Middleware` (`api/client.ts`), which runs
+**outside** React and therefore cannot call `useNavigate`. The app uses a React Router 7 **data router**
+(`createBrowserRouter`, `app.tsx`). The test harness `renderApp` (`src/test/render.tsx`) mounts the
+component under test at `path:"*"` in a **fresh `createMemoryRouter`** — it does not use the app's real
+`router` singleton.
+
+### Decisions (from brainstorming)
+1. **Soft redirect + return** (chosen over an in-place re-auth modal): router-navigate (no full reload)
+ to `/login?reason=expired&from=
`; login shows the reason and returns the user to `from`. The
+ React app + query cache survive; an unsaved edit form's typed values are still lost, but the user
+ lands back on the same record. (Full field-level preservation via an in-place modal is a deferred
+ follow-up.)
+2. **Navigate-bridge module** (chosen over exporting the `router` singleton or a custom DOM event): a
+ module holds a settable `navigate` ref that a one-line component registers. This is testable with the
+ memory-router harness (tests call `setNavigate(mock)` directly); importing the singleton `router`
+ would be untestable because `renderApp` never mounts it.
+
+## Components
+
+### `api/auth-redirect.ts` (rewrite)
+```ts
+type NavigateFn = (to: string, opts?: { replace?: boolean }) => void;
+
+let navigateFn: NavigateFn | null = null;
+
+/** Register/unregister the router's navigate fn (called by NavigationBridge). */
+export function setNavigate(fn: NavigateFn | null): void {
+ navigateFn = fn;
+}
+
+/** Soft-redirect to login on a 401, preserving SPA state and the return path.
+ * Falls back to a hard navigation when no router navigate is registered yet
+ * (e.g. a 401 during the very first load). No-op when already on /login. */
+export function redirectToLogin(): void {
+ const { pathname, search } = window.location;
+ if (pathname === "/login") return;
+ const from = encodeURIComponent(pathname + search);
+ const target = `/login?reason=expired&from=${from}`;
+ if (navigateFn) {
+ navigateFn(target, { replace: true });
+ } else {
+ window.location.assign(target);
+ }
+}
+```
+`api/client.ts` is unchanged — the middleware still calls `redirectToLogin()`; only its behaviour changes.
+
+### `shell/navigation-bridge.tsx` (new)
+```tsx
+import { useEffect } from "react";
+import { useNavigate } from "react-router-dom";
+
+import { setNavigate } from "../api/auth-redirect";
+
+/** Bridges React Router's navigate to the non-React 401 handler. Renders nothing. */
+export function NavigationBridge() {
+ const navigate = useNavigate();
+ useEffect(() => {
+ setNavigate(navigate);
+ return () => setNavigate(null);
+ }, [navigate]);
+ return null;
+}
+```
+
+### `app.tsx` (wrap routes in a pathless layout)
+Add a pathless layout route around **all** existing routes whose element mounts the bridge plus an
+``, so the bridge is always present (covers `/login` and the authed area):
+```tsx
+function RootLayout() {
+ return (
+ <>
+
+
+ >
+ );
+}
+```
+```tsx
+createRoutesFromElements(
+ }>
+ } />
+ {/* …existing RequireAuth / AppShell subtree unchanged… */}
+ } />
+ ,
+)
+```
+
+### `auth/login-page.tsx` (reason banner + return-to + empty-field guard)
+- `const [params] = useSearchParams();`
+- Reason banner when `params.get("reason") === "expired"`: render `t("auth.sessionExpired")` (a
+ non-error info note, distinct from the existing `role="alert"` invalid-credentials message).
+- On success, navigate to `safeFrom(params.get("from"))` instead of the hardcoded `/objects`:
+```ts
+function safeFrom(raw: string | null): string {
+ if (!raw) return "/objects";
+ // single leading slash only — reject protocol-relative ("//host") / absolute URLs (open redirect)
+ return /^\/(?!\/)/.test(raw) ? raw : "/objects";
+}
+```
+ (`raw` from `useSearchParams` is already percent-decoded.)
+- Disable submit on empty fields: `disabled={login.isPending || !email.trim() || !password}`.
+
+### `auth/require-auth.tsx` (capture the attempted location)
+```tsx
+const location = useLocation();
+// …
+if (!user) {
+ const from = encodeURIComponent(location.pathname + location.search);
+ return ;
+}
+```
+No `reason` here — an unauthenticated deep-link is not an expiry; login simply returns the user to `from`.
+
+### `shell/user-menu.tsx` (logout pending state)
+Disable the Sign out item while the mutation is in flight:
+`