From aef58fd5295b0ad3d0fb894f4d7b294cc4c6e92e Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Mon, 27 Jul 2026 09:37:24 +0200 Subject: [PATCH] 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. Co-Authored-By: Claude Sonnet 5 --- frontend/src/banner.ts | 54 +++++++++++++++++++++++++ frontend/src/components/BannerStack.css | 51 +++++++++++++++++++++++ frontend/src/components/BannerStack.tsx | 26 ++++++++++++ frontend/src/main.tsx | 2 + 4 files changed, 133 insertions(+) create mode 100644 frontend/src/banner.ts create mode 100644 frontend/src/components/BannerStack.css create mode 100644 frontend/src/components/BannerStack.tsx diff --git a/frontend/src/banner.ts b/frontend/src/banner.ts new file mode 100644 index 0000000..e34e240 --- /dev/null +++ b/frontend/src/banner.ts @@ -0,0 +1,54 @@ +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; +} diff --git a/frontend/src/components/BannerStack.css b/frontend/src/components/BannerStack.css new file mode 100644 index 0000000..fd89f9f --- /dev/null +++ b/frontend/src/components/BannerStack.css @@ -0,0 +1,51 @@ +.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; +} diff --git a/frontend/src/components/BannerStack.tsx b/frontend/src/components/BannerStack.tsx new file mode 100644 index 0000000..fc51405 --- /dev/null +++ b/frontend/src/components/BannerStack.tsx @@ -0,0 +1,26 @@ +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} + +
+ ))} +
+ ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a7d052f..74cd678 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,9 +1,11 @@ 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( + , )