Files
geniusrun/docs/superpowers/specs/2026-07-27-modern-error-displays-design.md
Christophe Vila b2c705ce29 docs: add design spec for modern error displays
Replaces every page/component's ad-hoc inline error paragraph with a
shared, module-level banner store (red/orange/blue/green, stacking,
auto-dismiss + manual close) -- from docs/IDEAS.md's backlog item.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:22:47 +02:00

168 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
(`<p className="error">{error}</p>` 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 `<div className="banner banner-{severity}">` 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
`<LoginGate />`:
```tsx
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BannerStack />
<LoginGate />
</StrictMode>,
);
```
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 && <p className="error">{error}</p>}` | 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 `<p className="login-gate-error">` 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.