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