Files
bface/src/ui/components/Shell.jsx
Amer Agovic 859db6ccb2 Release 1.0.8 with platform, security, and UI hardening.
Adds API filter registry, style theme registry, SW bitmask cache clear, KV namespacing, session expiry checks, accessibility improvements, and expanded test coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 21:08:21 -05:00

1123 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shell
* Provides shell state management, context, provider, placement, and singleton manager
* Platform-agnostic shell system for UI layout control
*/
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
import { XStack, YStack, Text, Button, Spinner } from 'tamagui';
import RecordsModel from '../../data/RecordsModel.js';
import { getIcon } from './IconMapper.jsx';
import { SidePanelShell } from './SidePanelShell.jsx';
import { networkActivityManager } from '../../platform/api.js';
// ============================================================================
// Shell Context
// ============================================================================
const ShellContext = createContext(null);
// ============================================================================
// Shell Manager (Singleton)
// ============================================================================
/**
* Shell Manager
* Singleton that maintains shell state and provides global access for handlers
* Accessible via Shell.Manager for global access
*/
class ShellManager {
constructor() {
this._state = {
leftSideWidth: 0,
rightSideWidth: 0,
headerHeight: 0,
footerHeight: 0
};
this._setters = {
setLeftSideWidth: null,
setRightSideWidth: null,
setHeaderHeight: null,
setFooterHeight: null
};
this._listeners = new Set();
}
/**
* Initialize shell manager (called by ShellProvider)
* @param {Object} setters - State setter functions from ShellProvider
*/
_init(setters) {
this._setters = setters;
}
/**
* Update internal state (called by ShellProvider)
*/
_updateState(state) {
this._state = { ...state };
this._notifyListeners();
}
/**
* Notify all listeners of state changes
*/
_notifyListeners() {
this._listeners.forEach(listener => {
try {
listener({ ...this._state });
} catch (error) {
console.warn('[ShellManager] Listener error:', error);
}
});
}
/**
* Get current state
* @returns {Object} Current shell state
*/
getState() {
return { ...this._state };
}
/**
* Get left side width
* @returns {number}
*/
getLeftSideWidth() {
return this._state.leftSideWidth;
}
/**
* Get right side width
* @returns {number}
*/
getRightSideWidth() {
return this._state.rightSideWidth;
}
/**
* Set left side width
* @param {number} width
*/
setLeftSideWidth(width) {
if (this._setters.setLeftSideWidth) {
this._setters.setLeftSideWidth(width);
} else {
console.warn('[ShellManager] setLeftSideWidth not initialized');
}
}
/**
* Set right side width
* @param {number} width
*/
setRightSideWidth(width) {
if (this._setters.setRightSideWidth) {
this._setters.setRightSideWidth(width);
} else {
console.warn('[ShellManager] setRightSideWidth not initialized');
}
}
/**
* Toggle left side
* @param {number} [targetWidth=250] - Target width when opening
*/
toggleLeftSide(targetWidth = 250) {
const currentWidth = this._state.leftSideWidth;
this.setLeftSideWidth(currentWidth === 0 ? targetWidth : 0);
}
/**
* Toggle right side
* @param {number} [targetWidth=250] - Target width when opening
*/
toggleRightSide(targetWidth = 250) {
const currentWidth = this._state.rightSideWidth;
this.setRightSideWidth(currentWidth === 0 ? targetWidth : 0);
}
/**
* Subscribe to state changes
* @param {Function} listener - Callback function
* @returns {Function} Unsubscribe function
*/
subscribe(listener) {
this._listeners.add(listener);
return () => {
this._listeners.delete(listener);
};
}
}
// Create singleton instance
const shellManager = new ShellManager();
// ============================================================================
// Toast Manager (Singleton)
// ============================================================================
/**
* Toast Manager
* Singleton that maintains toast state and provides global access for handlers
* Accessible via Shell.ToastManager for global access
*/
class ToastManager {
constructor() {
this._toasts = [];
this._setToasts = null;
this._maxToasts = 5;
this._defaultDuration = 5000;
this._timeouts = new Map(); // Map of toast ID to timeout ID
this._pausedTimes = new Map(); // Map of toast ID to remaining time when paused
}
/**
* Initialize toast manager (called by ShellProvider)
* @param {Function} setToasts - State setter function from ShellProvider
*/
_init(setToasts) {
this._setToasts = setToasts;
}
/**
* Generate unique toast ID
* @returns {string}
*/
_generateId() {
return `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Show a toast notification
* @param {string} title - Toast title
* @param {string} [message] - Toast message
* @param {Object} [options] - Toast options
* @param {string} [options.type='info'] - Toast type: 'info' | 'success' | 'warning' | 'error'
* @param {number} [options.duration] - Auto-dismiss duration in ms (default: 5000)
* @returns {string} Toast ID
*/
show(title, message = '', options = {}) {
const {
type = 'info',
duration = this._defaultDuration,
persistToNotifications = true
} = options;
const startTime = Date.now();
const toast = {
id: this._generateId(),
title,
message,
type,
duration,
persistToNotifications,
timestamp: startTime,
startTime: startTime // Store start time for pause/resume calculations
};
if (!this._setToasts) {
console.warn('[ToastManager] Toast setter not initialized');
return toast.id;
}
this._setToasts(prev => {
const updated = [toast, ...prev];
// Limit max toasts
return updated.slice(0, this._maxToasts);
});
// Auto-dismiss after duration
if (duration > 0) {
const timeoutId = setTimeout(() => {
if (toast.persistToNotifications) {
notificationCenterManager.addFromToast(toast);
}
this.hide(toast.id);
this._timeouts.delete(toast.id);
}, duration);
this._timeouts.set(toast.id, timeoutId);
}
return toast.id;
}
/**
* Hide a toast by ID
* @param {string} id - Toast ID
*/
hide(id) {
if (!this._setToasts) {
console.warn('[ToastManager] Toast setter not initialized');
return;
}
// Clear timeout if exists
const timeoutId = this._timeouts.get(id);
if (timeoutId) {
clearTimeout(timeoutId);
this._timeouts.delete(id);
}
this._pausedTimes.delete(id);
this._setToasts(prev => prev.filter(toast => toast.id !== id));
}
/**
* Pause auto-dismiss timeout for a toast (e.g., when hovered)
* @param {string} id - Toast ID
*/
pauseTimeout(id) {
const timeoutId = this._timeouts.get(id);
if (!timeoutId) return; // No active timeout
if (!this._setToasts) return;
// Get the toast to calculate remaining time
let toast = null;
this._setToasts(prev => {
toast = prev.find(t => t.id === id);
return prev;
});
if (!toast || !toast.duration || !toast.startTime) return;
// Calculate elapsed time and remaining time
const elapsed = Date.now() - toast.startTime;
const remaining = toast.duration - elapsed;
if (remaining <= 0) {
// Time already expired, hide immediately
this.hide(id);
return;
}
// Clear the current timeout
clearTimeout(timeoutId);
this._timeouts.delete(id);
// Store remaining time
this._pausedTimes.set(id, { remaining });
}
/**
* Resume auto-dismiss timeout for a toast (e.g., when hover ends)
* @param {string} id - Toast ID
*/
resumeTimeout(id) {
const pausedData = this._pausedTimes.get(id);
if (!pausedData) return; // Wasn't paused
if (!this._setToasts) return;
// Get the toast to verify it still exists
let toast = null;
this._setToasts(prev => {
toast = prev.find(t => t.id === id);
return prev;
});
if (!toast || !toast.duration) return;
// Use the remaining time from when it was paused
const remaining = pausedData.remaining;
if (remaining <= 0) {
// Time already expired, hide immediately
this.hide(id);
return;
}
// Set new timeout with remaining time
const timeoutId = setTimeout(() => {
if (toast.persistToNotifications) {
notificationCenterManager.addFromToast(toast);
}
this.hide(id);
this._timeouts.delete(id);
}, remaining);
this._timeouts.set(id, timeoutId);
this._pausedTimes.delete(id);
}
/**
* Clear all toasts
*/
clear() {
if (!this._setToasts) {
console.warn('[ToastManager] Toast setter not initialized');
return;
}
// Clear all timeouts
this._timeouts.forEach(timeoutId => clearTimeout(timeoutId));
this._timeouts.clear();
this._pausedTimes.clear();
this._setToasts([]);
}
/**
* Get all active toasts
* @returns {Array}
*/
getToasts() {
return [...this._toasts];
}
getNotificationModel() {
return notificationCenterManager.getModel();
}
setNotificationModel(model) {
notificationCenterManager.setModel(model);
}
openNotifications() {
notificationCenterManager.open();
}
closeNotifications() {
notificationCenterManager.close();
}
toggleNotifications() {
notificationCenterManager.toggle();
}
}
// Create singleton instance
const toastManager = new ToastManager();
function createDefaultNotificationModel() {
const now = Date.now();
return new RecordsModel({
rows: [
{
id: 'welcome-notification',
title: 'Welcome',
message: 'Notifications that expire from toast popups will appear here until dismissed.',
type: 'info',
timestamp: now,
created_at: now,
source: 'system',
status: 'pending',
is_read: false,
resource_path: '/notifications',
action_label: null,
action_target: null,
action_kind: null,
meta: {}
}
],
idField: 'id',
searchFields: ['title', 'message', 'type'],
summaryDefinitions: [
{ id: 'total', label: 'Pending', type: 'count' }
]
});
}
class NotificationCenterManager {
constructor() {
this._open = false;
this._setOpen = null;
this._model = createDefaultNotificationModel();
this._listeners = new Set();
this._unbindModelListener = null;
this._bindModelListener();
}
_init(setOpen) {
this._setOpen = setOpen;
}
_bindModelListener() {
if (this._unbindModelListener) {
this._unbindModelListener();
this._unbindModelListener = null;
}
if (this._model?.subscribe) {
this._unbindModelListener = this._model.subscribe(() => {
this._notifyListeners();
});
}
}
_setOpenState(open) {
this._open = Boolean(open);
this._setOpen?.(this._open);
this._notifyListeners();
}
_notifyListeners() {
this._listeners.forEach((listener) => {
try {
listener({
open: this._open,
model: this._model
});
} catch (error) {
console.warn('[NotificationCenterManager] Listener failed:', error);
}
});
}
subscribe(listener) {
this._listeners.add(listener);
return () => {
this._listeners.delete(listener);
};
}
isOpen() {
return this._open;
}
open() {
this._setOpenState(true);
}
close() {
this._setOpenState(false);
}
toggle() {
this._setOpenState(!this._open);
}
getModel() {
return this._model;
}
setModel(model) {
if (!model) {
return;
}
this._model = model;
this._bindModelListener();
this._notifyListeners();
}
async addFromToast(toast) {
if (!toast || !this._model?.createRecord) {
return;
}
const existing = this._model.getRecord ? await this._model.getRecord(toast.id) : null;
if (existing && this._model.updateRecord) {
await this._model.updateRecord(toast.id, {
title: toast.title,
message: toast.message,
type: toast.type,
timestamp: toast.timestamp,
created_at: toast.timestamp,
source: 'toast',
status: 'pending',
is_read: false,
resource_path: '/notifications',
action_label: toast.action_label ?? null,
action_target: toast.action_target ?? null,
action_kind: toast.action_kind ?? null,
meta: toast.meta ?? {}
});
return;
}
await this._model.createRecord({
id: toast.id,
title: toast.title,
message: toast.message,
type: toast.type,
timestamp: toast.timestamp,
created_at: toast.timestamp,
source: 'toast',
status: 'pending',
is_read: false,
resource_path: '/notifications',
action_label: toast.action_label ?? null,
action_target: toast.action_target ?? null,
action_kind: toast.action_kind ?? null,
meta: toast.meta ?? {}
});
}
async dismiss(id) {
if (!this._model?.deleteRecord) {
return;
}
await this._model.deleteRecord(id);
}
async clear() {
const model = this._model;
if (!model?.queryRecords || !model?.deleteRecord) {
return;
}
const result = await model.queryRecords({
offset: 0,
page_size: 500,
sort_by: [{ field: 'timestamp', direction: 'desc' }]
});
await Promise.all((result.rows || []).map((row) => model.deleteRecord(row.id)));
}
}
const notificationCenterManager = new NotificationCenterManager();
function getNotificationTypeStyle(type = 'info') {
return {
info: {
backgroundColor: '$blue3',
borderColor: '$blue8',
icon: 'info'
},
success: {
backgroundColor: '$green3',
borderColor: '$green8',
icon: 'success'
},
warning: {
backgroundColor: '$yellow3',
borderColor: '$yellow8',
icon: 'warning'
},
error: {
backgroundColor: '$red3',
borderColor: '$red8',
icon: 'error'
}
}[type] || {
backgroundColor: '$blue3',
borderColor: '$blue8',
icon: 'info'
};
}
// ============================================================================
// Shell Provider
// ============================================================================
/**
* Shell Provider Component
* Provides shell state and control functions to child components
*
* @param {Object} props
* @param {React.ReactNode} props.children
* @param {number} [props.initialLeftWidth=0]
* @param {number} [props.initialRightWidth=0]
* @param {number} [props.initialHeaderHeight=0]
* @param {number} [props.initialFooterHeight=0]
*/
export function ShellProvider({
children,
initialLeftWidth = 0,
initialRightWidth = 0,
initialHeaderHeight = 0,
initialFooterHeight = 0
}) {
const [leftSideWidth, setLeftSideWidth] = useState(initialLeftWidth);
const [rightSideWidth, setRightSideWidth] = useState(initialRightWidth);
const [headerHeight, setHeaderHeight] = useState(initialHeaderHeight);
const [footerHeight, setFooterHeight] = useState(initialFooterHeight);
// Toast state
const [toasts, setToasts] = useState([]);
const [notificationsOpen, setNotificationsOpen] = useState(notificationCenterManager.isOpen());
const [networkActivity, setNetworkActivity] = useState(networkActivityManager.getState());
// Initialize shell manager with setters
useEffect(() => {
shellManager._init({
setLeftSideWidth,
setRightSideWidth,
setHeaderHeight,
setFooterHeight
});
}, []);
// Initialize toast manager with setter
useEffect(() => {
toastManager._init(setToasts);
}, []);
useEffect(() => {
notificationCenterManager._init(setNotificationsOpen);
}, []);
useEffect(() => networkActivityManager.subscribe(setNetworkActivity), []);
// Update shell manager state when it changes
useEffect(() => {
shellManager._updateState({
leftSideWidth,
rightSideWidth,
headerHeight,
footerHeight
});
}, [leftSideWidth, rightSideWidth, headerHeight, footerHeight]);
// Toggle functions for convenience
const toggleLeftSide = useCallback((targetWidth = 250) => {
setLeftSideWidth(prev => prev === 0 ? targetWidth : 0);
}, []);
const toggleRightSide = useCallback((targetWidth = 250) => {
setRightSideWidth(prev => prev === 0 ? targetWidth : 0);
}, []);
// Toast functions
const showToast = useCallback((title, message = '', options = {}) => {
return toastManager.show(title, message, options);
}, []);
const hideToast = useCallback((id) => {
toastManager.hide(id);
}, []);
const clearToasts = useCallback(() => {
toastManager.clear();
}, []);
const pauseToast = useCallback((id) => {
toastManager.pauseTimeout(id);
}, []);
const resumeToast = useCallback((id) => {
toastManager.resumeTimeout(id);
}, []);
const value = {
leftSideWidth,
rightSideWidth,
headerHeight,
footerHeight,
setLeftSideWidth,
setRightSideWidth,
setHeaderHeight,
setFooterHeight,
toggleLeftSide,
toggleRightSide,
toast: {
show: showToast,
hide: hideToast,
clear: clearToasts,
pause: pauseToast,
resume: resumeToast,
toasts
}
};
return (
<ShellContext.Provider value={value}>
{children}
<NetworkActivityOverlay visible={networkActivity.visible} />
<NotificationCenterPanel open={notificationsOpen} />
</ShellContext.Provider>
);
}
// ============================================================================
// Shell Hooks
// ============================================================================
/**
* Hook to access shell context
* @returns {Object} Shell state and control functions
*/
export function useShell() {
const context = useContext(ShellContext);
if (!context) {
throw new Error('useShell must be used within a ShellProvider');
}
return context;
}
// ============================================================================
// Toast Components
// ============================================================================
/**
* Toast Component
* Individual toast notification item
*/
function Toast({ toast, onClose, onPause, onResume }) {
const [isHovered, setIsHovered] = useState(false);
const [isExiting, setIsExiting] = useState(false);
// Pause/resume timeout on hover
useEffect(() => {
if (isHovered) {
onPause?.(toast.id);
} else {
onResume?.(toast.id);
}
}, [isHovered, toast.id, onPause, onResume]);
const handleClose = () => {
setIsExiting(true);
setTimeout(() => {
onClose(toast.id);
}, 300); // Match animation duration
};
// Type-specific styling
const style = getNotificationTypeStyle(toast.type);
const Icon = getIcon(style.icon);
return (
<XStack
backgroundColor={style.backgroundColor}
borderWidth={1}
borderColor={style.borderColor}
borderRadius="$radiusMd"
padding="$3"
minWidth={300}
maxWidth={400}
shadowColor="$shadowColor"
shadowOffset={{ width: 0, height: 2 }}
shadowOpacity={0.1}
shadowRadius={8}
elevation={4}
gap="$2"
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
animation="quick"
opacity={isExiting ? 0 : 1}
style={{
transition: 'opacity 0.3s ease, transform 0.3s ease',
transform: isExiting ? 'translateX(400px)' : 'translateX(0)'
}}
role="status"
aria-live="polite"
>
{Icon ? (
<XStack alignItems="center" justifyContent="center" width={20} height={20} flexShrink={0}>
<Icon size="md" color="$textSecondary" />
</XStack>
) : null}
<YStack flex={1} gap="$1">
{toast.title && (
<Text fontWeight="600" fontSize="$4" color="$color">
{toast.title}
</Text>
)}
{toast.message && (
<Text fontSize="$3" color="$textSecondary">
{toast.message}
</Text>
)}
</YStack>
<Button
size="$2"
circular
chromeless
onPress={handleClose}
aria-label="Close toast"
>
{(() => {
const CloseIcon = getIcon('close');
return CloseIcon ? <CloseIcon size="sm" color="$textSecondary" /> : <Text color="$textSecondary">×</Text>;
})()}
</Button>
</XStack>
);
}
function NotificationRecord({ record, onDismiss }) {
const style = getNotificationTypeStyle(record.type);
const Icon = getIcon(style.icon);
const CloseIcon = getIcon('close');
return (
<XStack
backgroundColor={style.backgroundColor}
borderWidth={1}
borderColor={style.borderColor}
borderRadius="$radiusMd"
padding="$3"
gap="$2"
alignItems="flex-start"
>
{Icon ? (
<XStack alignItems="center" justifyContent="center" width={20} height={20} flexShrink={0} marginTop="$1">
<Icon size="md" color="$textSecondary" />
</XStack>
) : null}
<YStack flex={1} gap="$1">
{record.title ? (
<Text fontWeight="600" fontSize="$4" color="$color">
{record.title}
</Text>
) : null}
{record.message ? (
<Text fontSize="$3" color="$textSecondary">
{record.message}
</Text>
) : null}
{record.timestamp ? (
<Text fontSize="$2" color="$textMuted">
{new Date(record.timestamp).toLocaleString()}
</Text>
) : null}
{record.action_label ? (
<Text fontSize="$2" color="$accent">
{record.action_label}
</Text>
) : null}
</YStack>
<Button
size="$2"
circular
chromeless
onPress={() => onDismiss?.(record.id)}
aria-label="Dismiss notification"
icon={CloseIcon ? <CloseIcon size="sm" color="$textSecondary" /> : undefined}
/>
</XStack>
);
}
function NotificationCenterPanel({ open = false }) {
const [model, setModel] = useState(notificationCenterManager.getModel());
const [dataVersion, setDataVersion] = useState(0);
const [records, setRecords] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
return notificationCenterManager.subscribe(({ model: nextModel }) => {
setModel(nextModel);
setDataVersion((value) => value + 1);
});
}, []);
useEffect(() => {
if (!model?.subscribe) {
return undefined;
}
return model.subscribe(() => {
setDataVersion((value) => value + 1);
});
}, [model]);
useEffect(() => {
let cancelled = false;
async function loadNotifications() {
if (!open || !model?.queryRecords) {
if (!cancelled) {
setRecords([]);
}
return;
}
setLoading(true);
setError('');
try {
const result = await model.queryRecords({
offset: 0,
page_size: 100,
sort_by: [{ field: 'timestamp', direction: 'desc' }]
});
if (!cancelled) {
setRecords(result?.rows || []);
}
} catch (loadError) {
if (!cancelled) {
setError(String(loadError?.message || loadError));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadNotifications();
return () => {
cancelled = true;
};
}, [open, model, dataVersion]);
return (
<SidePanelShell
open={open}
onClose={() => notificationCenterManager.close()}
title="Notifications"
width={360}
toolbar={records.length > 0 ? [
{
id: 'clear-notifications',
label: 'Clear All',
chromeless: true,
onPress: () => notificationCenterManager.clear()
}
] : []}
>
{error ? (
<Text color="$danger" fontWeight="600">
{error}
</Text>
) : null}
{loading ? (
<Text color="$textMuted">Loading notifications...</Text>
) : null}
{!loading && records.length === 0 ? (
<YStack gap="$2">
<Text fontWeight="600" color="$textPrimary">No pending notifications</Text>
<Text color="$textMuted">Expired toast messages will appear here until dismissed.</Text>
</YStack>
) : null}
{records.map((record) => (
<NotificationRecord
key={record.id}
record={record}
onDismiss={(id) => notificationCenterManager.dismiss(id)}
/>
))}
</SidePanelShell>
);
}
/**
* ToastViewport Component
* Container for toast notifications (fixed bottom-right)
*/
export function ToastViewport() {
const { toast } = useShell();
if (!toast || !toast.toasts || toast.toasts.length === 0) {
return null;
}
return (
<YStack
position="fixed"
bottom="$4"
right="$4"
gap="$2"
zIndex={22000}
pointerEvents="none"
maxWidth="calc(100vw - 32px)"
>
{toast.toasts.map((toastItem) => (
<XStack
key={toastItem.id}
pointerEvents="auto"
>
<Toast
toast={toastItem}
onClose={toast.hide}
onPause={toast.pause}
onResume={toast.resume}
/>
</XStack>
))}
</YStack>
);
}
function NetworkActivityOverlay({ visible = false }) {
if (!visible) {
return null;
}
return (
<YStack
role="status"
aria-live="polite"
aria-busy
accessibilityLabel="Network activity in progress"
position="fixed"
top={0}
left={0}
right={0}
bottom={0}
zIndex={21500}
alignItems="center"
justifyContent="center"
pointerEvents="none"
>
<YStack
padding="$4"
gap="$2"
alignItems="center"
justifyContent="center"
backgroundColor="$bgPanel"
borderWidth={1}
borderColor="$lineSubtle"
borderRadius="$radiusLg"
shadowColor="$shadowColor"
shadowOpacity={0.15}
shadowRadius={12}
elevation={6}
>
<Spinner size="large" color="$accentColor" />
<Text color="$textSecondary" fontSize="$3">
Working...
</Text>
</YStack>
</YStack>
);
}
// ============================================================================
// Shell Placement
// ============================================================================
/**
* ShellPlacement Component
* Helper component for placing children in specific shell sections
*
* Usage:
* <ShellPlacement placement="leftSide">Sidebar content</ShellPlacement>
* <ShellPlacement placement="header">Header content</ShellPlacement>
*
* @param {Object} props
* @param {string} props.placement - 'leftSide' | 'rightSide' | 'header' | 'footer' | 'mainContent'
* @param {React.ReactNode} props.children - Content to place
*/
export function ShellPlacement({ placement = 'mainContent', children }) {
// Clone children and add placement prop
return React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
return React.cloneElement(child, {
placement,
shellPlacement: placement
});
}
return child;
});
}
// ============================================================================
// Exports
// ============================================================================
// Attach static properties
ShellProvider.Manager = shellManager;
ShellProvider.ToastManager = toastManager;
ShellProvider.NotificationManager = notificationCenterManager;
ShellProvider.Context = ShellContext;
ShellProvider.Placement = ShellPlacement;
export default ShellProvider;
export { shellManager as ShellManager, toastManager as ToastManager, notificationCenterManager as NotificationManager };
export { ShellContext };
// ShellPlacement is already exported as a function declaration above