diff --git a/apps/hq-web/src/main.tsx b/apps/hq-web/src/main.tsx
index db64a8a..4f51de0 100644
--- a/apps/hq-web/src/main.tsx
+++ b/apps/hq-web/src/main.tsx
@@ -4,7 +4,7 @@ import '@fontsource-variable/inter'
import '@fontsource-variable/jetbrains-mono'
import '../../../packages/ui/src/tokens.css'
import './app.css'
-import { initTheme } from '@sims/ui'
+import { initTheme, ToastProvider } from '@sims/ui'
import { Layout } from './Layout'
import { Login } from './Login'
import { Dashboard } from './pages/Dashboard'
@@ -42,4 +42,4 @@ function App() {
}
initTheme()
-createRoot(document.getElementById('root')!).render()
+createRoot(document.getElementById('root')!).render()
diff --git a/packages/ui/src/formgrid.tsx b/packages/ui/src/formgrid.tsx
new file mode 100644
index 0000000..b91712f
--- /dev/null
+++ b/packages/ui/src/formgrid.tsx
@@ -0,0 +1,15 @@
+import type { ReactNode } from 'react'
+import { Field } from './components'
+
+/** Two-column field grid for dialog bodies; single column under 560px. */
+export function FormGrid(props: { children: ReactNode }) {
+ return
{props.children}
+}
+
+export function FormField(props: { label: string; wide?: boolean; children: ReactNode }) {
+ return (
+
+ {props.children}
+
+ )
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 2c1e39c..333679a 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -9,3 +9,5 @@ export * from './tabs'
export * from './record'
export * from './palette-match'
export * from './palette'
+export * from './toast'
+export * from './formgrid'
diff --git a/packages/ui/src/toast-store.ts b/packages/ui/src/toast-store.ts
new file mode 100644
index 0000000..93289c4
--- /dev/null
+++ b/packages/ui/src/toast-store.ts
@@ -0,0 +1,17 @@
+/** Pure toast state — testable without a DOM. */
+
+export interface Toast { id: number; tone: 'ok' | 'err'; message: string }
+export interface ToastState { nextId: number; toasts: Toast[] }
+
+export const TOAST_LIMIT = 3
+export const initialToasts: ToastState = { nextId: 1, toasts: [] }
+
+export function pushToast(s: ToastState, tone: Toast['tone'], message: string): ToastState {
+ const toast: Toast = { id: s.nextId, tone, message }
+ const toasts = [...s.toasts, toast].slice(-TOAST_LIMIT)
+ return { nextId: s.nextId + 1, toasts }
+}
+
+export function dismissToast(s: ToastState, id: number): ToastState {
+ return { ...s, toasts: s.toasts.filter((t) => t.id !== id) }
+}
diff --git a/packages/ui/src/toast.tsx b/packages/ui/src/toast.tsx
new file mode 100644
index 0000000..98d0c5c
--- /dev/null
+++ b/packages/ui/src/toast.tsx
@@ -0,0 +1,45 @@
+import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react'
+import { dismissToast, initialToasts, pushToast, type Toast, type ToastState } from './toast-store'
+
+const AUTO_DISMISS_MS = 4000
+
+interface ToastApi { ok: (msg: string) => void; err: (msg: string) => void }
+const Ctx = createContext(null)
+
+/** Mount once at app root; renders the bottom-right stack. */
+export function ToastProvider(props: { children: ReactNode }) {
+ const [state, setState] = useState(initialToasts)
+ const stateRef = useRef(state)
+ stateRef.current = state
+
+ const push = useCallback((tone: Toast['tone'], message: string) => {
+ const id = stateRef.current.nextId
+ setState((s) => pushToast(s, tone, message))
+ window.setTimeout(() => setState((s) => dismissToast(s, id)), AUTO_DISMISS_MS)
+ }, [])
+
+ const api = useMemo(() => ({
+ ok: (msg) => push('ok', msg),
+ err: (msg) => push('err', msg),
+ }), [push])
+
+ return (
+
+ {props.children}
+
+ {state.toasts.map((t) => (
+
+ {t.message}
+
+
+ ))}
+
+
+ )
+}
+
+export function useToast(): ToastApi {
+ const api = useContext(Ctx)
+ if (api === null) throw new Error('useToast requires at the app root')
+ return api
+}
diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css
index 1597060..c6965df 100644
--- a/packages/ui/src/tokens.css
+++ b/packages/ui/src/tokens.css
@@ -376,3 +376,26 @@ table.wf tbody tr.tone-ok:hover td { filter: brightness(0.97); }
display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px 18px;
}
button.wf.pill { border-radius: 999px; padding: 8px 18px; }
+
+/* ================= toasts ================= */
+.wf-toasts {
+ position: fixed; right: 16px; bottom: 16px; z-index: 70;
+ display: flex; flex-direction: column; gap: 8px; max-width: 360px;
+}
+.wf-toast {
+ display: flex; align-items: center; gap: 10px;
+ background: var(--bg-raised); border: 1px solid var(--border);
+ border-radius: var(--radius); box-shadow: var(--shadow-lg);
+ padding: 10px 14px; font-size: 13px;
+}
+.wf-toast .msg { flex: 1; }
+.wf-toast.ok { border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
+.wf-toast.err { border-color: color-mix(in srgb, var(--err) 45%, transparent); background: var(--err-soft); }
+.wf-toast button {
+ border: none; background: none; color: var(--text-dim); cursor: pointer; font-size: 11px; padding: 2px;
+}
+
+/* ================= form grid (dialog bodies) ================= */
+.wf-formgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; }
+.wf-formgrid .wide { grid-column: 1 / -1; }
+@media (max-width: 560px) { .wf-formgrid { grid-template-columns: 1fr; } }
diff --git a/packages/ui/test/toast-store.test.ts b/packages/ui/test/toast-store.test.ts
new file mode 100644
index 0000000..fc2584b
--- /dev/null
+++ b/packages/ui/test/toast-store.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest'
+import { dismissToast, initialToasts, pushToast, TOAST_LIMIT } from '../src/toast-store'
+
+describe('toast store', () => {
+ it('pushes with increasing ids', () => {
+ let s = pushToast(initialToasts, 'ok', 'saved')
+ s = pushToast(s, 'err', 'failed')
+ expect(s.toasts.map((t) => t.id)).toEqual([1, 2])
+ expect(s.toasts[1]).toMatchObject({ tone: 'err', message: 'failed' })
+ })
+ it('keeps only the newest TOAST_LIMIT toasts', () => {
+ let s = initialToasts
+ for (let i = 0; i < TOAST_LIMIT + 2; i++) s = pushToast(s, 'ok', `m${i}`)
+ expect(s.toasts).toHaveLength(TOAST_LIMIT)
+ expect(s.toasts[0]!.message).toBe('m2')
+ })
+ it('dismisses by id and ignores unknown ids', () => {
+ let s = pushToast(initialToasts, 'ok', 'a')
+ s = pushToast(s, 'ok', 'b')
+ s = dismissToast(s, 1)
+ expect(s.toasts.map((t) => t.message)).toEqual(['b'])
+ expect(dismissToast(s, 99).toasts).toHaveLength(1)
+ })
+})