From 686956bfa8c886055aabacf7b83710b1696c773f Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:12:30 +0800 Subject: [PATCH] feat(app-shell): the console mounts the notification surfaces (#3014 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3071 gave each spec `NotificationTypeSchema` member its own presentation, but no host mounted `NotificationProvider` — the capability existed and the console could not reach it. `ConsoleShell` now mounts the provider plus the surfaces with a single global home; `ConsoleLayout` mounts the one that belongs in the content area: toast -> sonner, via the new `presentNotificationToast` (ConsoleShell) snackbar -> (ConsoleShell) alert -> (ConsoleShell) banner -> , beside the draft / unpublished bars (ConsoleLayout) inline -> the raising surface's own ; deliberately NOT mounted globally, since rendering in place at the raiser is the whole difference between it and a banner `presentNotificationToast` is the single place a notification becomes a sonner call: severity -> variant, `duration: 0` -> `Infinity` (the contract's "persistent", which passed through raw would make the toast vanish on the next tick), first action -> sonner's one action slot, an absent duration left to the ConsoleToaster default. Its severity table is `Record`, so a new spec severity fails type-check instead of silently rendering neutral. The banners go through `ConsoleNotificationBanners`, gated on `useHasNotificationProvider()`: `ConsoleShell` is deliberately composable pieces a host assembles itself, so `ConsoleLayout` can render without the provider above it — and `useNotifications()` throws there, white-screening the app instead of simply showing no banners. Both pieces are exported for hand-assembled shells. Verified in the running console (login route): a toast, a snackbar with an Undo action, and a blocking alert dialog on screen at once, visibly distinct; raising an `inline` with no outlet logs the dev warning naming the component to mount, once per notify(). Co-Authored-By: Claude Opus 5 --- .../console-mounts-notification-surfaces.md | 43 ++++++ content/docs/guide/notifications.md | 17 +++ packages/app-shell/README.md | 23 +++ packages/app-shell/src/chrome/index.ts | 1 + .../src/chrome/notificationToast.test.ts | 104 ++++++++++++++ .../app-shell/src/chrome/notificationToast.ts | 50 +++++++ .../app-shell/src/console/ConsoleShell.tsx | 55 +++++--- .../ConsoleShell.notifications.test.tsx | 132 ++++++++++++++++++ packages/app-shell/src/index.ts | 2 + .../app-shell/src/layout/ConsoleLayout.tsx | 5 + .../src/layout/ConsoleNotificationBanners.tsx | 28 ++++ packages/app-shell/src/layout/index.ts | 1 + 12 files changed, 445 insertions(+), 16 deletions(-) create mode 100644 .changeset/console-mounts-notification-surfaces.md create mode 100644 packages/app-shell/src/chrome/notificationToast.test.ts create mode 100644 packages/app-shell/src/chrome/notificationToast.ts create mode 100644 packages/app-shell/src/console/__tests__/ConsoleShell.notifications.test.tsx create mode 100644 packages/app-shell/src/layout/ConsoleNotificationBanners.tsx diff --git a/.changeset/console-mounts-notification-surfaces.md b/.changeset/console-mounts-notification-surfaces.md new file mode 100644 index 0000000000..e4d85468a6 --- /dev/null +++ b/.changeset/console-mounts-notification-surfaces.md @@ -0,0 +1,43 @@ +--- +"@object-ui/app-shell": minor +--- + +feat(app-shell): the console mounts the notification surfaces, so `displayType` works there (#3014 follow-up) + +#3071 gave each spec `NotificationTypeSchema` member its own presentation, but no +host mounted `NotificationProvider` — the capability existed and the console +could not reach it. `ConsoleShell` now mounts the provider and the surfaces with +a single global home; `ConsoleLayout` mounts the one that belongs in the content +area: + +| `displayType` | Surface | Mounted by | +|---|---|---| +| `toast` | sonner, via the new `presentNotificationToast` | `ConsoleShell` | +| `snackbar` | `` | `ConsoleShell` | +| `alert` | `` | `ConsoleShell` | +| `banner` | `` | `ConsoleLayout`, beside the draft / unpublished bars | +| `inline` | `` | the raising surface — **not** mounted globally | + +`inline` is left out deliberately: rendering in place at the raiser is the whole +difference between it and a banner, so a global inline outlet would collapse the +two again. + +`presentNotificationToast` is the single place a notification becomes a sonner +call — severity → variant, `duration: 0` → `Infinity` (the contract's +"persistent", which passed through raw would have made the toast vanish on the +next tick), first action → the one action slot sonner offers, an absent duration +left to the `ConsoleToaster` default rather than reinvented. Its severity table +is `Record`, so a new spec severity fails +type-check instead of silently rendering neutral. + +The banners go through `ConsoleNotificationBanners`, which gates on +`useHasNotificationProvider()`. `ConsoleShell` is deliberately a set of +composable pieces a host assembles in its own `App.tsx`, so `ConsoleLayout` can +legitimately render without the provider above it — and `useNotifications()` +throws there, which would white-screen the whole app instead of simply showing +no banners. + +Both pieces are exported (`presentNotificationToast`, `ConsoleNotificationBanners`) +for hand-assembled shells. The provider's `defaultDuration` matches +`ConsoleToaster`'s 4s, so a snackbar and a toast raised together disappear +together. diff --git a/content/docs/guide/notifications.md b/content/docs/guide/notifications.md index dbfec1a648..c96d7d66a9 100644 --- a/content/docs/guide/notifications.md +++ b/content/docs/guide/notifications.md @@ -67,6 +67,23 @@ are collected in `notifications` / `unreadCount` for a bell or list, and nothing is overlaid. Raising one of the other four types with its surface unmounted is a mistake, though — dev builds warn, naming the component to mount. +### In the console + +`@object-ui/app-shell` already does this wiring — a console route can call +`useNotifications()` and every display type presents correctly with no setup: + +| Surface | Mounted by | +|---|---| +| `toast` | `ConsoleShell`, via `presentNotificationToast` → sonner (`ConsoleToaster`) | +| `snackbar`, `alert` | `ConsoleShell` — both have a single global home | +| `banner` | `ConsoleLayout`, at the top of the content area, beside the draft / unpublished bars | +| `inline` | nothing, by contract — the raising surface mounts its own `` | + +Assembling a shell by hand? Both pieces are exported: `presentNotificationToast` +for the `onToast` delegate, and `ConsoleNotificationBanners` — `NotificationBanners` +guarded by `useHasNotificationProvider()`, so a layout rendered without the +provider above it renders no banners instead of throwing. + ## Raising notifications ```tsx diff --git a/packages/app-shell/README.md b/packages/app-shell/README.md index ac084c1290..a5d86eceac 100644 --- a/packages/app-shell/README.md +++ b/packages/app-shell/README.md @@ -64,6 +64,29 @@ function MyDashboard() { - **Full-Page AI Workspace**: The `/ai` surface provides a responsive chat workspace with a desktop conversation rail, mobile Chats drawer, and a constrained reading width for long conversations +- **Notification Surfaces**: `ConsoleShell` mounts `NotificationProvider` and + every spec `displayType` presents distinctly — no per-app wiring + +## Notifications + +`ConsoleShell` mounts `NotificationProvider`, so any console route can call +`useNotifications()` and get the presentation it asked for: + +| `displayType` | Surface | Mounted by | +| --- | --- | --- | +| `toast` | sonner, via `presentNotificationToast` | `ConsoleShell` | +| `snackbar` | `` | `ConsoleShell` | +| `alert` | `` | `ConsoleShell` | +| `banner` | `` | `ConsoleLayout`, top of the content area | +| `inline` | `` | the surface that raises it | + +`inline` is deliberately not mounted globally — rendering in place at the raiser +is the whole difference between it and a banner. Both console-side pieces are +exported for hand-assembled shells: `presentNotificationToast` (the `onToast` +delegate) and `ConsoleNotificationBanners` (the banners, guarded by +`useHasNotificationProvider()` so a layout without the provider renders no +banners instead of throwing). See the +[notifications guide](https://objectui.org/docs/guide/notifications). ## Components diff --git a/packages/app-shell/src/chrome/index.ts b/packages/app-shell/src/chrome/index.ts index 494668776d..58945ce555 100644 --- a/packages/app-shell/src/chrome/index.ts +++ b/packages/app-shell/src/chrome/index.ts @@ -8,3 +8,4 @@ export { ErrorBoundary } from './ErrorBoundary'; export { LoadingScreen } from './LoadingScreen'; export { ThemeProvider, useTheme } from './ThemeProvider'; export { toastWithUndo, type ToastWithUndoOptions } from './toast-helpers'; +export { presentNotificationToast } from './notificationToast'; diff --git a/packages/app-shell/src/chrome/notificationToast.test.ts b/packages/app-shell/src/chrome/notificationToast.test.ts new file mode 100644 index 0000000000..36b2854dc7 --- /dev/null +++ b/packages/app-shell/src/chrome/notificationToast.test.ts @@ -0,0 +1,104 @@ +/** + * The console's `toast` surface — the one display type `NotificationProvider` + * delegates instead of rendering itself (#3014). These pin the mapping onto + * sonner; which notifications ever get here is covered by + * `console/__tests__/ConsoleShell.notifications.test.tsx`. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { NotificationItem } from '@object-ui/react'; + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + info: vi.fn(), success: vi.fn(), warning: vi.fn(), error: vi.fn(), + }), +})); + +import { toast } from 'sonner'; +import { presentNotificationToast } from './notificationToast'; + +function item(overrides: Partial = {}): NotificationItem { + return { + id: 'n1', + title: 'Saved', + severity: 'success', + createdAt: new Date(0), + ...overrides, + }; +} + +describe('presentNotificationToast', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('maps each severity to its sonner variant', () => { + presentNotificationToast(item({ severity: 'info' })); + presentNotificationToast(item({ severity: 'success' })); + presentNotificationToast(item({ severity: 'warning' })); + presentNotificationToast(item({ severity: 'error' })); + + expect(toast.info).toHaveBeenCalledTimes(1); + expect(toast.success).toHaveBeenCalledTimes(1); + expect(toast.warning).toHaveBeenCalledTimes(1); + expect(toast.error).toHaveBeenCalledTimes(1); + }); + + it('passes the message through as the toast description', () => { + presentNotificationToast(item({ message: 'Record updated' })); + expect(toast.success).toHaveBeenCalledWith('Saved', expect.objectContaining({ + description: 'Record updated', + })); + }); + + it('turns duration 0 into a persistent toast', () => { + // `0 = persistent` is the notification contract; sonner spells it Infinity. + // Passing the 0 through would make the toast vanish on the next tick. + presentNotificationToast(item({ duration: 0 })); + expect(toast.success).toHaveBeenCalledWith('Saved', expect.objectContaining({ + duration: Infinity, + })); + }); + + it('leaves an unset duration to the ConsoleToaster default', () => { + presentNotificationToast(item()); + expect(toast.success).toHaveBeenCalledWith('Saved', expect.objectContaining({ + duration: undefined, + })); + }); + + it('forwards an explicit duration unchanged', () => { + presentNotificationToast(item({ duration: 12_000 })); + expect(toast.success).toHaveBeenCalledWith('Saved', expect.objectContaining({ + duration: 12_000, + })); + }); + + it('maps the first action to the toast action button', () => { + const onClick = vi.fn(); + presentNotificationToast(item({ + actions: [ + { label: 'Undo', onClick }, + { label: 'Ignored — a toast has one action slot', onClick: vi.fn() }, + ], + })); + + const options = vi.mocked(toast.success).mock.calls[0][1] as { + action?: { label: string; onClick: () => void }; + }; + expect(options.action?.label).toBe('Undo'); + options.action?.onClick(); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('omits the action slot entirely when none is declared', () => { + presentNotificationToast(item()); + const options = vi.mocked(toast.success).mock.calls[0][1] as Record; + expect(options).not.toHaveProperty('action'); + }); + + it('forwards dismissible', () => { + presentNotificationToast(item({ dismissible: false })); + expect(toast.success).toHaveBeenCalledWith('Saved', expect.objectContaining({ + dismissible: false, + })); + }); +}); diff --git a/packages/app-shell/src/chrome/notificationToast.ts b/packages/app-shell/src/chrome/notificationToast.ts new file mode 100644 index 0000000000..45aaa937df --- /dev/null +++ b/packages/app-shell/src/chrome/notificationToast.ts @@ -0,0 +1,50 @@ +/** + * The console's `toast` surface. + * + * `NotificationProvider` presents four of the five spec display types through + * surface components (#3014); `toast` is the one it delegates, and in the + * console that delegate is sonner. This module is that bridge — it is the + * ONLY place a notification becomes a sonner call, so the mapping (severity → + * variant, `duration: 0` → persistent, first action → the toast's action + * button) lives in one testable function rather than inline in the shell. + * + * Non-toast display types never reach here: the provider routes them to + * `NotificationBanners` / `NotificationSnackbar` / `NotificationAlerts` / + * `NotificationInline` instead. + */ + +import { toast } from 'sonner'; +import type { NotificationItem, NotificationSeverityLevel } from '@object-ui/react'; + +/** + * Typed as `Record` so a new severity in the spec + * `NotificationSeveritySchema` fails type-check here until it has a variant, + * rather than silently rendering as a neutral toast. + */ +const TOAST_BY_SEVERITY: Record unknown> = { + info: (message, data) => toast.info(message, data as never), + success: (message, data) => toast.success(message, data as never), + warning: (message, data) => toast.warning(message, data as never), + error: (message, data) => toast.error(message, data as never), +}; + +/** + * Present one `displayType: 'toast'` notification through sonner. + * + * Duration follows the notification contract: `0` means persistent (sonner's + * `Infinity`), and an absent duration defers to the `ConsoleToaster` default + * rather than being invented here. + */ +export function presentNotificationToast(notification: NotificationItem): void { + const show = TOAST_BY_SEVERITY[notification.severity] ?? TOAST_BY_SEVERITY.info; + // A toast carries at most one action button — sonner has one `action` slot, + // and a notification needing more than one belongs on a banner or an alert. + const action = notification.actions?.[0]; + + show(notification.title, { + description: notification.message, + duration: notification.duration === 0 ? Infinity : notification.duration, + dismissible: notification.dismissible, + ...(action ? { action: { label: action.label, onClick: action.onClick } } : {}), + }); +} diff --git a/packages/app-shell/src/console/ConsoleShell.tsx b/packages/app-shell/src/console/ConsoleShell.tsx index fd45cf11c8..eeb7c42f42 100644 --- a/packages/app-shell/src/console/ConsoleShell.tsx +++ b/packages/app-shell/src/console/ConsoleShell.tsx @@ -14,7 +14,9 @@ import { Suspense, useEffect, useRef, useState, type ReactNode } from 'react'; import { Navigate, useLocation } from 'react-router-dom'; import { AuthGuard, useAuth, createAuthenticatedFetch } from '@object-ui/auth'; import { useObjectTranslation } from '@object-ui/i18n'; -import { SchemaRendererProvider, ActionProvider } from '@object-ui/react'; +import { SchemaRendererProvider, ActionProvider, NotificationProvider } from '@object-ui/react'; +import { NotificationAlerts, NotificationSnackbar } from '@object-ui/components'; +import { presentNotificationToast } from '../chrome/notificationToast'; import { useActionModal } from '../hooks/useActionModal'; import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime'; import { createObjectStackUserStateAdapter } from '@object-ui/data-objectstack'; @@ -101,8 +103,9 @@ function GlobalActionRuntimeProvider({ dataSource, children }: { dataSource: unk /** * ConsoleShell — top-level provider stack shared by every console route. * Wraps children in ThemeProvider + NavigationProvider + FavoritesProvider + - * Suspense so lazy route components get a default loading fallback and - * dark/light/system theme switching works out of the box. + * NotificationProvider + Suspense so lazy route components get a default + * loading fallback, dark/light/system theme switching, and a notification + * system that honors the spec `displayType`. * * Place this inside a and around your : * @@ -111,23 +114,43 @@ function GlobalActionRuntimeProvider({ dataSource, children }: { dataSource: unk * ... * * + * + * ── Notification surfaces (#3014) ── + * Each spec display type has its own presentation, so the shell mounts the ones + * with a single global home and leaves the rest to their owners: + * + * toast → `presentNotificationToast` (sonner, via `onToast`) + * snackbar → here — it anchors itself bottom-center + * alert → here — a blocking dialog is global + * banner → in `ConsoleLayout`, at the top of the + * content area, next to the draft / + * unpublished bars it sits with + * inline → nothing here, by contract — an inline notification is rendered + * by the surface that RAISED it (``), + * which is the whole difference between it and a banner */ export function ConsoleShell({ children }: { children: ReactNode }) { return ( - - - - - - }>{children} - {/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */} - - - - - - + {/* `defaultDuration` matches ConsoleToaster's 4s toast default, so a + snackbar and a toast raised together disappear together. */} + + + + + + + }>{children} + {/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */} + + + + + + + + + ); } diff --git a/packages/app-shell/src/console/__tests__/ConsoleShell.notifications.test.tsx b/packages/app-shell/src/console/__tests__/ConsoleShell.notifications.test.tsx new file mode 100644 index 0000000000..1f73df9f28 --- /dev/null +++ b/packages/app-shell/src/console/__tests__/ConsoleShell.notifications.test.tsx @@ -0,0 +1,132 @@ +/** + * The console's notification surfaces (#3014 follow-up). + * + * `ConsoleShell` mounts `NotificationProvider` plus the surfaces with a single + * global home (snackbar, alert); `ConsoleLayout` mounts the banners in the + * content area. These tests pin the wiring: each spec `displayType` raised from + * inside the console reaches its OWN presentation, and `toast` — and only + * `toast` — reaches sonner. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useEffect } from 'react'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useNotifications, type NotificationItem } from '@object-ui/react'; +import { ConsoleShell } from '../ConsoleShell'; +import { ConsoleNotificationBanners } from '../../layout/ConsoleNotificationBanners'; + +// The `toast` surface. Mocked so the assertion is "sonner was asked to show +// exactly this", not "sonner rendered something in jsdom". +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + info: vi.fn(), success: vi.fn(), warning: vi.fn(), error: vi.fn(), + dismiss: vi.fn(), custom: vi.fn(), loading: vi.fn(), promise: vi.fn(), + }), + Toaster: () => null, +})); + +// ADR-0069 gate — unrelated to notifications and needs an AuthProvider above it. +vi.mock('../RemediationOverlay', () => ({ RemediationOverlay: () => null })); + +import { toast } from 'sonner'; + +type Notify = (input: Omit) => string; + +/** Populated once the shell has mounted; the tests raise through it. */ +const notifier: { current: Notify | null } = { current: null }; + +function Raiser() { + const { notify } = useNotifications(); + useEffect(() => { notifier.current = notify; }, [notify]); + return
ROUTE CONTENT
; +} + +const raise: Notify = (input) => notifier.current!(input); + +/** ConsoleShell with the content-area banners `ConsoleLayout` contributes. */ +function renderConsole() { + return render( + + + + , + ); +} + +/** The surface element a notification title actually rendered into, if any. */ +function surfaceOf(title: string): string | null { + const host = screen.queryByText(title)?.closest('[data-notification-surface]'); + return host?.getAttribute('data-notification-surface') ?? null; +} + +describe('ConsoleShell notification surfaces', () => { + beforeEach(() => { + vi.clearAllMocks(); + notifier.current = null; + renderConsole(); + }); + + it('mounts the provider so any console surface can notify', () => { + expect(screen.getByText('ROUTE CONTENT')).toBeInTheDocument(); + expect(notifier.current).toBeTypeOf('function'); + }); + + it('sends a toast to sonner and to no surface', () => { + act(() => { raise({ title: 'Saved', message: 'Record updated', severity: 'success' }); }); + + expect(toast.success).toHaveBeenCalledWith('Saved', expect.objectContaining({ + description: 'Record updated', + })); + expect(screen.queryByText('Saved')).not.toBeInTheDocument(); + }); + + it('renders a banner in the content area, not through sonner', () => { + act(() => { raise({ title: 'Viewing a draft', severity: 'warning', displayType: 'banner' }); }); + + expect(surfaceOf('Viewing a draft')).toBe('banner'); + expect(toast.warning).not.toHaveBeenCalled(); + }); + + it('renders a snackbar bottom-anchored, not through sonner', () => { + act(() => { raise({ title: 'Row deleted', severity: 'info', displayType: 'snackbar' }); }); + + expect(surfaceOf('Row deleted')).toBe('snackbar'); + expect(toast.info).not.toHaveBeenCalled(); + }); + + it('renders an alert as a blocking dialog', async () => { + const user = userEvent.setup(); + act(() => { raise({ title: 'Session expired', severity: 'error', displayType: 'alert' }); }); + + const dialog = await screen.findByRole('alertdialog'); + expect(dialog).toHaveAttribute('data-notification-surface', 'alert'); + expect(toast.error).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'OK' })); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + + it('keeps the four presentations distinct when raised together', () => { + act(() => { + raise({ title: 'N toast', severity: 'info', displayType: 'toast' }); + raise({ title: 'N snackbar', severity: 'info', displayType: 'snackbar' }); + raise({ title: 'N banner', severity: 'info', displayType: 'banner' }); + raise({ title: 'N alert', severity: 'info', displayType: 'alert' }); + }); + + const seen = ['N snackbar', 'N banner', 'N alert'].map(surfaceOf); + expect(seen).toEqual(['snackbar', 'banner', 'alert']); + expect(toast.info).toHaveBeenCalledTimes(1); + expect(toast.info).toHaveBeenCalledWith('N toast', expect.anything()); + }); +}); + +describe('ConsoleNotificationBanners outside a NotificationProvider', () => { + it('renders nothing instead of throwing', () => { + // `ConsoleLayout` is a composable piece — a host may assemble it without + // `ConsoleShell`. `useNotifications()` throws there, which would white-screen + // the app; the guard has to keep that from happening. + expect(() => render()).not.toThrow(); + }); +}); diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index 2bb5521d28..4c0ec8f9a7 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -71,6 +71,7 @@ export type { AiSurfaceState } from './hooks/useAiSurface'; // Layout chrome export { ConsoleLayout, + ConsoleNotificationBanners, AppHeader, AppSidebar, UnifiedSidebar, @@ -91,6 +92,7 @@ export { OnboardingWalkthrough, ConditionalAuthWrapper, ConsoleToaster, + presentNotificationToast, RouteFader, toastWithUndo, type ToastWithUndoOptions, diff --git a/packages/app-shell/src/layout/ConsoleLayout.tsx b/packages/app-shell/src/layout/ConsoleLayout.tsx index 1bf9f2d549..136a562aa8 100644 --- a/packages/app-shell/src/layout/ConsoleLayout.tsx +++ b/packages/app-shell/src/layout/ConsoleLayout.tsx @@ -25,6 +25,7 @@ import { } from './chatDockState'; import { DraftPreviewBar } from '../preview/DraftPreviewBar'; import { UnpublishedAppBar } from '../preview/UnpublishedAppBar'; +import { ConsoleNotificationBanners } from './ConsoleNotificationBanners'; import { UnifiedSidebar } from './UnifiedSidebar'; import { AppHeader } from './AppHeader'; import { MobileViewSwitcherProvider } from './MobileViewSwitcherContext'; @@ -185,6 +186,10 @@ export function ConsoleLayout({ {/* ADR-0045: materialized-but-unlisted app — real and interactive, invisible to end users until the Publish visibility flip. */} + {/* #3014: `displayType: 'banner'` notifications. In flow, above the + route content and below the two preview bars — a banner states a + standing condition, same as its neighbours here. */} + {children} diff --git a/packages/app-shell/src/layout/ConsoleNotificationBanners.tsx b/packages/app-shell/src/layout/ConsoleNotificationBanners.tsx new file mode 100644 index 0000000000..15e2af4963 --- /dev/null +++ b/packages/app-shell/src/layout/ConsoleNotificationBanners.tsx @@ -0,0 +1,28 @@ +/** + * ConsoleNotificationBanners + * + * `` for the console content area, guarded so + * `ConsoleLayout` stays usable outside a `NotificationProvider`. + * + * The guard is not defensive noise: `ConsoleShell` is deliberately a set of + * composable pieces consumers assemble in their own `App.tsx` (see that + * module's header), so a host can legitimately render `ConsoleLayout` without + * the shell above it. `useNotifications()` THROWS outside its provider — that + * would turn "no notification system configured" into a white screen for the + * whole app. `useHasNotificationProvider()` reads the context without throwing, + * so the banners simply don't render. + * @module + */ + +import { useHasNotificationProvider } from '@object-ui/react'; +import { NotificationBanners } from '@object-ui/components'; + +export function ConsoleNotificationBanners() { + // Must gate on RENDERING , not on an early return + // inside it — its own hooks throw before any guard we could put there. + const hasProvider = useHasNotificationProvider(); + if (!hasProvider) return null; + return ; +} + +ConsoleNotificationBanners.displayName = 'ConsoleNotificationBanners'; diff --git a/packages/app-shell/src/layout/index.ts b/packages/app-shell/src/layout/index.ts index 6bddabc1ad..facf88ca1f 100644 --- a/packages/app-shell/src/layout/index.ts +++ b/packages/app-shell/src/layout/index.ts @@ -1,4 +1,5 @@ export { ConsoleLayout } from './ConsoleLayout'; +export { ConsoleNotificationBanners } from './ConsoleNotificationBanners'; export { AppHeader } from './AppHeader'; export { AppSidebar } from './AppSidebar'; export { UnifiedSidebar } from './UnifiedSidebar';