= {
+ info: "ℹ",
+ success: "✓",
+ error: "✕",
+};
+
+interface ToastItemProps {
+ toast: {
+ id: string;
+ message: string;
+ variant: ToastVariant;
+ };
+ onDismiss: (id: string) => void;
+}
+
+function ToastItem({ toast, onDismiss }: ToastItemProps) {
+ return (
+ onDismiss(toast.id)}
+ role={toast.variant === "error" ? "alert" : "status"}
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onDismiss(toast.id);
+ }
+ }}
+ >
+
+ {ICONS[toast.variant]}
+
+ {toast.message}
+
+ );
+}
+
+export function ToastContainer() {
+ const { toasts, dismiss } = useToast();
+
+ // Don't render anything during SSR
+ if (typeof document === "undefined") {
+ return null;
+ }
+
+ return createPortal(
+
+ {toasts.map((toast) => (
+
+ ))}
+
,
+ document.body
+ );
+}
diff --git a/src/ui/toast/ToastProvider.tsx b/src/ui/toast/ToastProvider.tsx
new file mode 100644
index 0000000..164d727
--- /dev/null
+++ b/src/ui/toast/ToastProvider.tsx
@@ -0,0 +1,119 @@
+/**
+ * Toast Provider
+ * React Context provider for toast system with deduplication and auto-dismiss
+ */
+
+import { createContext, useContext, useReducer, useCallback, useEffect, type ReactNode } from "react";
+import type { Toast, ToastContextValue, ToastVariant, ToastAction } from "./types";
+import { TOAST_CONFIG } from "./constants";
+
+interface ToastState {
+ toasts: Toast[];
+ lastToast: { message: string; timestamp: number } | null;
+}
+
+
+const ToastContext = createContext(null);
+
+export function toastReducer(state: ToastState, action: ToastAction): ToastState {
+ switch (action.type) {
+ case "ADD": {
+ // Prevent duplicate toasts within dedup window
+ if (state.lastToast && state.lastToast.message === action.payload.message) {
+ const timeSinceLast = Date.now() - state.lastToast.timestamp;
+ if (timeSinceLast < TOAST_CONFIG.dedupWindow) {
+ return state;
+ }
+ }
+
+ const newState = {
+ toasts: [...state.toasts, action.payload].slice(-TOAST_CONFIG.maxVisible),
+ lastToast: { message: action.payload.message, timestamp: action.payload.timestamp },
+ };
+ return newState;
+ }
+ case "DISMISS": {
+ return {
+ ...state,
+ toasts: state.toasts.filter((t) => t.id !== action.payload),
+ };
+ }
+ case "CLEAR": {
+ return { toasts: [], lastToast: null };
+ }
+ default:
+ return state;
+ }
+}
+
+export function ToastProvider({ children }: { children: ReactNode }) {
+ const [state, dispatch] = useReducer(toastReducer, { toasts: [], lastToast: null });
+
+ const toast = useCallback((message: string, variant: ToastVariant = "info", duration?: number): string | null => {
+ // Check for deduplication before creating a new toast
+ if (state.lastToast && state.lastToast.message === message) {
+ const timeSinceLast = Date.now() - state.lastToast.timestamp;
+ if (timeSinceLast < TOAST_CONFIG.dedupWindow) {
+ // Find and return the existing toast's id
+ const existingToast = state.toasts.find(t => t.message === message);
+ return existingToast?.id ?? null;
+ }
+ }
+
+ const id = crypto.randomUUID();
+ const newToast: Toast = {
+ id,
+ message,
+ variant,
+ duration: duration ?? TOAST_CONFIG.defaultDuration,
+ timestamp: Date.now(),
+ };
+ dispatch({ type: "ADD", payload: newToast });
+ return id;
+ }, [state.toasts, state.lastToast]);
+
+ const dismiss = useCallback((id: string) => {
+ dispatch({ type: "DISMISS", payload: id });
+ }, []);
+
+ const clear = useCallback(() => {
+ dispatch({ type: "CLEAR" });
+ }, []);
+
+ // Auto-dismiss non-persistent toasts
+ useEffect(() => {
+ const timers: ReturnType[] = [];
+
+ state.toasts.forEach((t) => {
+ if (t.duration > 0) {
+ const elapsed = Date.now() - t.timestamp;
+ const remaining = Math.max(t.duration - elapsed, 0);
+ const timer = setTimeout(() => {
+ dismiss(t.id);
+ }, remaining);
+ timers.push(timer);
+ }
+ });
+
+ return () => {
+ timers.forEach(clearTimeout);
+ };
+ }, [state.toasts, dismiss]);
+
+ const contextValue: ToastContextValue = {
+ toasts: state.toasts,
+ toast,
+ dismiss,
+ clear,
+ };
+
+ return {children};
+}
+
+export function useToast(): ToastContextValue {
+ const context = useContext(ToastContext);
+ if (!context) {
+ throw new Error("useToast must be used within a ToastProvider");
+ }
+ return context;
+}
diff --git a/src/ui/toast/constants.ts b/src/ui/toast/constants.ts
new file mode 100644
index 0000000..d484a11
--- /dev/null
+++ b/src/ui/toast/constants.ts
@@ -0,0 +1,18 @@
+/**
+ * Toast System Constants
+ * Configuration for toast behavior
+ */
+
+import type { ToastConfig } from "./types";
+
+export const TOAST_CONFIG: ToastConfig = {
+ defaultDuration: 5000, // 5 seconds
+ dedupWindow: 2000, // 2 seconds - prevent rapid duplicate toasts
+ maxVisible: 3, // Maximum visible toasts at once
+};
+
+export const TOAST_VARIANTS = {
+ info: "info",
+ success: "success",
+ error: "error",
+} as const;
diff --git a/src/ui/toast/index.ts b/src/ui/toast/index.ts
new file mode 100644
index 0000000..b270a52
--- /dev/null
+++ b/src/ui/toast/index.ts
@@ -0,0 +1,8 @@
+/**
+ * Toast System
+ * Minimal toast system for error/success handling
+ */
+
+export { ToastProvider, useToast } from "./ToastProvider";
+export { ToastContainer } from "./ToastContainer";
+export * from "./types";
diff --git a/src/ui/toast/types.ts b/src/ui/toast/types.ts
new file mode 100644
index 0000000..52ca609
--- /dev/null
+++ b/src/ui/toast/types.ts
@@ -0,0 +1,32 @@
+/**
+ * Toast System Types
+ * Minimal toast types for error/success handling
+ */
+
+export type ToastVariant = "info" | "success" | "error";
+
+export interface Toast {
+ id: string;
+ message: string;
+ variant: ToastVariant;
+ duration: number; // 0 = persistent
+ timestamp: number;
+}
+
+export interface ToastConfig {
+ defaultDuration: number; // 5000ms
+ dedupWindow: number; // 2000ms
+ maxVisible: number; // 3 toasts
+}
+
+export interface ToastContextValue {
+ toasts: ReadonlyArray;
+ toast: (message: string, variant?: ToastVariant, duration?: number) => string | null;
+ dismiss: (id: string) => void;
+ clear: () => void;
+}
+
+export type ToastAction =
+ | { type: "ADD"; payload: Toast }
+ | { type: "DISMISS"; payload: string }
+ | { type: "CLEAR" };
diff --git a/tests/toast.test.ts b/tests/toast.test.ts
new file mode 100644
index 0000000..d19e415
--- /dev/null
+++ b/tests/toast.test.ts
@@ -0,0 +1,222 @@
+/**
+ * Toast System Unit Tests
+ * Tests for toast logic and reducer behavior
+ */
+
+import { describe, it, expect } from "bun:test";
+import { toastReducer } from "../src/ui/toast/ToastProvider";
+import { TOAST_CONFIG } from "../src/ui/toast/constants";
+import type { Toast, ToastVariant } from "../src/ui/toast/types";
+
+// Test state interface matching the reducer
+interface TestToastState {
+ toasts: Toast[];
+ lastToast: { message: string; timestamp: number } | null;
+}
+
+describe("toast reducer logic", () => {
+ it("should add a new toast", () => {
+ const initialState: TestToastState = { toasts: [], lastToast: null };
+ const action = {
+ type: "ADD" as const,
+ payload: {
+ id: "test-1",
+ message: "Test message",
+ variant: "success" as ToastVariant,
+ duration: 5000,
+ timestamp: Date.now(),
+ },
+ };
+
+ const newState = toastReducer(initialState, action);
+
+ expect(newState.toasts).toHaveLength(1);
+ expect(newState.toasts[0].message).toBe("Test message");
+ expect(newState.lastToast?.message).toBe("Test message");
+ });
+
+ it("should filter toasts by id on dismiss", () => {
+ const state: TestToastState = {
+ toasts: [
+ { id: "1", message: "Toast 1", variant: "info" as ToastVariant, duration: 5000, timestamp: Date.now() },
+ { id: "2", message: "Toast 2", variant: "success" as ToastVariant, duration: 5000, timestamp: Date.now() },
+ ],
+ lastToast: null,
+ };
+ const action = { type: "DISMISS" as const, payload: "1" };
+
+ const newState = toastReducer(state, action);
+
+ expect(newState.toasts).toHaveLength(1);
+ expect(newState.toasts[0].id).toBe("2");
+ });
+
+ it("should clear all toasts", () => {
+ const state: TestToastState = {
+ toasts: [{ id: "1", message: "Toast", variant: "info" as ToastVariant, duration: 5000, timestamp: Date.now() }],
+ lastToast: { message: "Toast", timestamp: Date.now() },
+ };
+ const action = { type: "CLEAR" as const };
+
+ const newState = toastReducer(state, action);
+
+ expect(newState.toasts).toHaveLength(0);
+ expect(newState.lastToast).toBeNull();
+ });
+
+ it("should limit toasts to max visible", () => {
+ const initialState: TestToastState = { toasts: [], lastToast: null };
+ const now = Date.now();
+
+ let state = initialState;
+ for (let i = 1; i <= 5; i++) {
+ const action = {
+ type: "ADD" as const,
+ payload: { id: String(i), message: `Toast ${i}`, variant: "info" as ToastVariant, duration: 5000, timestamp: now + i },
+ };
+ state = toastReducer(state, action);
+ }
+
+ expect(state.toasts).toHaveLength(TOAST_CONFIG.maxVisible);
+ expect(state.toasts[0]?.id).toBe("3");
+ });
+
+ it("should deduplicate within window when message matches", () => {
+ const now = Date.now();
+ const state: TestToastState = {
+ toasts: [{ id: "1", message: "Same message", variant: "info" as ToastVariant, duration: 5000, timestamp: now - 1000 }],
+ lastToast: { message: "Same message", timestamp: now - 1000 },
+ };
+ const action = {
+ type: "ADD" as const,
+ payload: { id: "2", message: "Same message", variant: "info" as ToastVariant, duration: 5000, timestamp: now },
+ };
+
+ const newState = toastReducer(state, action);
+
+ // Should not add duplicate
+ expect(newState.toasts).toHaveLength(1);
+ expect(newState.toasts[0]?.id).toBe("1");
+ });
+
+ it("should allow different messages within window", () => {
+ const now = Date.now();
+ const state: TestToastState = {
+ toasts: [{ id: "1", message: "Message 1", variant: "info" as ToastVariant, duration: 5000, timestamp: now - 1000 }],
+ lastToast: { message: "Message 1", timestamp: now - 1000 },
+ };
+ const action = {
+ type: "ADD" as const,
+ payload: { id: "2", message: "Message 2", variant: "info" as ToastVariant, duration: 5000, timestamp: now },
+ };
+
+ const newState = toastReducer(state, action);
+
+ // Should add different message
+ expect(newState.toasts).toHaveLength(2);
+ expect(newState.toasts[1]?.message).toBe("Message 2");
+ });
+
+ it("should allow same message outside dedup window", () => {
+ const now = Date.now();
+ const state: TestToastState = {
+ toasts: [{ id: "1", message: "Same message", variant: "info" as ToastVariant, duration: 5000, timestamp: now - 3000 }],
+ lastToast: { message: "Same message", timestamp: now - 3000 },
+ };
+ const action = {
+ type: "ADD" as const,
+ payload: { id: "2", message: "Same message", variant: "info" as ToastVariant, duration: 5000, timestamp: now },
+ };
+
+ const newState = toastReducer(state, action);
+
+ // Should add since outside dedup window
+ expect(newState.toasts).toHaveLength(2);
+ });
+});
+
+describe("toast configuration", () => {
+ it("should have correct default duration", () => {
+ expect(TOAST_CONFIG.defaultDuration).toBe(5000);
+ });
+
+ it("should have correct dedup window", () => {
+ expect(TOAST_CONFIG.dedupWindow).toBe(2000);
+ });
+
+ it("should have correct max visible", () => {
+ expect(TOAST_CONFIG.maxVisible).toBe(3);
+ });
+});
+
+describe("toast variants", () => {
+ it("should accept info variant", () => {
+ const toast: Toast = {
+ id: "1",
+ message: "Test",
+ variant: "info",
+ duration: 5000,
+ timestamp: Date.now(),
+ };
+ expect(toast.variant).toBe("info");
+ });
+
+ it("should accept success variant", () => {
+ const toast: Toast = {
+ id: "1",
+ message: "Test",
+ variant: "success",
+ duration: 5000,
+ timestamp: Date.now(),
+ };
+ expect(toast.variant).toBe("success");
+ });
+
+ it("should accept error variant", () => {
+ const toast: Toast = {
+ id: "1",
+ message: "Test",
+ variant: "error",
+ duration: 5000,
+ timestamp: Date.now(),
+ };
+ expect(toast.variant).toBe("error");
+ });
+});
+
+describe("toast id generation", () => {
+ it("should generate unique ids", () => {
+ const ids = new Set();
+ for (let i = 0; i < 100; i++) {
+ ids.add(crypto.randomUUID());
+ }
+ expect(ids.size).toBe(100);
+ });
+
+ it("should generate string ids", () => {
+ const id = crypto.randomUUID();
+ expect(typeof id).toBe("string");
+ expect(id.length).toBe(36); // UUID format
+ });
+});
+
+describe("toast message formatting", () => {
+ it("should handle base error messages", () => {
+ const formatError = (error: { shortMessage?: string; message?: string }) => {
+ return error.shortMessage || error.message || "An unknown error occurred";
+ };
+
+ expect(formatError({ shortMessage: "User rejected request" })).toBe("User rejected request");
+ expect(formatError({ message: "Connection timeout" })).toBe("Connection timeout");
+ expect(formatError({})).toBe("An unknown error occurred");
+ });
+
+ it("should handle generic errors", () => {
+ const formatError = (error: Error | unknown) => {
+ return error instanceof Error ? error.message : "An unknown error occurred";
+ };
+
+ expect(formatError(new Error("Custom error"))).toBe("Custom error");
+ expect(formatError(null)).toBe("An unknown error occurred");
+ });
+});