Files
geniusrun/docs/superpowers/plans/2026-07-27-modern-error-displays-plan.md
Christophe Vila 3bf11ae6bf docs: add implementation plan for modern error displays
11 tasks: the shared banner store + renderer, a friendlier
network-unreachable message in client.ts, then migrating every
existing inline error site (GarminConnection, SyncModal, Profile,
Activities, Analysis, TrainingTypesCard, LoginGate, OnboardingWizard)
to it, finishing with dead-CSS cleanup.

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

55 KiB
Raw Permalink Blame History

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 <BannerStack> component (mounted once, in main.tsx, above <LoginGate />) 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."
  • <BannerStack> is mounted exactly once, in frontend/src/main.tsx, as a sibling above <LoginGate />, 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 <p className="error">-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:

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:

.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:

import { useSyncExternalStore } from "react";
import "./BannerStack.css";
import { dismiss, getSnapshot, subscribe } from "../banner";

// Mounted exactly once, in main.tsx, above <LoginGate /> -- 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 (
    <div className="banner-stack">
      {banners.map((b) => (
        <div key={b.id} className={`banner banner-${b.severity}`}>
          <span className="banner-message">{b.message}</span>
          <button type="button" className="banner-close" aria-label="Dismiss" onClick={() => dismiss(b.id)}>
            ×
          </button>
        </div>
      ))}
    </div>
  );
}
  • Step 4: Mount it in main.tsx

Replace the full contents of frontend/src/main.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(
  <StrictMode>
    <BannerStack />
    <LoginGate />
  </StrictMode>,
)
  • 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:

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 <BannerStack>, 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
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:

async function request<T>(path: string, init?: RequestInit): Promise<T> {
  const res = await fetch(`${BASE_URL}${path}`, {
    headers: { "Content-Type": "application/json" },
    credentials: "include",
    ...init,
  });
  if (res.status === 401 && path !== "/api/session/me") {

with:

async function request<T>(path: string, init?: RequestInit): Promise<T> {
  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
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 && <SyncModal onClose={...} />}).

  • Step 1: Add the import

In frontend/src/components/GarminConnection.tsx, replace:

import { useEffect, useState } from "react";
import { api } from "../api/client";
import type { AuthResponse } from "../types/api";
import { SyncModal } from "./SyncModal";

with:

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:

  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  // There's no real Garmin logout -- the backend session just sits idle.

with:

  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:

  useEffect(() => {
    api.authStatus().then(setAuth).catch((e) => setError(String(e)));
    const interval = setInterval(() => {

with:

  useEffect(() => {
    api.authStatus().then(setAuth).catch((e) => showError(String(e)));
    const interval = setInterval(() => {

Replace connect():

  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:

  async function connect() {
    setBusy(true);
    try {
      await onBeforeConnect?.();
      setAuth(await api.login());
      setDisconnected(false);
    } catch (e) {
      showError(String(e));
    } finally {
      setBusy(false);
    }
  }

Replace submitMFA():

  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:

  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():

  async function sync() {
    setBusy(true);
    setError(null);
    try {
      await api.syncRun();
      setShowSyncModal(true);
    } catch (e) {
      setError(String(e));
    } finally {
      setBusy(false);
    }
  }

with:

  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:

    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:

    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:

      {!disconnected && auth?.message && <p className="garmin-connection-message">{auth.message}</p>}

      {error && <p className="error">{error}</p>}

      {showSyncModal && <SyncModal onClose={() => setShowSyncModal(false)} />}

with:

      {!disconnected && auth?.message && <p className="garmin-connection-message">{auth.message}</p>}

      {showSyncModal && <SyncModal onClose={() => 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
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:

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<SyncStatus | null>(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<typeof setTimeout>;
    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 (
    <div className="modal-backdrop">
      <div className="modal-content sync-modal-content">
        <div className="modal-header">
          <span>Synchronizing</span>
        </div>
        <div className="sync-modal-body">
          {bar ? (
            <>
              <progress className="sync-modal-progress" value={bar.done} max={Math.max(bar.total, 1)} />
              <p className="sync-modal-label">{label}</p>
            </>
          ) : (
            <>
              <div className="sync-modal-spinner" />
              <p className="sync-modal-label">{label}</p>
            </>
          )}
        </div>
        <div className="modal-header-actions sync-modal-actions">
          <button type="button" onClick={onClose}>
            Close
          </button>
        </div>
      </div>
    </div>
  );
}
  • 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

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:

import { useEffect, useRef, useState } from "react";
import { api, BASE_URL } from "../api/client";
import { ColorField } from "../components/ColorField";

with:

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:

  const [profile, setProfile] = useState<ProfileType | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [saved, setSaved] = useState(false);

with:

  const [profile, setProfile] = useState<ProfileType | null>(null);
  const [profileLoadFailed, setProfileLoadFailed] = useState(false);
  const [saved, setSaved] = useState(false);
  • Step 3: Update the mount effect

Replace:

  useEffect(() => {
    api.getProfile().then(setProfile).catch((e) => setError(String(e)));
  }, []);

with:

  useEffect(() => {
    api.getProfile().then(setProfile).catch((e) => {
      showError(String(e));
      setProfileLoadFailed(true);
    });
  }, []);
  • Step 4: Update persist()

Replace:

  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:

  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:

  async function deleteAccount() {
    setDeleting(true);
    setError(null);
    try {

with:

  async function deleteAccount() {
    setDeleting(true);
    try {

Replace:

    } catch (e) {
      setError(String(e));
      setDeleting(false);
    }
  }

  if (!profile) {
    return (
      <div className="page">
        {error ? <p className="error">{error}</p> : <p>Loading...</p>}
      </div>
    );
  }

with:

    } catch (e) {
      showError(String(e));
      setDeleting(false);
    }
  }

  if (!profile) {
    return (
      <div className="page">
        {profileLoadFailed ? <p className="empty-state">Couldn't load profile.</p> : <p>Loading...</p>}
      </div>
    );
  }
  • Step 6: Remove the inline error paragraph in the main render

Replace:

    <div className="page profile-page">
      {error && <p className="error">{error}</p>}
      {saved && <p className="garmin-connection-message">Saved.</p>}

with:

    <div className="page profile-page">
      {saved && <p className="garmin-connection-message">Saved.</p>}
  • 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
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:

import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../api/client";
import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart";

with:

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:

  const [filterKindId, setFilterKindId] = useState<string>("");
  const [error, setError] = useState<string | null>(null);
  const [resolvingId, setResolvingId] = useState<number | null>(null);

with:

  const [filterKindId, setFilterKindId] = useState<string>("");
  const [resolvingId, setResolvingId] = useState<number | null>(null);
  • Step 3: Update loadMore's catch

Replace:

      } catch (e) {
        if (gen === generationRef.current) setError(String(e));
      } finally {
        setLoadingMore(false);

with:

      } catch (e) {
        if (gen === generationRef.current) showError(String(e));
      } finally {
        setLoadingMore(false);
  • Step 4: Update loadFirstPage's catch

Replace:

      .catch((e) => {
        if (gen === generationRef.current) setError(String(e));
      })
      .finally(() => {
        if (gen === generationRef.current) setInitialLoading(false);
      });

with:

      .catch((e) => {
        if (gen === generationRef.current) showError(String(e));
      })
      .finally(() => {
        if (gen === generationRef.current) setInitialLoading(false);
      });
  • Step 5: Update the mount effect

Replace:

  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:

  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):

    } catch (e) {
      setError(String(e));
    } finally {
      setResolvingId(null);
    }
  }

  async function unassign(activityId: number) {

with:

    } catch (e) {
      showError(String(e));
    } finally {
      setResolvingId(null);
    }
  }

  async function unassign(activityId: number) {

Replace (in unassign):

    } catch (e) {
      setError(String(e));
    } finally {
      setResolvingId(null);
    }
  }

  async function unlock(activityId: number) {

with:

    } catch (e) {
      showError(String(e));
    } finally {
      setResolvingId(null);
    }
  }

  async function unlock(activityId: number) {

Replace (in unlock):

    } catch (e) {
      setError(String(e));
    } finally {
      setResolvingId(null);
    }
  }

  function toggleFilter(id: string) {

with:

    } catch (e) {
      showError(String(e));
    } finally {
      setResolvingId(null);
    }
  }

  function toggleFilter(id: string) {
  • Step 7: Remove the inline error paragraph

Replace:

    <div className="page">
      {error && <p className="error">{error}</p>}

      {grandTotal > 0 && (

with:

    <div className="page">
      {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
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:

import { useEffect, useState } from "react";
import { api } from "../api/client";
import { ProgressionChart } from "../components/charts/ProgressionChart";

with:

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:

  const [points, setPoints] = useState<ProgressionPoint[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(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:

  const [points, setPoints] = useState<ProgressionPoint[]>([]);
  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:

          {error && <p className="error">{error}</p>}
          {loading ? <p>Loading...</p> : <ProgressionChart points={points} metric={metric} />}

with:

          {loading ? <p>Loading...</p> : <ProgressionChart points={points} metric={metric} />}
  • 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
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:

import { useEffect, useState } from "react";
import { api } from "../api/client";
import { NullableNumberField } from "./NullableNumberField";

with:

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:

  const [hrMax, setHrMax] = useState<number | null>(null);
  const [error, setError] = useState<string | null>(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:

  const [hrMax, setHrMax] = useState<number | null>(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:

      <legend>Training types</legend>

      {error && <p className="error">{error}</p>}

      <ul className="training-type-list">

with:

      <legend>Training types</legend>

      <ul className="training-type-list">
  • 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
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:

import { useEffect, useState } from "react";
import { api, BASE_URL } from "./api/client";
import "./LoginGate.css";
import App from "./App";

with:

import { useEffect, useState } from "react";
import { api, BASE_URL } from "./api/client";
import { showError } from "./banner";
import "./LoginGate.css";
import App from "./App";

Replace:

  useEffect(() => {
    api
      .getSessionInfo()
      .then((s) => {
        setSession(s);
        setStatus("authenticated");
      })
      .catch(() => setStatus("unauthenticated"));
  }, []);

  if (status === "loading") {
    return <div className="login-gate-loading">Loading</div>;
  }

  if (status === "unauthenticated") {
    const authError = new URLSearchParams(window.location.search).get("auth_error");
    return (
      <div className="login-gate">
        <h1>🧞‍♀️ geniusrun</h1>
        {authError && <p className="login-gate-error">{AUTH_ERROR_MESSAGES[authError] ?? "Login failed, please try again."}</p>}
        <a className="login-gate-button" href={`${BASE_URL}/api/session/login`}>
          Log in
        </a>
      </div>
    );
  }

with:

  useEffect(() => {
    api
      .getSessionInfo()
      .then((s) => {
        setSession(s);
        setStatus("authenticated");
      })
      .catch(() => setStatus("unauthenticated"));
  }, []);

  // Keycloak redirects back here with ?auth_error=<code> 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 <div className="login-gate-loading">Loading</div>;
  }

  if (status === "unauthenticated") {
    return (
      <div className="login-gate">
        <h1>🧞‍♀️ geniusrun</h1>
        <a className="login-gate-button" href={`${BASE_URL}/api/session/login`}>
          Log in
        </a>
      </div>
    );
  }
  • Step 2: Remove the now-dead CSS rule

In frontend/src/LoginGate.css, remove:

.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 <frontend-url>/?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
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:

import { useEffect, useRef, useState } from "react";
import { api } from "./api/client";
import "./OnboardingWizard.css";
import type { AuthResponse } from "./types/api";

with:

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:

  const [auth, setAuth] = useState<AuthResponse | null>(null);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(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:

  const [auth, setAuth] = useState<AuthResponse | null>(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<string | null>(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):

          {error && <p className="onboarding-wizard-error">{error}</p>}
        </form>
      </div>
    );
  }

with:

          {validationError && <p className="onboarding-wizard-error">{validationError}</p>}
        </form>
      </div>
    );
  }

Replace (the "authenticated, finishing setup" branch):

          <p className="onboarding-wizard-message">Connected to Garmin  finishing setup</p>
          {error && (
            <>
              <p className="onboarding-wizard-error">{error}</p>
              <button type="button" disabled={busy} onClick={complete}>
                {busy ? "Finishing…" : "Retry"}
              </button>
            </>
          )}

with:

          <p className="onboarding-wizard-message">Connected to Garmin  finishing setup</p>
          {completeFailed && (
            <button type="button" disabled={busy} onClick={complete}>
              {busy ? "Finishing…" : "Retry"}
            </button>
          )}

Replace (the "mfa_required" branch):

          {auth.message && <p className="onboarding-wizard-message">{auth.message}</p>}
          {error && <p className="onboarding-wizard-error">{error}</p>}
          <button type="button" disabled={busy} onClick={submitMFA}>
            Submit code
          </button>
        </div>
      ) : (

with:

          {auth.message && <p className="onboarding-wizard-message">{auth.message}</p>}
          <button type="button" disabled={busy} onClick={submitMFA}>
            Submit code
          </button>
        </div>
      ) : (

Replace (the Garmin-login form branch, the last remaining use of error):

          {auth?.message && <p className="onboarding-wizard-message">{auth.message}</p>}
          {error && <p className="onboarding-wizard-error">{error}</p>}
          <button type="submit" disabled={busy}>
            {busy ? "Connecting…" : "Login"}
          </button>
        </form>
      )}

with:

          {auth?.message && <p className="onboarding-wizard-message">{auth.message}</p>}
          {validationError && <p className="onboarding-wizard-error">{validationError}</p>}
          <button type="submit" disabled={busy}>
            {busy ? "Connecting…" : "Login"}
          </button>
        </form>
      )}
  • 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 <p className="onboarding-wizard-error"> sites, just no longer for real-failure messages.

  • Step 5: Commit
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:

.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

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).