diff --git a/docs/superpowers/specs/2026-07-27-modern-error-displays-design.md b/docs/superpowers/specs/2026-07-27-modern-error-displays-design.md new file mode 100644 index 0000000..5f3dd7a --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-modern-error-displays-design.md @@ -0,0 +1,167 @@ +# Modern Error Displays — Design + +**Status:** Approved, ready for implementation planning. +**Origin:** `docs/IDEAS.md`'s "modern error displays" backlog item. + +## Goal + +Replace every page/component's ad-hoc inline `error`-state paragraph +(`

{error}

` and its several near-duplicates) with a +single, consistent banner system: ephemeral, color-coded, full-width bars +(red for error, orange for warning, blue for notice, green for success) +that stack, auto-dismiss, and can be closed manually. + +This is a full replacement, not an addition alongside the old pattern — +every existing inline error display in the frontend is migrated to the new +system, with one deliberate exception (OnboardingWizard's field-validation +messages — see below). + +## Non-goals + +- No backend changes. This is purely a frontend presentation change; no API + shape changes anything about how errors are already communicated to the + frontend. +- No new "logout failed" error path. `handleSessionLogout` on the backend + has no failure mode today (unconditional cookie-clear + redirect) — the + banner system will be *capable* of showing a logout error if one is ever + added, but nothing is invented here just to exercise it. + +## Architecture + +### `frontend/src/banner.ts` — the store + +A plain module, not a React Context/Provider. The codebase has no existing +`createContext`/`useContext` usage anywhere, and — critically — +`frontend/src/api/client.ts` is a plain module (not a component), so a +Context wouldn't be reachable from it without extra plumbing. A +module-level singleton store is simpler and callable from anywhere: + +```ts +type Severity = "error" | "warning" | "notice" | "success"; +type Banner = { id: number; severity: Severity; message: string }; + +// newest-on-top; auto-dismisses after 8000ms; manually dismissible early. +export function showError(message: string): void; +export function showWarning(message: string): void; +export function showNotice(message: string): void; +export function showSuccess(message: string): void; +export function dismiss(id: number): void; + +// useSyncExternalStore plumbing for the render side. +export function subscribe(listener: () => void): () => void; +export function getSnapshot(): Banner[]; +``` + +Multiple banners stack (newest on top); each dismisses independently on its +own timer. A banner's `message` may contain embedded newlines (`\n`) for +the sync-result-plus-nudges case (see below) — the renderer treats them as +line breaks within one banner, not separate banners. + +### `frontend/src/components/BannerStack.tsx` + `BannerStack.css` — the renderer + +- `useSyncExternalStore(subscribe, getSnapshot)` to read the current list. +- Renders one `
` per active + banner: the message text (respecting embedded newlines) plus a `×` close + button that calls `dismiss(id)`. +- Four CSS variants — `banner-error` (red bg), `banner-warning` (orange + bg), `banner-notice` (blue bg), `banner-success` (green bg) — each with + readable foreground text/icon color against its background. +- **Mounted exactly once**, in `frontend/src/main.tsx`, as a sibling above + ``: + + ```tsx + createRoot(document.getElementById("root")!).render( + + + + , + ); + ``` + + Rendered in normal document flow (not `position: fixed`), so it + naturally pushes down whatever's currently showing below it — the login + screen, the onboarding wizard, or the full app (header + tabs + content) + — without needing a separate mount point wired into each of those three + top-level layouts individually. When no banners are active, it renders + nothing (no reserved empty space). + +### `frontend/src/api/client.ts` — friendlier network-failure messages + +`request()`'s `fetch()` call is wrapped so a network-level failure (the +`fetch()` promise itself rejecting — offline, connection refused, CORS, +etc.) throws a friendly, fixed message instead of the raw browser error +text: + +```ts +let res: Response; +try { + res = await fetch(`${BASE_URL}${path}`, { ... }); +} catch { + throw new Error("Can't reach the server — check your connection."); +} +``` + +A real HTTP response that just happens to be non-2xx (4xx/5xx) is +unaffected — that branch keeps its existing `${status} ${body}` message, +since that's a legitimate API-level error, not a "the backend isn't there" +condition. + +This means every call site's existing pattern — +`.catch((e) => setError(String(e)))` — becomes +`.catch((e) => showError(String(e)))`, and the friendly message for a truly +unreachable backend falls out for free, with no per-call-site +network-error special-casing needed. This is what satisfies IDEAS.md's +"used on all pages/tabs in case of backend is not here": every page's +existing catch-and-display path already covers this, once it displays via +`showError` instead of local state. + +## Migration inventory + +Every current inline-error site, and what changes: + +| File | Before | After | +|---|---|---| +| `components/GarminConnection.tsx` | Local `error` state, all catch blocks `setError(String(e))`, inline `{error &&

{error}

}` | Remove the state and the paragraph; every catch block calls `showError(String(e))` directly. | +| `components/SyncModal.tsx` | Never auto-closes; on completion renders a "final result" block inline (success/error line + optional pending-activities/pending-workouts nudges) with a manual Close button; a status-poll fetch failure renders inline too. | The "final result" JSX block is deleted entirely — **the modal now purely shows the progress bar/spinner and nothing else**. On the poll tick where `status.in_progress` is first observed to be `false`, build one message: the success/error line, followed by a newline-separated line for each applicable pending nudge (`"N more activities pending — click Sync now again"` / `"N more workouts pending — click Sync now again"`), call `showSuccess(...)` or `showError(...)` with that combined message, then call `onClose()` immediately — the modal disappears the instant sync finishes, and the banner carries the result. A transient status-poll fetch failure (modal still open, sync still presumably running) calls `showError(String(e))` but does **not** close the modal — polling continues as it does today. | +| `pages/Profile.tsx` | Local `error` state (profile load/save failures), inline paragraph. | Same pattern as GarminConnection — state and paragraph removed, catch blocks call `showError`. | +| `pages/Activities.tsx` | Local `error` state (multiple load/action failures), inline paragraph. | Same. | +| `pages/Analysis.tsx` | Local `error` state, inline paragraph. | Same. | +| `components/TrainingTypesCard.tsx` | Local `error` state, inline paragraph. | Same. | +| `LoginGate.tsx` | Reads `?auth_error=` from the URL synchronously during render, shows `

` with a mapped message. | A mount effect reads `?auth_error=` once and calls `showError(mappedMessage)` (same `AUTH_ERROR_MESSAGES` mapping as today); the inline paragraph and its CSS class are removed. | +| `OnboardingWizard.tsx` | Single `error` state used for both field validation ("Please enter a display name.", "Please enter your Garmin email and password.") **and** real failures (Garmin login rejected, MFA rejected, `complete()` failing after a successful Garmin auth) — all rendered via the same `onboarding-wizard-error` paragraph, in 4 places. | **Split.** Field-validation messages stay exactly as they are today (local state, inline paragraph, tied to a specific input the user needs to fix — a transient top-of-page banner is the wrong medium for "you left a required field blank"). Real failures (`submitGarmin`'s catch, `submitMFA`'s catch, `complete()`'s catch) call `showError(...)` instead of `setError(...)`. The `complete()`-failure Retry button, which today is conditionally rendered on `error &&`, switches to a new local boolean `completeFailed` (set `true` in that catch block) — the button's visibility no longer depends on the error text still being present, since that text now lives only in the (transient, auto-dismissing) banner. | + +**Dead CSS removal:** once no `.tsx` file references them, delete the +`.error`, `.onboarding-wizard-error`, and `.login-gate-error` rules from +their respective CSS files. + +## Data flow example (sync completion) + +1. User clicks "Sync now" → `GarminConnection.sync()` calls + `api.syncRun()`, sets `showSyncModal(true)`. +2. `SyncModal` polls `/api/sync/status` every 1.5s, rendering the + spinner/progress bar per `progress.Phase` (unchanged from the existing + implementation). +3. A poll response shows `in_progress: false` for the first time. + `SyncModal` builds a message from `last_run.Status`/`ErrorMessage`/ + `ActivitiesFetched` plus `activities_pending_details`/ + `workouts_pending`, calls `showSuccess(message)` or `showError(message)` + accordingly, then calls `onClose()`. +4. `GarminConnection` unmounts `SyncModal` (its normal `showSyncModal` + toggle, unchanged). The banner, now living in the shared store + independent of any unmounted component, continues to display and + auto-dismiss on its own schedule. + +## Testing + +No frontend test suite exists in this repo (established convention — +verification is `tsc -b`/`vite build`, `oxlint`, and manual browser +checks). Verification for this feature is: + +- `npm run build` / `npm run lint` clean. +- Manual smoke test covering each severity: trigger a real API error (e.g. + an invalid save), a network failure (stop the backend, attempt any + action), a successful sync, a sync ending in error, a sync with pending + activities/workouts remaining, and the login screen's `auth_error` + redirect — confirming banners stack, auto-dismiss, and can be closed + manually, and that `SyncModal` now closes itself the instant sync + finishes rather than waiting for a manual Close click.