fix(catalyst-ui): remove status banners from Apps page; surface as global notifications (closes #475) (#487)

Founder #475 — the "Provisioning failed" / "Cancel & Wipe" / "Per-component
install monitoring is unavailable" banners pollute the Apps page. They render
above the apps grid, forcing operators onto the Apps tab to read terminal
deployment status, and crowd out the actual catalog.

Replaces the inline banners with a global toast surface:

  • new shared/ui/notifications.tsx — NotificationProvider + useNotifications()
    seam. Bottom-right stacked tray, fixed positioning so it's visible on
    every tab (Apps / Jobs / Dashboard / Cloud / Users). Toasts replace
    in-place by id so a deployment-failure update edits the existing card
    rather than stacking duplicates.
  • RootLayout — mounts NotificationProvider once at the top of the tree.
  • AppsPage — strips FailureCard + Phase1UnavailableBanner. Two new
    useEffects mirror the same copy + the same retry / wipe / back-to-wizard
    actions through notify(). WipeDeploymentModal stays page-scoped so the
    toast action can flip it open.
  • useDeploymentEvents — wraps `retry` in useCallback so the AppsPage
    notification effect doesn't re-fire every render (would otherwise loop
    notify → re-render → notify).

Vitest:
  • 8 cases on the notification surface (push, replace-by-id, dismiss,
    role=alert vs role=status, action dismissOnClick semantics, provider
    guard).
  • 2 new cases on AppsPage that gate any future regression: main element
    has zero role="alert" / role="status" children on first paint, and the
    legacy banner test ids never render.

Acceptance vs founder ask:
  • Apps page in failed state renders ONLY apps grid + tabs + search box.
  • Same status content fires as a bottom-right toast with Retry stream /
    Cancel & Wipe / Back to wizard actions.
  • Notifications stay visible across Apps / Jobs / Dashboard / Cloud /
    Users tabs because the tray is mounted in RootLayout above Outlet.

Co-authored-by: hatiyildiz <hatiyildiz@noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
e3mrah 2026-05-01 23:23:12 +04:00 committed by GitHub
parent 62e03ae129
commit e6663f169d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 621 additions and 121 deletions

View File

@ -1,10 +1,13 @@
import { Outlet } from '@tanstack/react-router'
import { TooltipProvider } from '@/shared/ui/tooltip'
import { NotificationProvider } from '@/shared/ui/notifications'
export function RootLayout() {
return (
<TooltipProvider>
<Outlet />
<NotificationProvider>
<Outlet />
</NotificationProvider>
</TooltipProvider>
)
}

View File

@ -24,9 +24,16 @@ import {
import { AppsPage } from './AppsPage'
import { useWizardStore } from '@/entities/deployment/store'
import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model'
import { NotificationProvider } from '@/shared/ui/notifications'
function renderProvision(deploymentId: string) {
const rootRoute = createRootRoute({ component: () => <Outlet /> })
const rootRoute = createRootRoute({
component: () => (
<NotificationProvider>
<Outlet />
</NotificationProvider>
),
})
const provisionRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId',
@ -116,6 +123,29 @@ describe('AppsPage — tabs', () => {
})
})
describe('AppsPage — banner pollution gate (founder #475)', () => {
it('renders no role="alert" or role="status" inside main on first paint', async () => {
renderProvision('d-1')
// Wait for the page to settle.
await screen.findByText('Applications')
// The Apps page main surface must be free of inline banners. Toasts
// (which would render inside the global tray, not main) are allowed.
const main = document.querySelector('main')
expect(main).toBeTruthy()
expect(main!.querySelector('[role="alert"]')).toBeNull()
expect(main!.querySelector('[role="status"]')).toBeNull()
})
it('does not import or render the legacy FailureCard test ids', async () => {
renderProvision('d-1')
await screen.findByText('Applications')
// These test ids were the inline banner anchors. They must not paint
// anywhere on the Apps surface; the failure UX moves to global toasts.
expect(screen.queryByTestId('sov-failure-card')).toBeNull()
expect(screen.queryByTestId('sov-phase1-unavailable-banner')).toBeNull()
})
})
describe('AppsPage — card grid', () => {
it('renders one .app-card per Application from first paint', async () => {
renderProvision('d-1')

View File

@ -30,7 +30,7 @@
* is no hand-maintained id list in this file.
*/
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useParams, useRouter, Link } from '@tanstack/react-router'
import { useWizardStore } from '@/entities/deployment/store'
import { PortalShell } from './PortalShell'
@ -38,6 +38,7 @@ import { resolveApplications, type ApplicationDescriptor } from './applicationCa
import { useDeploymentEvents } from './useDeploymentEvents'
import type { ApplicationStatus } from './eventReducer'
import { WipeDeploymentModal } from '@/components/CrudModals/WipeDeploymentModal'
import { useNotifications } from '@/shared/ui/notifications'
interface AppsPageProps {
/** Test seam — disables the live SSE EventSource attach. */
@ -70,6 +71,88 @@ export function AppsPage({ disableStream = false }: AppsPageProps = {}) {
const failureMessage = streamError ?? snapshot?.error ?? null
const sovereignFQDN = snapshot?.sovereignFQDN ?? snapshot?.result?.sovereignFQDN ?? null
// Wipe modal is owned by AppsPage so the "Cancel & Wipe" toast action
// can flip it open. The toast itself is mounted by the global tray; the
// modal stays here because it owns the destructive POST + spinner.
const [showWipeModal, setShowWipeModal] = useState(false)
const onWipeComplete = () => {
setShowWipeModal(false)
router.navigate({ to: '/wizard' })
}
// Surface terminal-failure + phase-1-skipped state as global toasts
// (founder #475 — Apps page must render only the apps grid). Same
// copy + same actions as the legacy banner, just delivered via the
// `useNotifications` seam so they stay visible across tabs and don't
// fight the apps grid for vertical space.
const { notify, dismiss } = useNotifications()
useEffect(() => {
const id = `deployment-failure:${deploymentId}`
if (!isFailed) {
dismiss(id)
return
}
const isUnreachable = streamStatus === 'unreachable'
notify({
id,
level: 'error',
title: isUnreachable
? 'Couldnt reach the deployment stream'
: 'Provisioning failed',
body: isUnreachable
? `The catalyst-api is unreachable, or deployment ${deploymentId} is unknown to the backend.`
: `The catalyst-api emitted a terminal failure for deployment ${deploymentId}.`,
raw: failureMessage ?? undefined,
actions: [
{
label: 'Retry stream',
variant: 'primary',
testId: 'sov-failure-retry',
onClick: retry,
dismissOnClick: false,
},
{
label: 'Cancel & Wipe',
variant: 'danger',
testId: 'sov-failure-wipe',
onClick: () => setShowWipeModal(true),
dismissOnClick: false,
},
{
label: 'Back to wizard',
variant: 'ghost',
testId: 'sov-failure-back',
onClick: () => router.navigate({ to: '/wizard' }),
},
],
})
// The dismiss-on-recovery branch above handles cleanup; no return
// closure needed because notification ids are stable per deployment.
}, [isFailed, streamStatus, failureMessage, deploymentId, notify, dismiss, retry, router])
useEffect(() => {
const id = `phase1-unavailable:${deploymentId}`
if (!state.phase1WatchSkipped) {
dismiss(id)
return
}
const target = sovereignFQDN ?? 'the new Sovereign cluster'
notify({
id,
level: 'warn',
title: 'Per-component install monitoring is unavailable for this deployment',
body: `The Catalyst API couldnt fetch the new clusters kubeconfig. Use kubectl directly to check Helm releases on ${target}.`,
raw: state.phase1WatchSkippedReason ?? undefined,
})
}, [
state.phase1WatchSkipped,
state.phase1WatchSkippedReason,
sovereignFQDN,
deploymentId,
notify,
dismiss,
])
// Catalog = every Application this deployment knows about (canonical
// calls this "every app in the org's catalog"; for the wizard surface
// it's the union of bootstrap-kit + transitive deps + selected). Same
@ -156,22 +239,23 @@ export function AppsPage({ disableStream = false }: AppsPageProps = {}) {
>
<style>{APPS_PAGE_CSS}</style>
{isFailed ? (
<FailureCard
{/*
* Failure + phase-1-unavailable banners used to render here above
* the apps grid. Per founder #475 they now fire as global toasts
* via the NotificationProvider mounted in RootLayout, leaving the
* Apps page to render only the grid + tabs + search box.
*
* The Cancel-&-Wipe action on the failure toast still needs a
* page-scoped modal mount, so WipeDeploymentModal lives below.
*/}
{showWipeModal ? (
<WipeDeploymentModal
open={showWipeModal}
deploymentId={deploymentId}
sovereignFQDN={sovereignFQDN}
status={streamStatus as 'failed' | 'unreachable'}
message={failureMessage}
onRetry={retry}
onBack={() => router.navigate({ to: '/wizard' })}
onWiped={() => router.navigate({ to: '/wizard' })}
/>
) : null}
{state.phase1WatchSkipped ? (
<Phase1UnavailableBanner
fqdn={sovereignFQDN}
reason={state.phase1WatchSkippedReason}
onClose={() => setShowWipeModal(false)}
onWiped={onWipeComplete}
/>
) : null}
@ -324,108 +408,6 @@ function AppCard({ app, status, deploymentId, isService }: AppCardProps) {
)
}
interface FailureCardProps {
deploymentId: string
sovereignFQDN: string | null
status: 'failed' | 'unreachable'
message: string | null
onRetry: () => void
onBack: () => void
onWiped: () => void
}
function FailureCard({ deploymentId, sovereignFQDN, status, message, onRetry, onBack, onWiped }: FailureCardProps) {
const isUnreachable = status === 'unreachable'
const [showWipeModal, setShowWipeModal] = useState(false)
return (
<div
role="alert"
data-testid="sov-failure-card"
className="my-3 rounded-xl border border-[var(--color-danger)]/40 bg-[var(--color-danger)]/10 p-4 text-sm text-[var(--color-text)]"
>
<h3 className="m-0 mb-1 text-base font-semibold text-[var(--color-danger)]">
{isUnreachable ? 'Couldnt reach the deployment stream' : 'Provisioning failed'}
</h3>
<p className="m-0 mb-2 text-[var(--color-text-dim)]">
{isUnreachable
? `The catalyst-api is unreachable, or deployment ${deploymentId} is unknown to the backend.`
: `The catalyst-api emitted a terminal failure for deployment ${deploymentId}.`}
</p>
{message ? (
<pre data-testid="sov-failure-error" className="my-2 overflow-x-auto rounded bg-[var(--color-bg)] p-2 text-[11px] text-[var(--color-text-dim)]">
{message}
</pre>
) : null}
<div className="mt-2 flex gap-2 flex-wrap">
<button
type="button"
onClick={onRetry}
data-testid="sov-failure-retry"
className="rounded-md border border-[var(--color-accent)] bg-[var(--color-accent)] px-3 py-1 text-xs font-semibold text-white hover:bg-[var(--color-accent-hover)]"
>
Retry stream
</button>
<button
type="button"
onClick={() => setShowWipeModal(true)}
data-testid="sov-failure-wipe"
className="rounded-md border border-[var(--color-danger)] bg-[var(--color-danger)] px-3 py-1 text-xs font-semibold text-white hover:opacity-90"
>
Cancel &amp; Wipe
</button>
<button
type="button"
onClick={onBack}
data-testid="sov-failure-back"
className="rounded-md border border-[var(--color-border)] bg-transparent px-3 py-1 text-xs text-[var(--color-text-dim)] hover:text-[var(--color-text)]"
>
Back to wizard
</button>
</div>
{showWipeModal ? (
<WipeDeploymentModal
open={showWipeModal}
deploymentId={deploymentId}
sovereignFQDN={sovereignFQDN}
onClose={() => setShowWipeModal(false)}
onWiped={() => { setShowWipeModal(false); onWiped() }}
/>
) : null}
</div>
)
}
interface Phase1UnavailableBannerProps {
fqdn: string | null
reason: string | null
}
function Phase1UnavailableBanner({ fqdn, reason }: Phase1UnavailableBannerProps) {
const target = fqdn ?? 'the new Sovereign cluster'
return (
<div
role="status"
data-testid="sov-phase1-unavailable-banner"
className="my-3 rounded-lg border border-[var(--color-warn)]/35 bg-[var(--color-warn)]/10 p-3 text-sm text-[var(--color-text)]"
>
<strong className="text-[var(--color-warn)] font-bold">
Per-component install monitoring is unavailable for this deployment
</strong>{' '}
<span className="text-xs text-[var(--color-text-dim)]">
the Catalyst API couldnt fetch the new clusters kubeconfig. Use kubectl directly to check Helm releases on {target}.
</span>
{reason ? (
<pre
data-testid="sov-phase1-unavailable-reason"
className="mt-2 whitespace-pre-wrap break-words rounded bg-[var(--color-bg)] p-2 font-mono text-[11px] text-[var(--color-text-dim)]"
>
{reason}
</pre>
) : null}
</div>
)
}
/**
* Pixel-ported `<style>` block from the canonical AppsPage.svelte.
* Same selector tree, same values the only Tailwind-vs-CSS diff is

View File

@ -37,7 +37,7 @@
* does less than the other.
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { API_BASE } from '@/shared/config/urls'
import {
buildInitialState,
@ -257,6 +257,13 @@ export function useDeploymentEvents(
}
}, [deploymentId, retryNonce, disableStream])
// Stable callback — referential identity matters because callers
// (e.g. AppsPage's notification effect) include `retry` in their
// useEffect dependency arrays. A new function every render would
// re-fire the effect on every state change and cause the toast push
// → re-render → toast push loop that triggered the test hang.
const retry = useCallback(() => setRetryNonce((n) => n + 1), [])
return {
state,
snapshot,
@ -264,6 +271,6 @@ export function useDeploymentEvents(
streamError,
startedAt,
finishedAt,
retry: () => setRetryNonce((n) => n + 1),
retry,
}
}

View File

@ -0,0 +1,227 @@
/**
* notifications.test.tsx global toast surface (founder #475).
*
* Provider mounts with no toasts initially tray DOM is absent
* notify() pushes a toast renders title + body + raw + actions
* notify() with the same id REPLACES the existing toast in-place
* dismiss(id) removes the toast
* action onClick fires + auto-dismisses unless dismissOnClick=false
* level=error renders role="alert", everything else role="status"
*/
import { describe, it, expect, afterEach, vi } from 'vitest'
import {
render,
screen,
cleanup,
fireEvent,
act,
} from '@testing-library/react'
import {
NotificationProvider,
useNotifications,
type NotificationLevel,
} from './notifications'
afterEach(() => cleanup())
interface PushOpts {
level?: NotificationLevel
title?: string
body?: string
raw?: string
id?: string
onRetry?: () => void
onWipe?: () => void
}
/**
* Test harness exposes a button that triggers `notify()` with the
* supplied opts. Re-rendering with new opts gives the test direct
* control over the next call's payload.
*/
function PushButton({ opts }: { opts: PushOpts }) {
const { notify } = useNotifications()
return (
<button
type="button"
data-testid="harness-push"
onClick={() =>
notify({
id: opts.id,
level: opts.level ?? 'error',
title: opts.title ?? 'Provisioning failed',
body: opts.body,
raw: opts.raw,
actions:
opts.onRetry || opts.onWipe
? [
...(opts.onRetry
? [
{
label: 'Retry stream',
variant: 'primary' as const,
testId: 'sov-failure-retry',
onClick: opts.onRetry,
dismissOnClick: false,
},
]
: []),
...(opts.onWipe
? [
{
label: 'Cancel & Wipe',
variant: 'danger' as const,
testId: 'sov-failure-wipe',
onClick: opts.onWipe,
},
]
: []),
]
: undefined,
})
}
>
push
</button>
)
}
function renderHarness(opts: PushOpts = {}) {
return render(
<NotificationProvider>
<PushButton opts={opts} />
</NotificationProvider>,
)
}
describe('NotificationProvider — empty state', () => {
it('does not render the tray when there are no notifications', () => {
renderHarness()
expect(screen.queryByTestId('notification-tray')).toBeNull()
})
})
describe('NotificationProvider — push', () => {
it('renders a toast with title, body, raw block, and action buttons', () => {
renderHarness({
id: 'deployment-failure:d-1',
level: 'error',
title: 'Provisioning failed',
body: 'The catalyst-api emitted a terminal failure for deployment d-1.',
raw: 'tofu apply: rg busy',
onRetry: () => undefined,
onWipe: () => undefined,
})
fireEvent.click(screen.getByTestId('harness-push'))
expect(screen.getByTestId('notification-tray')).toBeTruthy()
expect(screen.getByText('Provisioning failed')).toBeTruthy()
expect(screen.getByText(/terminal failure for deployment d-1/)).toBeTruthy()
expect(
screen.getByTestId('notification-deployment-failure:d-1-raw').textContent,
).toContain('tofu apply: rg busy')
expect(screen.getByTestId('sov-failure-retry')).toBeTruthy()
expect(screen.getByTestId('sov-failure-wipe')).toBeTruthy()
})
it('uses role="alert" for level=error and role="status" for warn/info', () => {
const { rerender } = render(
<NotificationProvider>
<PushButton opts={{ id: 't1', level: 'error', title: 'boom' }} />
</NotificationProvider>,
)
fireEvent.click(screen.getByTestId('harness-push'))
expect(screen.getByTestId('notification-t1').getAttribute('role')).toBe('alert')
rerender(
<NotificationProvider>
<PushButton opts={{ id: 't2', level: 'warn', title: 'careful' }} />
</NotificationProvider>,
)
fireEvent.click(screen.getByTestId('harness-push'))
expect(screen.getByTestId('notification-t2').getAttribute('role')).toBe('status')
})
})
describe('NotificationProvider — id-based replace', () => {
it('replaces an existing toast when notify() is called with the same id', () => {
function Harness() {
const { notify } = useNotifications()
return (
<>
<button
data-testid="push-a"
onClick={() =>
notify({ id: 'same', level: 'error', title: 'first' })
}
>
a
</button>
<button
data-testid="push-b"
onClick={() =>
notify({ id: 'same', level: 'error', title: 'second' })
}
>
b
</button>
</>
)
}
render(
<NotificationProvider>
<Harness />
</NotificationProvider>,
)
fireEvent.click(screen.getByTestId('push-a'))
fireEvent.click(screen.getByTestId('push-b'))
expect(screen.getAllByTestId('notification-same').length).toBe(1)
expect(screen.getByText('second')).toBeTruthy()
expect(screen.queryByText('first')).toBeNull()
})
})
describe('NotificationProvider — dismiss', () => {
it('removes the toast when the close button is clicked', () => {
renderHarness({ id: 'x', level: 'info', title: 'hi' })
fireEvent.click(screen.getByTestId('harness-push'))
expect(screen.queryByTestId('notification-x')).toBeTruthy()
fireEvent.click(screen.getByTestId('notification-x-dismiss'))
expect(screen.queryByTestId('notification-x')).toBeNull()
})
it('action with dismissOnClick !== false auto-dismisses after firing', () => {
const onWipe = vi.fn()
renderHarness({ id: 'y', level: 'error', title: 'failed', onWipe })
fireEvent.click(screen.getByTestId('harness-push'))
fireEvent.click(screen.getByTestId('sov-failure-wipe'))
expect(onWipe).toHaveBeenCalledOnce()
expect(screen.queryByTestId('notification-y')).toBeNull()
})
it('action with dismissOnClick=false fires onClick but keeps the toast', () => {
const onRetry = vi.fn()
renderHarness({ id: 'z', level: 'error', title: 'failed', onRetry })
fireEvent.click(screen.getByTestId('harness-push'))
fireEvent.click(screen.getByTestId('sov-failure-retry'))
expect(onRetry).toHaveBeenCalledOnce()
expect(screen.queryByTestId('notification-z')).toBeTruthy()
})
})
describe('NotificationProvider — context guard', () => {
it('throws when useNotifications is called outside the provider', () => {
function Naked() {
useNotifications()
return null
}
// React logs the boundary error; suppress for cleaner test output.
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
expect(() =>
act(() => {
render(<Naked />)
}),
).toThrow(/NotificationProvider/)
spy.mockRestore()
})
})

View File

@ -0,0 +1,251 @@
/**
* notifications.tsx global, app-wide toast surface.
*
* Founder mandate (#475, 2026-05-01): page-level status banners (e.g. the
* "Provisioning failed" banner that used to render above the apps grid)
* pollute the surface they're attached to. The Apps page must show ONLY
* the apps grid; deployment status must surface via a global notification
* affordance bottom-right toasts stack here.
*
* The provider is mounted once in `RootLayout` so toasts are visible
* across every tab (Apps / Jobs / Dashboard / Cloud / Users) and survive
* client-side navigation.
*
* Public API:
* <NotificationProvider> wraps the app, renders the toast tray
* useNotifications() returns { notify, dismiss, items }
* notify({ id?, level, title, body?, actions? })
*
* If `id` is supplied, calls with the same id REPLACE the existing toast
* (so a deployment-failure update doesn't stack it edits in-place).
*
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), every visual
* value flows through CSS variables (`--color-danger`, `--color-warn`,
* `--color-accent`, ). No inlined hex.
*/
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from 'react'
export type NotificationLevel = 'info' | 'warn' | 'error' | 'success'
export interface NotificationAction {
/** Visible label, e.g. "Retry stream" or "Cancel & Wipe". */
label: string
/** Click handler — triggered before the toast is dismissed. */
onClick: () => void
/** Visual emphasis. `primary` matches the canonical accent button. */
variant?: 'primary' | 'danger' | 'ghost'
/** When true (default) the toast is dismissed after `onClick` fires. */
dismissOnClick?: boolean
/** data-testid to expose for E2E. */
testId?: string
}
export interface Notification {
/** Stable id — pass to replace an existing toast in-place. Auto-assigned if omitted. */
id: string
level: NotificationLevel
title: string
/** Optional secondary body (string or pre-formatted error text). */
body?: string
/** Action buttons rendered at the bottom of the toast. */
actions?: NotificationAction[]
/**
* Render a `<pre>` raw block (e.g. server error trace). Set this
* separately from `body` so the layout can use a monospaced rail
* without pre-wrapping arbitrary copy.
*/
raw?: string
}
interface NotificationsContextValue {
items: readonly Notification[]
/** Push a new toast OR replace an existing one with the same id. */
notify: (n: Omit<Notification, 'id'> & { id?: string }) => string
/** Dismiss by id. No-op if the id is unknown. */
dismiss: (id: string) => void
}
const NotificationsContext = createContext<NotificationsContextValue | null>(null)
export function useNotifications(): NotificationsContextValue {
const ctx = useContext(NotificationsContext)
if (!ctx) {
throw new Error(
'useNotifications() must be called inside a <NotificationProvider>',
)
}
return ctx
}
interface NotificationProviderProps {
children: ReactNode
}
let nextAutoId = 0
export function NotificationProvider({ children }: NotificationProviderProps) {
const [items, setItems] = useState<readonly Notification[]>([])
const dismiss = useCallback((id: string) => {
setItems((prev) => prev.filter((n) => n.id !== id))
}, [])
const notify = useCallback<NotificationsContextValue['notify']>((n) => {
const id = n.id ?? `auto-${++nextAutoId}`
const next: Notification = { ...n, id }
setItems((prev) => {
const idx = prev.findIndex((p) => p.id === id)
if (idx === -1) return [...prev, next]
const copy = [...prev]
copy[idx] = next
return copy
})
return id
}, [])
const value = useMemo<NotificationsContextValue>(
() => ({ items, notify, dismiss }),
[items, notify, dismiss],
)
return (
<NotificationsContext.Provider value={value}>
{children}
<NotificationTray items={items} dismiss={dismiss} />
</NotificationsContext.Provider>
)
}
interface NotificationTrayProps {
items: readonly Notification[]
dismiss: (id: string) => void
}
/**
* Bottom-right stacked toast tray. Fixed positioning so the surface is
* visible regardless of which page or tab is rendered. Tray is
* unmounted entirely when there are no items, keeping the DOM clean
* (and keeping the `role="alert"` / `role="status"` count at zero on
* the page chrome).
*/
function NotificationTray({ items, dismiss }: NotificationTrayProps) {
if (items.length === 0) return null
return (
<div
data-testid="notification-tray"
className="fixed bottom-4 right-4 z-[100] flex max-w-[26rem] flex-col gap-2"
aria-live="polite"
>
{items.map((n) => (
<NotificationCard key={n.id} item={n} dismiss={dismiss} />
))}
</div>
)
}
interface NotificationCardProps {
item: Notification
dismiss: (id: string) => void
}
function NotificationCard({ item, dismiss }: NotificationCardProps) {
const tone = TONE_BY_LEVEL[item.level]
return (
<div
role={item.level === 'error' ? 'alert' : 'status'}
data-testid={`notification-${item.id}`}
data-level={item.level}
className={`rounded-xl border ${tone.border} ${tone.surface} p-3 text-sm text-[var(--color-text)] shadow-lg`}
>
<div className="flex items-start gap-2">
<h3 className={`m-0 flex-1 text-sm font-semibold ${tone.title}`}>
{item.title}
</h3>
<button
type="button"
aria-label="Dismiss notification"
data-testid={`notification-${item.id}-dismiss`}
onClick={() => dismiss(item.id)}
className="-m-1 ml-2 rounded p-1 text-[var(--color-text-dim)] hover:text-[var(--color-text)]"
>
<svg viewBox="0 0 24 24" width={14} height={14} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
{item.body ? (
<p className="m-0 mt-1 text-[var(--color-text-dim)]">{item.body}</p>
) : null}
{item.raw ? (
<pre
data-testid={`notification-${item.id}-raw`}
className="my-2 max-h-32 overflow-auto rounded bg-[var(--color-bg)] p-2 text-[11px] text-[var(--color-text-dim)]"
>
{item.raw}
</pre>
) : null}
{item.actions && item.actions.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{item.actions.map((a, i) => (
<button
key={`${a.label}-${i}`}
type="button"
data-testid={a.testId}
onClick={() => {
a.onClick()
if (a.dismissOnClick !== false) dismiss(item.id)
}}
className={ACTION_CLASS[a.variant ?? 'primary']}
>
{a.label}
</button>
))}
</div>
) : null}
</div>
)
}
const TONE_BY_LEVEL: Record<
NotificationLevel,
{ border: string; surface: string; title: string }
> = {
error: {
border: 'border-[var(--color-danger)]/40',
surface: 'bg-[var(--color-danger)]/10',
title: 'text-[var(--color-danger)]',
},
warn: {
border: 'border-[var(--color-warn)]/40',
surface: 'bg-[var(--color-warn)]/10',
title: 'text-[var(--color-warn)]',
},
info: {
border: 'border-[var(--color-accent)]/40',
surface: 'bg-[var(--color-accent)]/10',
title: 'text-[var(--color-accent)]',
},
success: {
border: 'border-[var(--color-success)]/40',
surface: 'bg-[var(--color-success)]/10',
title: 'text-[var(--color-success)]',
},
}
const ACTION_CLASS: Record<NonNullable<NotificationAction['variant']>, string> = {
primary:
'rounded-md border border-[var(--color-accent)] bg-[var(--color-accent)] px-3 py-1 text-xs font-semibold text-white hover:bg-[var(--color-accent-hover)]',
danger:
'rounded-md border border-[var(--color-danger)] bg-[var(--color-danger)] px-3 py-1 text-xs font-semibold text-white hover:opacity-90',
ghost:
'rounded-md border border-[var(--color-border)] bg-transparent px-3 py-1 text-xs text-[var(--color-text-dim)] hover:text-[var(--color-text)]',
}