diff --git a/docs/superpowers/plans/2026-07-27-modern-error-displays-plan.md b/docs/superpowers/plans/2026-07-27-modern-error-displays-plan.md new file mode 100644 index 0000000..f7c98e9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-modern-error-displays-plan.md @@ -0,0 +1,2027 @@ +# Modern Error Displays Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace every page/component's ad-hoc inline `error`-state paragraph with a shared, +ephemeral, color-coded banner system (red/error, orange/warning, blue/notice, green/success), +per `docs/superpowers/specs/2026-07-27-modern-error-displays-design.md`. + +**Architecture:** A plain module `frontend/src/banner.ts` holds a module-level singleton list of +active banners plus a tiny pub-sub (`showError`/`showWarning`/`showNotice`/`showSuccess`, +`dismiss`, `subscribe`/`getSnapshot`). A single `` component (mounted once, in +`main.tsx`, above ``) renders the current list via `useSyncExternalStore`, in normal +document flow so it pushes down whatever's currently showing. Every existing inline error site +across the frontend switches to calling `showError` (or `showSuccess`/`showNotice` where relevant) +instead of local state. + +**Tech Stack:** React/TypeScript (frontend only -- no backend changes). No frontend test suite -- +verified via `tsc -b`/`vite build`, `oxlint`, and manual browser smoke tests, per this repo's +established convention. + +## Global Constraints + +- Frontend: `npm run build` (`tsc -b && vite build`) and `npm run lint` (oxlint) must be clean + before every commit in this plan. +- Auto-dismiss duration is 8000ms, defined once in `banner.ts` -- no per-call-site duration + overrides. +- Exactly four severities, with these exact exported function names and CSS class names: + `showError`/`banner-error` (red), `showWarning`/`banner-warning` (orange), + `showNotice`/`banner-notice` (blue), `showSuccess`/`banner-success` (green). `showWarning` and + `showNotice` have no caller yet in this plan (nothing in the current codebase needs them) -- + they exist because the design calls for all four severities to be available, not because this + plan invents a use for them. Do not remove them for being "unused." +- `` is mounted **exactly once**, in `frontend/src/main.tsx`, as a sibling above + ``, rendered in normal document flow (not `position: fixed`) -- never mounted a + second time inside `App.tsx`/`LoginGate.tsx`/`OnboardingWizard.tsx`. +- This is a full replacement, not an addition alongside the old pattern: once a file's migration + task is complete, it must have zero remaining `useState` for an `error`/local-message string + that exists solely to render an inline failure paragraph, and zero remaining + `

`-style inline error paragraphs -- except `OnboardingWizard.tsx`'s + field-validation messages ("Please enter a display name.", "Please enter your Garmin email and + password."), which stay exactly as they are today (local state, inline paragraph) since they're + about fixing a specific input, not a transient system event. +- No backend changes anywhere in this plan. No new "logout failed" error path is invented -- + `handleSessionLogout` has no failure mode today and none is added. + +--- + +### Task 1: Banner store, `BannerStack` component, mount in `main.tsx` + +**Files:** +- Create: `frontend/src/banner.ts` +- Create: `frontend/src/components/BannerStack.tsx` +- Create: `frontend/src/components/BannerStack.css` +- Modify: `frontend/src/main.tsx` + +**Interfaces:** +- Consumes: nothing new (React's `useSyncExternalStore`, already available -- this repo is on + React 19). +- Produces: `showError(message: string): void`, `showWarning(message: string): void`, + `showNotice(message: string): void`, `showSuccess(message: string): void` (all from + `frontend/src/banner.ts`) -- every later task in this plan imports one or more of these. + +- [ ] **Step 1: Create the banner store** + +Create `frontend/src/banner.ts`: + +```ts +export type BannerSeverity = "error" | "warning" | "notice" | "success"; + +export interface BannerEntry { + id: number; + severity: BannerSeverity; + message: string; +} + +const DISMISS_AFTER_MS = 8000; + +let banners: BannerEntry[] = []; +let nextId = 1; +const listeners = new Set<() => void>(); + +function notify() { + for (const listener of listeners) listener(); +} + +function show(severity: BannerSeverity, message: string): void { + const id = nextId++; + banners = [{ id, severity, message }, ...banners]; + notify(); + setTimeout(() => dismiss(id), DISMISS_AFTER_MS); +} + +export function showError(message: string): void { + show("error", message); +} + +export function showWarning(message: string): void { + show("warning", message); +} + +export function showNotice(message: string): void { + show("notice", message); +} + +export function showSuccess(message: string): void { + show("success", message); +} + +export function dismiss(id: number): void { + banners = banners.filter((b) => b.id !== id); + notify(); +} + +export function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getSnapshot(): BannerEntry[] { + return banners; +} +``` + +- [ ] **Step 2: Create the CSS** + +Create `frontend/src/components/BannerStack.css`: + +```css +.banner-stack { + display: flex; + flex-direction: column; +} + +.banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1.5rem; + white-space: pre-line; +} + +.banner-message { + flex: 1; +} + +.banner-close { + background: none; + border: none; + color: inherit; + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + padding: 0 0.25rem; +} + +.banner-error { + background: #7f1d1d; + border-bottom: 1px solid #f87171; + color: #fff; +} + +.banner-warning { + background: #7c2d12; + border-bottom: 1px solid #fb923c; + color: #fff; +} + +.banner-notice { + background: #1e3a5f; + border-bottom: 1px solid #3b82f6; + color: #fff; +} + +.banner-success { + background: #14532d; + border-bottom: 1px solid #22c55e; + color: #fff; +} +``` + +- [ ] **Step 3: Create the renderer component** + +Create `frontend/src/components/BannerStack.tsx`: + +```tsx +import { useSyncExternalStore } from "react"; +import "./BannerStack.css"; +import { dismiss, getSnapshot, subscribe } from "../banner"; + +// Mounted exactly once, in main.tsx, above -- see +// docs/superpowers/specs/2026-07-27-modern-error-displays-design.md. Renders +// in normal document flow (not position: fixed) so it pushes down whatever's +// currently showing (login screen, onboarding wizard, or the full app), +// without needing a separate mount point in each of those three layouts. +export function BannerStack() { + const banners = useSyncExternalStore(subscribe, getSnapshot); + if (banners.length === 0) return null; + + return ( +

+ {banners.map((b) => ( +
+ {b.message} + +
+ ))} +
+ ); +} +``` + +- [ ] **Step 4: Mount it in `main.tsx`** + +Replace the full contents of `frontend/src/main.tsx`: + +```tsx +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BannerStack } from './components/BannerStack.tsx' +import { LoginGate } from './LoginGate.tsx' + +createRoot(document.getElementById('root')!).render( + + + + , +) +``` + +- [ ] **Step 5: Verify build and lint are clean** + +Run: `cd frontend && npm run build 2>&1 | tail -30` +Expected: clean build, no errors. + +Run: `cd frontend && npm run lint 2>&1 | tail -30` +Expected: only the pre-existing, unrelated `PaceField.tsx` fast-refresh warnings. + +- [ ] **Step 6: Manual smoke test of the store in isolation** + +Nothing calls `showError`/etc. yet, so verify the plumbing itself works before wiring any real +caller into it: + +Start the dev server (`cd frontend && npm run dev`), open the app in a browser, open the browser +console, and run: + +```js +const m = await import("/src/banner.ts"); +m.showError("Test error banner"); +m.showSuccess("Test success banner"); +``` + +(This dynamic import resolves to the same singleton module instance Vite is already serving to +``, since Vite's dev server caches ES modules by URL -- it is not a separate, +disconnected instance.) + +Expected: two banners appear, stacked (success on top since it was shown second, newest-on-top), +red error on the bottom, green success above it, pushing the login screen down slightly. Click the +× on one -- it disappears immediately. Wait ~8s -- the other disappears on its own. + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/banner.ts frontend/src/components/BannerStack.tsx frontend/src/components/BannerStack.css frontend/src/main.tsx +git commit -m "$(cat <<'EOF' +feat(frontend): add shared banner system (error/warning/notice/success) + +A plain module-level singleton store (banner.ts) plus a single renderer +(BannerStack, mounted once above LoginGate in main.tsx) -- no React +Context, since this codebase has none today and a plain exported +function is callable from anywhere, including api/client.ts (a plain +module, not a component) in a later task. Nothing calls it yet; that's +each of this plan's remaining tasks. +EOF +)" +``` + +--- + +### Task 2: Friendlier network-unreachable message in `client.ts` + +**Files:** +- Modify: `frontend/src/api/client.ts:18-38` (`request`) + +**Interfaces:** +- Consumes: nothing new. +- Produces: no signature change to `request`/`api.*` -- callers are unaffected. The only + observable change is the *text* of the `Error` thrown when `fetch()` itself fails (network + down, connection refused, offline), which every later task's `showError(String(e))` call site + picks up automatically. + +- [ ] **Step 1: Wrap the `fetch()` call** + +In `frontend/src/api/client.ts`, replace: + +```ts +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE_URL}${path}`, { + headers: { "Content-Type": "application/json" }, + credentials: "include", + ...init, + }); + if (res.status === 401 && path !== "/api/session/me") { +``` + +with: + +```ts +async function request(path: string, init?: RequestInit): Promise { + let res: Response; + try { + res = await fetch(`${BASE_URL}${path}`, { + headers: { "Content-Type": "application/json" }, + credentials: "include", + ...init, + }); + } catch { + // fetch() itself rejecting (not a non-2xx response, which is handled + // below) means the backend genuinely isn't reachable -- offline, + // connection refused, CORS, etc. The raw browser error text here + // (e.g. "TypeError: Failed to fetch") is not something to show a user; + // every caller's catch-and-showError already displays whatever this + // throws, so the friendly text only needs to be written once, here. + throw new Error("Can't reach the server — check your connection."); + } + if (res.status === 401 && path !== "/api/session/me") { +``` + +(the rest of `request` -- the 401 branch, the `!res.ok` branch, the 204/json return -- is +unchanged; only the `fetch()` call itself moves inside a `try`/`catch`.) + +- [ ] **Step 2: Verify build and lint are clean** + +Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. +Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` warnings. + +- [ ] **Step 3: Manual verification** + +Stop the backend (if running), start the frontend dev server, load the app, and confirm any action +that hits the API (e.g. the initial session check) eventually surfaces "Can't reach the server — +check your connection." rather than a raw `TypeError` -- note this won't render as a banner yet +(no caller uses `showError` until Task 3+), so check via the browser console/network tab that the +thrown error's `.message` is the friendly text, or via a temporary `console.log` you remove before +committing. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/api/client.ts +git commit -m "$(cat <<'EOF' +fix(frontend): show a friendly message when the backend is unreachable + +request()'s fetch() call throwing (network down, connection refused, +offline) previously propagated the raw browser error text (e.g. +"TypeError: Failed to fetch"). Centralizing the friendly message here, +rather than in each call site, means every existing +catch((e) => showError(String(e))) pattern gets it for free. +EOF +)" +``` + +--- + +### Task 3: Migrate `GarminConnection.tsx` + +**Files:** +- Modify: `frontend/src/components/GarminConnection.tsx` + +**Interfaces:** +- Consumes: `showError` (Task 1). +- Produces: nothing new -- `SyncModal` (Task 4) is unaffected by this task, still rendered the + same way (`{showSyncModal && }`). + +- [ ] **Step 1: Add the import** + +In `frontend/src/components/GarminConnection.tsx`, replace: + +```tsx +import { useEffect, useState } from "react"; +import { api } from "../api/client"; +import type { AuthResponse } from "../types/api"; +import { SyncModal } from "./SyncModal"; +``` + +with: + +```tsx +import { useEffect, useState } from "react"; +import { api } from "../api/client"; +import { showError } from "../banner"; +import type { AuthResponse } from "../types/api"; +import { SyncModal } from "./SyncModal"; +``` + +- [ ] **Step 2: Remove the local `error` state** + +Replace: + +```tsx + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + // There's no real Garmin logout -- the backend session just sits idle. +``` + +with: + +```tsx + const [busy, setBusy] = useState(false); + // There's no real Garmin logout -- the backend session just sits idle. +``` + +- [ ] **Step 3: Swap every `setError` call site for `showError`, remove the now-unneeded resets** + +Replace the mount effect: + +```tsx + useEffect(() => { + api.authStatus().then(setAuth).catch((e) => setError(String(e))); + const interval = setInterval(() => { +``` + +with: + +```tsx + useEffect(() => { + api.authStatus().then(setAuth).catch((e) => showError(String(e))); + const interval = setInterval(() => { +``` + +Replace `connect()`: + +```tsx + async function connect() { + setBusy(true); + setError(null); + try { + await onBeforeConnect?.(); + setAuth(await api.login()); + setDisconnected(false); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + } +``` + +with: + +```tsx + async function connect() { + setBusy(true); + try { + await onBeforeConnect?.(); + setAuth(await api.login()); + setDisconnected(false); + } catch (e) { + showError(String(e)); + } finally { + setBusy(false); + } + } +``` + +Replace `submitMFA()`: + +```tsx + async function submitMFA() { + if (!code.trim()) return; + setBusy(true); + setError(null); + try { + setAuth(await api.submitMFA(code.trim())); + setCode(""); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + } +``` + +with: + +```tsx + async function submitMFA() { + if (!code.trim()) return; + setBusy(true); + try { + setAuth(await api.submitMFA(code.trim())); + setCode(""); + } catch (e) { + showError(String(e)); + } finally { + setBusy(false); + } + } +``` + +Replace `sync()`: + +```tsx + async function sync() { + setBusy(true); + setError(null); + try { + await api.syncRun(); + setShowSyncModal(true); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + } +``` + +with: + +```tsx + async function sync() { + setBusy(true); + try { + await api.syncRun(); + setShowSyncModal(true); + } catch (e) { + showError(String(e)); + } finally { + setBusy(false); + } + } +``` + +Replace `resetAll()`'s body from `setBusy(true)` through its catch block: + +```tsx + setBusy(true); + setError(null); + try { + await api.resetSync(); + // Reset runs as a background sync job; wait for it to actually finish + // before reloading, otherwise other pages (e.g. Activities) would + // still show the just-deleted activities from their own stale state. + let status = await api.syncStatus(); + while (status.in_progress) { + await new Promise((resolve) => setTimeout(resolve, 300)); + status = await api.syncStatus(); + } + window.location.reload(); + } catch (e) { + setError(String(e)); + setBusy(false); + } +``` + +with: + +```tsx + setBusy(true); + try { + await api.resetSync(); + // Reset runs as a background sync job; wait for it to actually finish + // before reloading, otherwise other pages (e.g. Activities) would + // still show the just-deleted activities from their own stale state. + let status = await api.syncStatus(); + while (status.in_progress) { + await new Promise((resolve) => setTimeout(resolve, 300)); + status = await api.syncStatus(); + } + window.location.reload(); + } catch (e) { + showError(String(e)); + setBusy(false); + } +``` + +- [ ] **Step 4: Remove the inline error paragraph** + +Replace: + +```tsx + {!disconnected && auth?.message &&

{auth.message}

} + + {error &&

{error}

} + + {showSyncModal && setShowSyncModal(false)} />} +``` + +with: + +```tsx + {!disconnected && auth?.message &&

{auth.message}

} + + {showSyncModal && setShowSyncModal(false)} />} +``` + +- [ ] **Step 5: Verify build and lint are clean** + +Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. +Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` warnings. + +- [ ] **Step 6: Manual smoke test** + +With the backend stopped, load the Profile page and click "Connect to Garmin" -- confirm a red +error banner appears (the friendly "Can't reach the server" message from Task 2) instead of +nothing/a silent failure. + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/components/GarminConnection.tsx +git commit -m "$(cat <<'EOF' +refactor(frontend): migrate GarminConnection.tsx to the banner system + +Removes the local error state and its inline paragraph; every failure +(connect, MFA, sync trigger, reset-all) now calls showError directly. +EOF +)" +``` + +--- + +### Task 4: Migrate `SyncModal.tsx` (auto-close on completion, banner for the result) + +**Files:** +- Modify: `frontend/src/components/SyncModal.tsx` + +**Interfaces:** +- Consumes: `showError`/`showSuccess` (Task 1). +- Produces: `SyncModal({ onClose })`'s prop signature is unchanged -- `GarminConnection.tsx` + (Task 3) needs no further change here. Behavior changes: the modal now closes itself (calling + `onClose`) the instant a poll reports `in_progress: false`, instead of rendering a final-result + block and waiting for a manual Close click. + +- [ ] **Step 1: Replace the whole file** + +The modal keeps its `Close` button as a manual escape hatch (e.g. if a user wants to dismiss it +before it auto-closes, or if polling gets stuck) -- it's unconditionally rendered today and stays +that way; only the "final result" block is removed, since that's now handled by the banner. + +Replace the full contents of `frontend/src/components/SyncModal.tsx`: + +```tsx +import { useEffect, useRef, useState } from "react"; +import { api } from "../api/client"; +import { showError, showSuccess } from "../banner"; +import type { SyncStatus } from "../types/api"; + +function phaseDisplay(status: SyncStatus): { label: string; bar: { done: number; total: number } | null } { + const { Phase, Done, Total } = status.progress; + switch (Phase) { + case "discovering": + return { label: "Discovering activities…", bar: null }; + case "activities": + return { label: `Activities: ${Done}/${Total}`, bar: { done: Done, total: Total } }; + case "workouts": + return { label: `Workouts: ${Done}/${Total}`, bar: { done: Done, total: Total } }; + default: + return { label: "Starting…", bar: null }; + } +} + +// Builds the single banner message covering both the sync outcome and any +// pending-activities/pending-workouts nudge, so completion produces exactly +// one banner (with the nudges as extra lines) rather than several separate +// ones landing at once. +function completionMessage(status: SyncStatus): string { + const lines: string[] = []; + if (status.last_run) { + lines.push( + status.last_run.Status === "error" + ? `Sync failed: ${status.last_run.ErrorMessage}` + : `Sync complete: ${status.last_run.ActivitiesFetched} new activities`, + ); + } + if (status.activities_pending_details > 0) { + lines.push(`${status.activities_pending_details} more activities pending — click Sync now again`); + } + if (status.workouts_pending > 0) { + lines.push(`${status.workouts_pending} more workouts pending — click Sync now again`); + } + return lines.join("\n"); +} + +// Blocking overlay shown while "Sync now" is running -- purely the progress +// bar/spinner (see docs/superpowers/specs/2026-07-27-modern-error-displays-design.md). +// The instant a poll reports the sync finished, it reports the result via a +// banner (success/error, folded together with any pending nudge) and closes +// itself -- there's no manual Close step for the *result* anymore. The Close +// button stays as a manual escape hatch for a still-running (or stuck) sync. +export function SyncModal({ onClose }: { onClose: () => void }) { + const [status, setStatus] = useState(null); + const stoppedRef = useRef(false); + // Captures the latest onClose without making it an effect dependency -- + // GarminConnection passes a fresh inline arrow function on every one of + // its own re-renders (e.g. its 6s auth-status poll), and this effect must + // not restart because of that (it would reset stoppedRef/the in-flight + // timeout and silently double the poll cadence). + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + useEffect(() => { + stoppedRef.current = false; + let timeout: ReturnType; + const tick = () => { + api + .syncStatus() + .then((s) => { + setStatus(s); + if (!s.in_progress) { + if (s.last_run?.Status === "error") { + showError(completionMessage(s)); + } else { + showSuccess(completionMessage(s)); + } + onCloseRef.current(); + return; + } + if (!stoppedRef.current) timeout = setTimeout(tick, 1500); + }) + .catch((e) => { + showError(String(e)); + if (!stoppedRef.current) timeout = setTimeout(tick, 1500); + }); + }; + tick(); + return () => { + stoppedRef.current = true; + clearTimeout(timeout); + }; + }, []); + + const { label, bar } = status ? phaseDisplay(status) : { label: "Starting…", bar: null }; + + return ( +
+
+
+ Synchronizing +
+
+ {bar ? ( + <> + +

{label}

+ + ) : ( + <> +
+

{label}

+ + )} +
+
+ +
+
+
+ ); +} +``` + +- [ ] **Step 2: Verify build and lint are clean** + +Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. +Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` warnings. + +- [ ] **Step 3: Manual smoke test** + +Against `cmd/seedsample` data or a real Garmin-connected profile, click "Sync now" on the Profile +page and confirm: +- The modal appears immediately (spinner + "Discovering activities…"), same as before. +- It transitions through "Activities: N/M" / "Workouts: N/M" progress bars, same as before. +- The instant sync finishes, the modal disappears on its own (no manual click needed) and a banner + appears with the result (green "Sync complete: N new activities", or red "Sync failed: ..."), + including any pending-activities/pending-workouts line folded into the same banner. +- Clicking "Close" while a sync is still in progress dismisses the modal early without producing + a banner (there's no result to report yet). + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/components/SyncModal.tsx +git commit -m "$(cat <<'EOF' +refactor(frontend): SyncModal reports its result via a banner, auto-closes + +The modal is now purely the progress bar/spinner. The instant a poll +reports the sync finished, it builds one banner message (the +success/error line plus any pending-activities/pending-workouts +nudge as extra lines) and closes itself -- no more manual Close click +to dismiss a result the user already saw. The Close button stays as a +manual escape hatch for a still-running sync. +EOF +)" +``` + +--- + +### Task 5: Migrate `Profile.tsx` + +**Files:** +- Modify: `frontend/src/pages/Profile.tsx` + +**Interfaces:** +- Consumes: `showError` (Task 1). +- Produces: nothing new. + +- [ ] **Step 1: Add the import** + +Replace: + +```tsx +import { useEffect, useRef, useState } from "react"; +import { api, BASE_URL } from "../api/client"; +import { ColorField } from "../components/ColorField"; +``` + +with: + +```tsx +import { useEffect, useRef, useState } from "react"; +import { api, BASE_URL } from "../api/client"; +import { showError } from "../banner"; +import { ColorField } from "../components/ColorField"; +``` + +- [ ] **Step 2: Replace the local `error` state with a `profileLoadFailed` boolean** + +The page can't render its fields at all without `profile` loaded, so unlike the other pages in +this plan, its "nothing loaded yet" fallback needs *some* way to distinguish "still loading" from +"failed to load" -- the actual error text now lives only in the (transient) banner, so a boolean +drives which fallback text shows. + +Replace: + +```tsx + const [profile, setProfile] = useState(null); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); +``` + +with: + +```tsx + const [profile, setProfile] = useState(null); + const [profileLoadFailed, setProfileLoadFailed] = useState(false); + const [saved, setSaved] = useState(false); +``` + +- [ ] **Step 3: Update the mount effect** + +Replace: + +```tsx + useEffect(() => { + api.getProfile().then(setProfile).catch((e) => setError(String(e))); + }, []); +``` + +with: + +```tsx + useEffect(() => { + api.getProfile().then(setProfile).catch((e) => { + showError(String(e)); + setProfileLoadFailed(true); + }); + }, []); +``` + +- [ ] **Step 4: Update `persist()`** + +Replace: + +```tsx + async function persist(next: ProfileType) { + try { + const updated = await api.updateProfile(next); + profileRef.current = updated; + setProfile(updated); + setError(null); + setSaved(true); + onSaved?.(updated); + } catch (e) { + setError(String(e)); + } + } +``` + +with: + +```tsx + async function persist(next: ProfileType) { + try { + const updated = await api.updateProfile(next); + profileRef.current = updated; + setProfile(updated); + setSaved(true); + onSaved?.(updated); + } catch (e) { + showError(String(e)); + } + } +``` + +- [ ] **Step 5: Update `deleteAccount()`** + +Replace: + +```tsx + async function deleteAccount() { + setDeleting(true); + setError(null); + try { +``` + +with: + +```tsx + async function deleteAccount() { + setDeleting(true); + try { +``` + +Replace: + +```tsx + } catch (e) { + setError(String(e)); + setDeleting(false); + } + } + + if (!profile) { + return ( +
+ {error ?

{error}

:

Loading...

} +
+ ); + } +``` + +with: + +```tsx + } catch (e) { + showError(String(e)); + setDeleting(false); + } + } + + if (!profile) { + return ( +
+ {profileLoadFailed ?

Couldn't load profile.

:

Loading...

} +
+ ); + } +``` + +- [ ] **Step 6: Remove the inline error paragraph in the main render** + +Replace: + +```tsx +
+ {error &&

{error}

} + {saved &&

Saved.

} +``` + +with: + +```tsx +
+ {saved &&

Saved.

} +``` + +- [ ] **Step 7: Verify build and lint are clean** + +Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. +Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` warnings. + +- [ ] **Step 8: Manual smoke test** + +With the backend running, load the Profile page and edit a field -- confirm autosave still works +with no visual regression. Stop the backend, edit a field again -- confirm a red error banner +appears (autosave failure) rather than nothing. + +- [ ] **Step 9: Commit** + +```bash +git add frontend/src/pages/Profile.tsx +git commit -m "$(cat <<'EOF' +refactor(frontend): migrate Profile.tsx to the banner system + +Load/save/delete failures now call showError instead of setting local +error state. The profile-not-loaded-yet fallback gains a +profileLoadFailed boolean so it can still distinguish "loading" from +"failed to load" without the error text itself, which now lives only +in the (transient) banner. +EOF +)" +``` + +--- + +### Task 6: Migrate `Activities.tsx` + +**Files:** +- Modify: `frontend/src/pages/Activities.tsx` + +**Interfaces:** +- Consumes: `showError` (Task 1). +- Produces: nothing new. + +- [ ] **Step 1: Add the import** + +Replace: + +```tsx +import { useCallback, useEffect, useRef, useState } from "react"; +import { api } from "../api/client"; +import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart"; +``` + +with: + +```tsx +import { useCallback, useEffect, useRef, useState } from "react"; +import { api } from "../api/client"; +import { showError } from "../banner"; +import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart"; +``` + +- [ ] **Step 2: Remove the local `error` state** + +Replace: + +```tsx + const [filterKindId, setFilterKindId] = useState(""); + const [error, setError] = useState(null); + const [resolvingId, setResolvingId] = useState(null); +``` + +with: + +```tsx + const [filterKindId, setFilterKindId] = useState(""); + const [resolvingId, setResolvingId] = useState(null); +``` + +- [ ] **Step 3: Update `loadMore`'s catch** + +Replace: + +```tsx + } catch (e) { + if (gen === generationRef.current) setError(String(e)); + } finally { + setLoadingMore(false); +``` + +with: + +```tsx + } catch (e) { + if (gen === generationRef.current) showError(String(e)); + } finally { + setLoadingMore(false); +``` + +- [ ] **Step 4: Update `loadFirstPage`'s catch** + +Replace: + +```tsx + .catch((e) => { + if (gen === generationRef.current) setError(String(e)); + }) + .finally(() => { + if (gen === generationRef.current) setInitialLoading(false); + }); +``` + +with: + +```tsx + .catch((e) => { + if (gen === generationRef.current) showError(String(e)); + }) + .finally(() => { + if (gen === generationRef.current) setInitialLoading(false); + }); +``` + +- [ ] **Step 5: Update the mount effect** + +Replace: + +```tsx + useEffect(() => { + loadFirstPage(""); + api.listWorkoutKinds().then(setKinds).catch((e) => setError(String(e))); + api.getProfile().then(setProfile).catch((e) => setError(String(e))); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +``` + +with: + +```tsx + useEffect(() => { + loadFirstPage(""); + api.listWorkoutKinds().then(setKinds).catch((e) => showError(String(e))); + api.getProfile().then(setProfile).catch((e) => showError(String(e))); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +``` + +- [ ] **Step 6: Update `resolve`, `unassign`, `unlock`** + +Replace (in `resolve`): + +```tsx + } catch (e) { + setError(String(e)); + } finally { + setResolvingId(null); + } + } + + async function unassign(activityId: number) { +``` + +with: + +```tsx + } catch (e) { + showError(String(e)); + } finally { + setResolvingId(null); + } + } + + async function unassign(activityId: number) { +``` + +Replace (in `unassign`): + +```tsx + } catch (e) { + setError(String(e)); + } finally { + setResolvingId(null); + } + } + + async function unlock(activityId: number) { +``` + +with: + +```tsx + } catch (e) { + showError(String(e)); + } finally { + setResolvingId(null); + } + } + + async function unlock(activityId: number) { +``` + +Replace (in `unlock`): + +```tsx + } catch (e) { + setError(String(e)); + } finally { + setResolvingId(null); + } + } + + function toggleFilter(id: string) { +``` + +with: + +```tsx + } catch (e) { + showError(String(e)); + } finally { + setResolvingId(null); + } + } + + function toggleFilter(id: string) { +``` + +- [ ] **Step 7: Remove the inline error paragraph** + +Replace: + +```tsx +
+ {error &&

{error}

} + + {grandTotal > 0 && ( +``` + +with: + +```tsx +
+ {grandTotal > 0 && ( +``` + +- [ ] **Step 8: Verify build and lint are clean** + +Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. +Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` warnings. + +- [ ] **Step 9: Manual smoke test** + +With the backend running, load the Activities tab -- confirm the list still loads and +classify/unlock actions still work with no visual regression. Stop the backend and reload -- +confirm a red error banner appears instead of a silent failure. + +- [ ] **Step 10: Commit** + +```bash +git add frontend/src/pages/Activities.tsx +git commit -m "$(cat <<'EOF' +refactor(frontend): migrate Activities.tsx to the banner system + +Every load/classify/unlock failure now calls showError instead of +setting local error state; the inline error paragraph is removed. +EOF +)" +``` + +--- + +### Task 7: Migrate `Analysis.tsx` + +**Files:** +- Modify: `frontend/src/pages/Analysis.tsx` + +**Interfaces:** +- Consumes: `showError` (Task 1). +- Produces: nothing new. + +- [ ] **Step 1: Add the import** + +Replace: + +```tsx +import { useEffect, useState } from "react"; +import { api } from "../api/client"; +import { ProgressionChart } from "../components/charts/ProgressionChart"; +``` + +with: + +```tsx +import { useEffect, useState } from "react"; +import { api } from "../api/client"; +import { showError } from "../banner"; +import { ProgressionChart } from "../components/charts/ProgressionChart"; +``` + +- [ ] **Step 2: Remove the local `error` state and update both effects** + +Replace: + +```tsx + const [points, setPoints] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + api + .listWorkoutKinds() + .then((k) => { + setKinds(k); + if (k.length > 0) setSelectedKindId(k[0].ID); + }) + .catch((e) => setError(String(e))); + }, []); + + useEffect(() => { + if (selectedKindId == null) return; + setLoading(true); + setError(null); + api + .progression(selectedKindId, metric) + .then(setPoints) + .catch((e) => setError(String(e))) + .finally(() => setLoading(false)); + }, [selectedKindId, metric]); +``` + +with: + +```tsx + const [points, setPoints] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + api + .listWorkoutKinds() + .then((k) => { + setKinds(k); + if (k.length > 0) setSelectedKindId(k[0].ID); + }) + .catch((e) => showError(String(e))); + }, []); + + useEffect(() => { + if (selectedKindId == null) return; + setLoading(true); + api + .progression(selectedKindId, metric) + .then(setPoints) + .catch((e) => showError(String(e))) + .finally(() => setLoading(false)); + }, [selectedKindId, metric]); +``` + +- [ ] **Step 3: Remove the inline error paragraph** + +Replace: + +```tsx + {error &&

{error}

} + {loading ?

Loading...

: } +``` + +with: + +```tsx + {loading ?

Loading...

: } +``` + +- [ ] **Step 4: Verify build and lint are clean** + +Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. +Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` warnings. + +- [ ] **Step 5: Manual smoke test** + +With the backend running, load the Analysis tab and switch training types/metrics -- confirm no +visual regression. Stop the backend and switch again -- confirm a red error banner appears. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/pages/Analysis.tsx +git commit -m "$(cat <<'EOF' +refactor(frontend): migrate Analysis.tsx to the banner system +EOF +)" +``` + +--- + +### Task 8: Migrate `TrainingTypesCard.tsx` + +**Files:** +- Modify: `frontend/src/components/TrainingTypesCard.tsx` + +**Interfaces:** +- Consumes: `showError` (Task 1). +- Produces: nothing new. + +- [ ] **Step 1: Add the import** + +Replace: + +```tsx +import { useEffect, useState } from "react"; +import { api } from "../api/client"; +import { NullableNumberField } from "./NullableNumberField"; +``` + +with: + +```tsx +import { useEffect, useState } from "react"; +import { api } from "../api/client"; +import { showError } from "../banner"; +import { NullableNumberField } from "./NullableNumberField"; +``` + +- [ ] **Step 2: Remove the local `error` state and its resets** + +Replace: + +```tsx + const [hrMax, setHrMax] = useState(null); + const [error, setError] = useState(null); + + function reload() { + api.listWorkoutKinds(true).then(setKinds).catch((e) => setError(String(e))); + } + + useEffect(reload, []); + + function startEdit(k: WorkoutKind) { + setEditing(k); + setName(k.Name); + setDescription(k.Description); + setPaceMin(k.pace_min_sec_per_km); + setPaceMax(k.pace_max_sec_per_km); + setHrMin(k.hr_min_pct_hrr); + setHrMax(k.hr_max_pct_hrr); + setError(null); + } + + async function save() { + if (!editing) return; + setError(null); + try { + await api.updateWorkoutKind(editing.ID, { + name, + description, + rule: JSON.parse(editing.RuleJSON || "{}"), + pace_min_sec_per_km: paceMin, + pace_max_sec_per_km: paceMax, + hr_min_pct_hrr: hrMin, + hr_max_pct_hrr: hrMax, + }); + setEditing(null); + reload(); + } catch (e) { + setError(String(e)); + } + } +``` + +with: + +```tsx + const [hrMax, setHrMax] = useState(null); + + function reload() { + api.listWorkoutKinds(true).then(setKinds).catch((e) => showError(String(e))); + } + + useEffect(reload, []); + + function startEdit(k: WorkoutKind) { + setEditing(k); + setName(k.Name); + setDescription(k.Description); + setPaceMin(k.pace_min_sec_per_km); + setPaceMax(k.pace_max_sec_per_km); + setHrMin(k.hr_min_pct_hrr); + setHrMax(k.hr_max_pct_hrr); + } + + async function save() { + if (!editing) return; + try { + await api.updateWorkoutKind(editing.ID, { + name, + description, + rule: JSON.parse(editing.RuleJSON || "{}"), + pace_min_sec_per_km: paceMin, + pace_max_sec_per_km: paceMax, + hr_min_pct_hrr: hrMin, + hr_max_pct_hrr: hrMax, + }); + setEditing(null); + reload(); + } catch (e) { + showError(String(e)); + } + } +``` + +- [ ] **Step 3: Remove the inline error paragraph** + +Replace: + +```tsx + Training types + + {error &&

{error}

} + +
    +``` + +with: + +```tsx + Training types + +
      +``` + +- [ ] **Step 4: Verify build and lint are clean** + +Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. +Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` warnings. + +- [ ] **Step 5: Manual smoke test** + +With the backend running, edit and save a training type on the Profile page -- confirm no visual +regression. Stop the backend and try saving again -- confirm a red error banner appears. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/components/TrainingTypesCard.tsx +git commit -m "$(cat <<'EOF' +refactor(frontend): migrate TrainingTypesCard.tsx to the banner system +EOF +)" +``` + +--- + +### Task 9: Migrate `LoginGate.tsx` + +**Files:** +- Modify: `frontend/src/LoginGate.tsx` +- Modify: `frontend/src/LoginGate.css:14-16` (remove `.login-gate-error`) + +**Interfaces:** +- Consumes: `showError` (Task 1). +- Produces: nothing new. + +- [ ] **Step 1: Move the `auth_error` handling into an effect** + +In `frontend/src/LoginGate.tsx`, replace: + +```tsx +import { useEffect, useState } from "react"; +import { api, BASE_URL } from "./api/client"; +import "./LoginGate.css"; +import App from "./App"; +``` + +with: + +```tsx +import { useEffect, useState } from "react"; +import { api, BASE_URL } from "./api/client"; +import { showError } from "./banner"; +import "./LoginGate.css"; +import App from "./App"; +``` + +Replace: + +```tsx + useEffect(() => { + api + .getSessionInfo() + .then((s) => { + setSession(s); + setStatus("authenticated"); + }) + .catch(() => setStatus("unauthenticated")); + }, []); + + if (status === "loading") { + return
      Loading…
      ; + } + + if (status === "unauthenticated") { + const authError = new URLSearchParams(window.location.search).get("auth_error"); + return ( +
      +

      🧞‍♀️ geniusrun

      + {authError &&

      {AUTH_ERROR_MESSAGES[authError] ?? "Login failed, please try again."}

      } + + Log in + +
      + ); + } +``` + +with: + +```tsx + useEffect(() => { + api + .getSessionInfo() + .then((s) => { + setSession(s); + setStatus("authenticated"); + }) + .catch(() => setStatus("unauthenticated")); + }, []); + + // Keycloak redirects back here with ?auth_error= when the + // login-gate's own role check (not Keycloak's own authentication) rejects + // a session -- surfaced once, as a banner, the moment this screen is + // reached. + useEffect(() => { + if (status !== "unauthenticated") return; + const authError = new URLSearchParams(window.location.search).get("auth_error"); + if (authError) showError(AUTH_ERROR_MESSAGES[authError] ?? "Login failed, please try again."); + }, [status]); + + if (status === "loading") { + return
      Loading…
      ; + } + + if (status === "unauthenticated") { + return ( +
      +

      🧞‍♀️ geniusrun

      + + Log in + +
      + ); + } +``` + +- [ ] **Step 2: Remove the now-dead CSS rule** + +In `frontend/src/LoginGate.css`, remove: + +```css +.login-gate-error { + color: #f87171; +} + +``` + +(the blank line after it goes too, so the file doesn't end up with a doubled blank line between +the remaining rules). + +- [ ] **Step 3: Verify build and lint are clean** + +Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. +Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` warnings. + +- [ ] **Step 4: Manual smoke test** + +Load `/?auth_error=forbidden` while logged out -- confirm the login screen shows a +red error banner reading "Your account isn't authorized for geniusrun." instead of the old inline +text, and the "Log in" link/button still works normally. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/LoginGate.tsx frontend/src/LoginGate.css +git commit -m "$(cat <<'EOF' +refactor(frontend): migrate LoginGate.tsx's auth_error to the banner system +EOF +)" +``` + +--- + +### Task 10: Migrate `OnboardingWizard.tsx` (splitting validation from real failures) + +**Files:** +- Modify: `frontend/src/OnboardingWizard.tsx` +- `frontend/src/OnboardingWizard.css` is NOT modified in this task -- `.onboarding-wizard-error` + stays in active use (see Step 4). + +**Interfaces:** +- Consumes: `showError` (Task 1). +- Produces: nothing new. `onCreated` prop signature unchanged. + +- [ ] **Step 1: Add the import, split the state** + +Field-validation messages ("Please enter a display name.", "Please enter your Garmin email and +password.") stay local and inline -- they're about fixing a specific input, not a transient system +event. Real failures (Garmin login rejected, MFA rejected, `complete()` failing) call `showError` +instead. The `complete()`-failure Retry button's visibility switches from `error &&` to a new +`completeFailed` boolean, since the error text itself now lives only in the (transient) banner. + +Replace: + +```tsx +import { useEffect, useRef, useState } from "react"; +import { api } from "./api/client"; +import "./OnboardingWizard.css"; +import type { AuthResponse } from "./types/api"; +``` + +with: + +```tsx +import { useEffect, useRef, useState } from "react"; +import { api } from "./api/client"; +import { showError } from "./banner"; +import "./OnboardingWizard.css"; +import type { AuthResponse } from "./types/api"; +``` + +Replace: + +```tsx + const [auth, setAuth] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const completeTriggered = useRef(false); + + function submitName(e: React.FormEvent) { + e.preventDefault(); + if (!displayName.trim()) { + setError("Please enter a display name."); + return; + } + setError(null); + setStep("garmin"); + } + + async function submitGarmin(e: React.FormEvent) { + e.preventDefault(); + if (!email.trim() || !password) { + setError("Please enter your Garmin email and password."); + return; + } + setBusy(true); + setError(null); + try { + setAuth(await api.setupGarminLogin(email.trim(), password)); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong, please try again."); + } finally { + setBusy(false); + } + } + + async function submitMFA() { + if (!code.trim()) return; + setBusy(true); + setError(null); + try { + setAuth(await api.setupGarminMFA(code.trim())); + setCode(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong, please try again."); + } finally { + setBusy(false); + } + } + + async function complete() { + setBusy(true); + setError(null); + try { + const result = await api.setupComplete(displayName.trim()); + onCreated(result.display_name); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong, please try again."); + setBusy(false); + } + } +``` + +with: + +```tsx + const [auth, setAuth] = useState(null); + const [busy, setBusy] = useState(false); + // Field-validation messages only ("please fill this in") -- real + // API/Garmin failures go through the shared banner system instead (see + // completeFailed below for why the Retry button no longer keys off this). + const [validationError, setValidationError] = useState(null); + const [completeFailed, setCompleteFailed] = useState(false); + const completeTriggered = useRef(false); + + function submitName(e: React.FormEvent) { + e.preventDefault(); + if (!displayName.trim()) { + setValidationError("Please enter a display name."); + return; + } + setValidationError(null); + setStep("garmin"); + } + + async function submitGarmin(e: React.FormEvent) { + e.preventDefault(); + if (!email.trim() || !password) { + setValidationError("Please enter your Garmin email and password."); + return; + } + setBusy(true); + setValidationError(null); + try { + setAuth(await api.setupGarminLogin(email.trim(), password)); + } catch (err) { + showError(err instanceof Error ? err.message : "Something went wrong, please try again."); + } finally { + setBusy(false); + } + } + + async function submitMFA() { + if (!code.trim()) return; + setBusy(true); + try { + setAuth(await api.setupGarminMFA(code.trim())); + setCode(""); + } catch (err) { + showError(err instanceof Error ? err.message : "Something went wrong, please try again."); + } finally { + setBusy(false); + } + } + + async function complete() { + setBusy(true); + setCompleteFailed(false); + try { + const result = await api.setupComplete(displayName.trim()); + onCreated(result.display_name); + } catch (err) { + showError(err instanceof Error ? err.message : "Something went wrong, please try again."); + setCompleteFailed(true); + setBusy(false); + } + } +``` + +- [ ] **Step 2: Update the three JSX render sites** + +Replace (the "name" step): + +```tsx + {error &&

      {error}

      } + +
+ ); + } +``` + +with: + +```tsx + {validationError &&

{validationError}

} + +
+ ); + } +``` + +Replace (the "authenticated, finishing setup" branch): + +```tsx +

Connected to Garmin — finishing setup…

+ {error && ( + <> +

{error}

+ + + )} +``` + +with: + +```tsx +

Connected to Garmin — finishing setup…

+ {completeFailed && ( + + )} +``` + +Replace (the "mfa_required" branch): + +```tsx + {auth.message &&

{auth.message}

} + {error &&

{error}

} + +
+ ) : ( +``` + +with: + +```tsx + {auth.message &&

{auth.message}

} + +
+ ) : ( +``` + +Replace (the Garmin-login form branch, the last remaining use of `error`): + +```tsx + {auth?.message &&

{auth.message}

} + {error &&

{error}

} + + + )} +``` + +with: + +```tsx + {auth?.message &&

{auth.message}

} + {validationError &&

{validationError}

} + + + )} +``` + +- [ ] **Step 3: Verify build and lint are clean** + +Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. +Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` warnings. + +- [ ] **Step 4: Manual smoke test** + +Walk through onboarding as a brand-new account: leave the display name blank and submit -- confirm +the inline "Please enter a display name." message still appears (not a banner). Enter a name, +continue, then enter a wrong Garmin password -- confirm a red error banner appears instead of the +old inline message, and the form is still usable to retry. + +Note: `frontend/src/OnboardingWizard.css`'s `.onboarding-wizard-error` rule is **not** touched in +this task -- it's still in active use by `validationError`'s two remaining +`

` sites, just no longer for real-failure messages. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/OnboardingWizard.tsx +git commit -m "$(cat <<'EOF' +refactor(frontend): split OnboardingWizard's validation vs real failures + +Field-validation messages ("please fill this in") stay local and +inline. Real failures (Garmin login rejected, MFA rejected, complete() +failing) now call showError. The complete()-failure Retry button's +visibility switches from the error text's presence to a new +completeFailed boolean, since the error text itself now lives only in +the (transient) banner. +EOF +)" +``` + +--- + +### Task 11: Remove the now-dead `.error` CSS class, final verification + +**Files:** +- Modify: `frontend/src/App.css:277-279` (remove `.error`) + +**Interfaces:** +- Consumes: nothing. +- Produces: nothing -- this is cleanup plus the plan's final verification pass. + +- [ ] **Step 1: Confirm nothing references `.error` anymore** + +Run: `grep -rn 'className="error"' frontend/src` +Expected: no output. (Tasks 3, 5, 6, 7, 8 removed every use; `OnboardingWizard.css`'s +`.onboarding-wizard-error` and `LoginGate.css`'s -- already removed in Task 9 -- are separate, +differently-named classes, not `.error` itself.) + +- [ ] **Step 2: Remove the dead rule** + +In `frontend/src/App.css`, remove: + +```css +.error { + color: #f87171; +} + +``` + +- [ ] **Step 3: Full verification** + +Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. +Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` +warnings. + +- [ ] **Step 4: End-to-end manual smoke test** + +With both the backend and frontend dev servers running: +- Trigger a real API error (e.g. an invalid save somewhere) -- confirm a red banner. +- Stop the backend and trigger any action -- confirm a red banner reading "Can't reach the server + — check your connection." +- Restart the backend, run a full sync to completion -- confirm the modal auto-closes and a green + success banner appears. +- Trigger a sync that leaves pending activities/workouts (e.g. interrupt a large backfill, or use + a small `detailFillLimit`) -- confirm the same banner's second line shows the pending nudge. +- Load `?auth_error=failed` while logged out -- confirm a red banner on the login screen. +- Trigger two errors in quick succession -- confirm they stack (newest on top) and each + auto-dismisses independently after ~8s, and that clicking a banner's × dismisses it immediately. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/App.css +git commit -m "$(cat <<'EOF' +chore(frontend): remove the now-dead .error CSS class + +Every inline error paragraph that used it has been migrated to the +banner system across the preceding tasks. +EOF +)" +``` + +--- + +## Final verification + +- [ ] Run the full frontend build/lint one more time: `cd frontend && npm run build && npm run lint` +- [ ] Re-run the end-to-end manual smoke test from Task 11, Step 4, once more, fresh. +- [ ] Use superpowers:finishing-a-development-branch to wrap up (tests green -> present the + merge/PR/keep-as-is menu).