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 <noreply@anthropic.com>
This commit is contained in:
54
frontend/src/banner.ts
Normal file
54
frontend/src/banner.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
51
frontend/src/components/BannerStack.css
Normal file
51
frontend/src/components/BannerStack.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
26
frontend/src/components/BannerStack.tsx
Normal file
26
frontend/src/components/BannerStack.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BannerStack } from './components/BannerStack.tsx'
|
||||||
import { LoginGate } from './LoginGate.tsx'
|
import { LoginGate } from './LoginGate.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<BannerStack />
|
||||||
<LoginGate />
|
<LoginGate />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user