{ + if (registerIcon(iconName, Icon, options)) { + registeredNames.push(normalizeIconName(iconName)); + } + }); + return registeredNames; +} + +export function hasIcon(iconName) { + return iconRegistry.has(normalizeIconName(iconName)); +} + +export function setFallbackIcon(iconNameOrComponent = null) { + if (typeof iconNameOrComponent === 'string') { + fallbackIconName = normalizeIconName(iconNameOrComponent); + fallbackIconComponent = null; + return true; + } + + if (!iconNameOrComponent) { + fallbackIconName = ''; + fallbackIconComponent = null; + return true; + } + + const wrappedIcon = wrapRegisteredIcon('FallbackIcon', iconNameOrComponent); + if (!wrappedIcon) { + console.warn('[IconMapper] setFallbackIcon() requires an icon alias, component, or null'); + return false; + } + + fallbackIconName = ''; + fallbackIconComponent = wrappedIcon; + return true; +} + +export function getFallbackIconName() { + return normalizeIconName(fallbackIconName) || null; +} + /** * Look up a wrapped Phosphor icon by name. Returns a React component (the - * wrapper handles theme-aware color, sizing, and weight) or `null` if the - * name is unknown. + * wrapper handles theme-aware color, sizing, and weight). Unknown icons fall + * back to the configured fallback icon when available. * - * @param {string} iconName - canonical alias from {@link iconMap} + * @param {string} iconName - canonical alias from the icon registry + * @param {{allowFallback?: boolean}} [options] * @returns {React.ComponentType<{size?:string|number,color?:string,weight?:string}>|null} */ -export function getIcon(iconName) { - if (typeof iconName !== 'string' || !iconName) return null; - return iconMap[iconName.toLowerCase().trim()] || null; +export function getIcon(iconName, options = {}) { + const { allowFallback = true } = options; + const normalized = normalizeIconName(iconName); + + if (!normalized) { + return allowFallback ? resolveFallbackIcon(normalized) : null; + } + + const registeredIcon = iconRegistry.get(normalized) || null; + if (registeredIcon) { + return registeredIcon; + } + + return allowFallback ? resolveFallbackIcon(normalized) : null; } /** @@ -459,13 +569,17 @@ export function getIcon(iconName) { * single character (e.g. user-entered avatar glyphs). */ export function IconMapper({ iconName, size = DEFAULT_SIZE, color = '$textPrimary', ...props }) { - const Icon = getIcon(iconName); + const Icon = getIcon(iconName, { allowFallback: false }); if (Icon) { return ; } if (typeof iconName === 'string' && (iconName.length <= 2 || /[\u{1F300}-\u{1F9FF}]/u.test(iconName))) { return {iconName}; } + const FallbackIcon = getIcon(iconName); + if (FallbackIcon) { + return ; + } return null; } @@ -530,7 +644,7 @@ export function getIconSize(size) { * List of every registered alias. Handy for validation / docs. */ export function getIconNames() { - return Object.keys(iconMap); + return Array.from(iconRegistry.keys()).sort(); } export default IconMapper; diff --git a/src/ui/components/MenuItemButton.jsx b/src/ui/components/MenuItemButton.jsx index 8a12b79..40fc53f 100644 --- a/src/ui/components/MenuItemButton.jsx +++ b/src/ui/components/MenuItemButton.jsx @@ -19,6 +19,29 @@ import { import { MenuItem, getMenuItemExpandedPreference, setMenuItemExpandedPreference } from '../../platform/menu.js'; import { securityService, useSecurityState } from '../../security/runtime/security-service.js'; +function buildMenuItemA11yProps({ + menuItem, + showLabel, + hasSubitems, + popupOpen, + isExpanded, + effectiveExpandMode +}) { + const props = { + accessibilityRole: 'menuitem' + }; + + if (!showLabel && menuItem.label) { + props.accessibilityLabel = menuItem.label; + } + + if (hasSubitems) { + props['aria-expanded'] = effectiveExpandMode === 'popup' ? popupOpen : isExpanded; + } + + return props; +} + /** * MenuItemButton Component * @@ -157,7 +180,7 @@ export function MenuItemButton({ return NotificationManager.subscribe(syncCount); }, [menuItem.id]); - // Close popup when clicking outside (popup mode only) + // Close popup when clicking outside or pressing Escape (popup mode only) useEffect(() => { if (effectiveExpandMode === 'popup' && popupOpen) { const handleClickOutside = (event) => { @@ -170,9 +193,20 @@ export function MenuItemButton({ setPopupOpen(false); } }; - // Use compatibility layer for document event listener - return addDocumentEventListener('mousedown', handleClickOutside); + const handleEscape = (event) => { + if (event.key === 'Escape') { + setPopupOpen(false); + } + }; + + const removeClickListener = addDocumentEventListener('mousedown', handleClickOutside); + const removeKeyListener = addDocumentEventListener('keydown', handleEscape); + return () => { + removeClickListener?.(); + removeKeyListener?.(); + }; } + return undefined; }, [popupOpen, effectiveExpandMode]); // Determine width @@ -345,6 +379,14 @@ export function MenuItemButton({ ? (orientation === 'vertical' ? 'chevron-right' : 'chevron-down') : (isExpanded ? 'chevron-down' : 'chevron-right'); const ArrowIcon = getIcon(arrowIconName); + const menuItemA11yProps = buildMenuItemA11yProps({ + menuItem, + showLabel, + hasSubitems, + popupOpen, + isExpanded, + effectiveExpandMode + }); // Render based on orientation if (orientation === 'horizontal') { @@ -365,6 +407,7 @@ export function MenuItemButton({ borderRadius="$radiusSm" padding={padding} opacity={menuItem.is_active !== false ? 1 : 0.55} + {...menuItemA11yProps} > {/* Icon + Label (clickable main area) */} {/* Icon + Label (clickable main area) */} { + if (guardState.evaluating || guardState.pending || guardState.allowed || !guardState.requires_login) { + return; + } + + const loginRoute = securityState.config?.login_route || '/login'; + if (!loginRoute || route.path === loginRoute) { + return; + } + + setRouterPath(loginRoute, true, { + state: route.path ? { redirect_to: route.path } : null + }).catch((error) => { + console.warn('[Router] Failed to redirect to login route:', error); + }); + }, [guardState.allowed, guardState.evaluating, guardState.pending, guardState.requires_login, route.path, securityState.config?.login_route]); + // If no route is active, render fallback or null if (!route.component) { - console.log('[Router.SelectedComponent] No route component, rendering fallback or null'); - return fallback ? : null; - } - - if (guardState.evaluating) { + routerDebug('[Router.SelectedComponent] No route component, rendering fallback or null'); + if (fallback) { + const Fallback = fallback; + return ; + } return null; } - if (guardState.pending) { - return null; + if (guardState.evaluating || guardState.pending) { + return ( + + + + ); } if (!guardState.allowed) { if (guardState.requires_login) { - return ; + return null; } return ( @@ -490,9 +518,6 @@ function SelectedComponent({ placement, fallback }) { // Note: For fragment routes, this will be the parent component // The parent component can use useRoute() to get the fragment_path to know which fragment is active const RouteComponent = route.component; - if (lastLoggedRouteRef.current === routeSignature) { - console.log('[Router.SelectedComponent] Rendering component:', getComponentLabel(RouteComponent), route.fragment_path ? `(fragment path: ${route.fragment_path})` : ''); - } return ( { const match = findRoute(navigationState.currentPath); if (!match) { - console.log('[Router] getSelectedRoute() - No match for path:', navigationState.currentPath); - console.log('[Router] Available routes:', Array.from(routesRef.current.keys())); + routerDebug('[Router] getSelectedRoute() - No match for path:', navigationState.currentPath); + routerDebug('[Router] Available routes:', Array.from(routesRef.current.keys())); return null; } @@ -654,10 +679,10 @@ export function Router({ initialPath = '/', children, onRouteChange }) { if (parentRoute) { // Use parent component for rendering, but keep original path componentToRender = parentRoute.component; - console.log('[Router] Fragment route detected:', navigationState.currentPath, '→ Using parent:', parentRoute.path); + routerDebug('[Router] Fragment route detected:', navigationState.currentPath, '→ Using parent:', parentRoute.path); } else { // No parent found, fallback to fragment route itself - console.log('[Router] Fragment route with no parent, using fragment component:', navigationState.currentPath); + routerDebug('[Router] Fragment route with no parent, using fragment component:', navigationState.currentPath); componentToRender = match.component; } } @@ -671,7 +696,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) { is_fragment: match.is_fragment || false, fragment_path: match.is_fragment ? navigationState.currentPath : null // Store fragment path if applicable }; - console.log('[Router] getSelectedRoute() - Returning:', route.path, '→', getComponentLabel(route.component), match.is_fragment ? '(fragment)' : ''); + routerDebug('[Router] getSelectedRoute() - Returning:', route.path, '→', getComponentLabel(route.component), match.is_fragment ? '(fragment)' : ''); return route; }, [navigationState.currentPath, navigationState.routeState, findRoute, findParentRoute]); @@ -679,8 +704,8 @@ export function Router({ initialPath = '/', children, onRouteChange }) { const navigate = useCallback(async (path, options = {}) => { const { replace = false, state = null } = options; - console.log('[Router] navigate() called with path:', path); - console.log('[Router] Available routes:', Array.from(routesRef.current.keys())); + routerDebug('[Router] navigate() called with path:', path); + routerDebug('[Router] Available routes:', Array.from(routesRef.current.keys())); // Find matching route const match = findRoute(path); @@ -690,7 +715,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) { return; } - console.log('[Router] Route matched:', path, '→', getComponentLabel(match.component)); + routerDebug('[Router] Route matched:', path, '→', getComponentLabel(match.component)); // Update browser URL and save to storage (via compat layer) await setRouterPath(path, replace, { notify: false, state }); @@ -709,7 +734,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) { }; }); - console.log('[Router] Navigation complete. Current path:', path); + routerDebug('[Router] Navigation complete. Current path:', path); // Call onRouteChange callback if (onRouteChangeRef.current) { @@ -841,7 +866,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) { // Set up goToPage handler InvokeHandlers.goToPage = (menuItem, eventSource, event) => { if (menuItem.invoke_target) { - console.log('[Router] Navigating to:', menuItem.invoke_target); + routerDebug('[Router] Navigating to:', menuItem.invoke_target); navigate(menuItem.invoke_target); } else { console.warn('[Router] MenuItem missing invoke_target for goToPage'); @@ -861,14 +886,14 @@ export function Router({ initialPath = '/', children, onRouteChange }) { // Set up goToModal handler (placeholder - to be implemented) InvokeHandlers.goToModal = (menuItem, eventSource, event) => { if (menuItem.invoke_target) { - console.log('[Router] Modal not yet implemented:', menuItem.invoke_target); + routerDebug('[Router] Modal not yet implemented:', menuItem.invoke_target); // TODO: Implement modal system } else { console.warn('[Router] MenuItem missing invoke_target for goToModal'); } }; - console.log('[Router] InvokeHandlers configured'); + routerDebug('[Router] InvokeHandlers configured'); }, [navigate]); // Load initial path from browser URL or storage @@ -877,7 +902,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) { if (navigationState.initialPathLoaded) return; // Only load once const actualInitialPath = await getRouterPath(initialPath); - console.log('[Router] Initial path resolved:', actualInitialPath, '(fallback:', initialPath, ')'); + routerDebug('[Router] Initial path resolved:', actualInitialPath, '(fallback:', initialPath, ')'); setNavigationState({ initialPathLoaded: true, @@ -896,7 +921,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) { if (!navigationState.initialPathLoaded) return; // Wait for initial path to load const unsubscribe = subscribeToPathChanges((path, state) => { - console.log('[Router] Browser URL changed (popstate):', path); + routerDebug('[Router] Browser URL changed (popstate):', path); // Always adopt the browser path first. Route registration can lag // behind URL changes during boot or auth redirects. @@ -965,7 +990,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) { ...routeData // Preserve additional properties like is_fragment }); if (isNewRoute) { - console.log('[Router] Registered route:', route.path, '→', getComponentLabel(route.component), route.is_fragment ? '(fragment)' : ''); + routerDebug('[Router] Registered route:', route.path, '→', getComponentLabel(route.component), route.is_fragment ? '(fragment)' : ''); newRoutesCount++; } } @@ -973,7 +998,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) { // Log registered routes for debugging (only if new routes were added) if (newRoutesCount > 0) { - console.log('[Router] Registered routes:', Array.from(routesRef.current.keys())); + routerDebug('[Router] Registered routes:', Array.from(routesRef.current.keys())); } // Trigger re-render by updating routesVersion so findRoute gets recreated @@ -999,7 +1024,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) { console.warn('[Router] Available routes:', Array.from(routes.keys())); // Fall back to initialPath if current path doesn't match if (navigationState.currentPath !== initialPath) { - console.log('[Router] Falling back to initialPath:', initialPath); + routerDebug('[Router] Falling back to initialPath:', initialPath); setNavigationState({ initialPathLoaded: true, history: [{ path: initialPath, state: null, timestamp: Date.now() }], diff --git a/src/ui/components/Shell.jsx b/src/ui/components/Shell.jsx index 52d5438..8a60c57 100644 --- a/src/ui/components/Shell.jsx +++ b/src/ui/components/Shell.jsx @@ -331,6 +331,9 @@ class ToastManager { // Set new timeout with remaining time const timeoutId = setTimeout(() => { + if (toast.persistToNotifications) { + notificationCenterManager.addFromToast(toast); + } this.hide(id); this._timeouts.delete(id); }, remaining); @@ -1035,6 +1038,10 @@ function NetworkActivityOverlay({ visible = false }) { return ( getMenuVersion()); - - useEffect(() => { - const unsubscribe = subscribeToMenuChanges((newVersion) => { - setVersion(newVersion); - }); - - return () => { - unsubscribe(); - }; - }, []); - - return version; -} +import { useIsBelowSmBreakpoint } from '../hooks/useShellLayout.js'; +import { useMenuVersion } from '../hooks/useMenuVersion.js'; +import { useShellBrandConfig } from '../hooks/useShellBrandConfig.js'; +import { useEscapeDismiss } from '../hooks/useEscapeDismiss.js'; /** * Shared logic for organizing children and menu items @@ -135,19 +120,7 @@ function SideBarWide({ collapsedWidth = 80, secondaryStyle = 'inline' }) { - const [brandLogo, setBrandLogo] = useState(null); - const [appName, setAppName] = useState(null); - - useEffect(() => { - async function loadConfig() { - const logo = await getConfig(CONFIG_KEYS.BRAND_LOGO, null); - const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, null); - setBrandLogo(logo); - setAppName(name); - } - loadConfig(); - }, []); - + const { brandLogo, appName } = useShellBrandConfig(); const organizedChildren = useSideBarContent(children); const shell = useShell(); @@ -255,33 +228,14 @@ function SideBarWide({ )} - {/* Toggle Button (chevron) - matches MenuItemButton chevron style */} - - {(() => { - const ChevronIcon = getIcon(isCollapsed ? 'chevrons-right' : 'chevrons-left'); - if (!ChevronIcon) return null; - return ( - - ); - })()} - + /> {/* Middle Side - Primary menu items and other content */} @@ -359,22 +313,12 @@ function SideBarWide({ * Hamburger menu button + Sheet for menu items */ function SideBarNarrow({ children }) { - const [brandLogo, setBrandLogo] = useState(null); - const [appName, setAppName] = useState(null); + const { brandLogo, appName } = useShellBrandConfig(); const [menuOpen, setMenuOpen] = useState(false); - - useEffect(() => { - async function loadConfig() { - const logo = await getConfig(CONFIG_KEYS.BRAND_LOGO, null); - const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, null); - setBrandLogo(logo); - setAppName(name); - } - loadConfig(); - }, []); - const organizedChildren = useSideBarContent(children); + useEscapeDismiss(menuOpen, () => setMenuOpen(false)); + return ( <> setMenuOpen(true)} /> @@ -438,8 +384,11 @@ function SideBarNarrow({ children }) { > - - + + + + Navigation + {/* Primary Menu Items */} {organizedChildren.primaryMenuItems.map((item) => ( { if (open) { setMounted(true); @@ -51,7 +55,7 @@ export function SidePanelShell({ const CloseIcon = getIcon('close'); - return ( + const panel = ( - + {title} @@ -143,6 +150,12 @@ export function SidePanelShell({ ); + + if (typeof document === 'undefined' || !document.body) { + return panel; + } + + return createPortal(panel, document.body); } export default SidePanelShell; diff --git a/src/ui/components/TopBar.jsx b/src/ui/components/TopBar.jsx index 62f5007..e554855 100644 --- a/src/ui/components/TopBar.jsx +++ b/src/ui/components/TopBar.jsx @@ -6,34 +6,18 @@ */ import React, { useMemo, useState, useEffect } from 'react'; -import { XStack, YStack, Text, Image, Sheet, Button, useMedia } from 'tamagui'; +import { XStack, YStack, Text, Image, Sheet, Button } from 'tamagui'; import { View } from '@tamagui/core'; -import { getConfig, setConfig, CONFIG_KEYS } from '../../platform/env.js'; -import { getRootItem, subscribeToMenuChanges, getMenuVersion, MenuItem } from '../../platform/menu.js'; +import { getRootItem, MenuItem } from '../../platform/menu.js'; import { MenuItemButton } from './MenuItemButton.jsx'; import { PersonalMenuItem } from './PersonalMenuItem.jsx'; import { getIcon } from './IconMapper.jsx'; +import { useIsBelowSmBreakpoint } from '../hooks/useShellLayout.js'; +import { useMenuVersion } from '../hooks/useMenuVersion.js'; +import { useShellBrandConfig } from '../hooks/useShellBrandConfig.js'; +import { useEscapeDismiss } from '../hooks/useEscapeDismiss.js'; import { securityService, useSecurityState } from '../../security/runtime/security-service.js'; -/** - * Hook to track menu changes and force re-render - */ -function useMenuVersion() { - const [version, setVersion] = useState(() => getMenuVersion()); - - useEffect(() => { - const unsubscribe = subscribeToMenuChanges((newVersion) => { - setVersion(newVersion); - }); - - return () => { - unsubscribe(); - }; - }, []); - - return version; -} - /** * Shared logic for organizing children and menu items * Used by both Wide and Narrow variants @@ -169,19 +153,7 @@ function TopBarWide({ middleSideWidth = 0, rightSideWidth = 0 }) { - const [brandLogo, setBrandLogo] = useState(null); - const [appName, setAppName] = useState(null); - - useEffect(() => { - async function loadConfig() { - const logo = await getConfig(CONFIG_KEYS.BRAND_LOGO, null); - const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, null); - setBrandLogo(logo); - setAppName(name); - } - loadConfig(); - }, []); - + const { brandLogo, appName } = useShellBrandConfig(); const organizedChildren = useTopBarContent(children); const effectiveRightWidth = rightSideWidth > 0 ? rightSideWidth : (organizedChildren.hasRightSideContent ? 'auto' : 0); @@ -256,38 +228,7 @@ function TopBarWide({ borderLeftColor="$lineSubtle" style={{ flexShrink: 0 }} > - {organizedChildren.secondaryMenuItems.length > 0 && ( - - {organizedChildren.secondaryMenuItems.map((item) => ( - - ))} - - )} - - {organizedChildren.personalRoot && organizedChildren.personalRoot instanceof MenuItem && ( - 0 ? "$2" : undefined} - marginLeft={organizedChildren.secondaryMenuItems.length > 0 ? "$1" : undefined} - borderLeftWidth={organizedChildren.secondaryMenuItems.length > 0 ? 1 : 0} - borderLeftColor="$lineSubtle" - > - - - )} + {organizedChildren.sections.rightSide} )} @@ -299,22 +240,12 @@ function TopBarWide({ * Hamburger menu button + Sheet for menu items */ function TopBarNarrow({ children }) { - const [brandLogo, setBrandLogo] = useState(null); - const [appName, setAppName] = useState(null); + const { brandLogo, appName } = useShellBrandConfig(); const [menuOpen, setMenuOpen] = useState(false); - - useEffect(() => { - async function loadConfig() { - const logo = await getConfig(CONFIG_KEYS.BRAND_LOGO, null); - const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, null); - setBrandLogo(logo); - setAppName(name); - } - loadConfig(); - }, []); - const organizedChildren = useTopBarContent(children); + useEscapeDismiss(menuOpen, () => setMenuOpen(false)); + return ( <> setMenuOpen(true)} /> @@ -353,33 +286,10 @@ function TopBarNarrow({ children }) { )} - {/* Secondary Menu Items - render in topbar, left of personal menu */} - {organizedChildren.secondaryMenuItems.length > 0 && ( + {/* Custom right-side slots, secondary menu, and personal menu */} + {organizedChildren.sections.rightSide.length > 0 && ( - {organizedChildren.secondaryMenuItems.map((item) => ( - - ))} - - )} - - {/* Personal Menu (only on mobile) - render with horizontal orientation, right-aligned */} - {organizedChildren.personalRoot && organizedChildren.personalRoot instanceof MenuItem && ( - - + {organizedChildren.sections.rightSide} )} @@ -394,8 +304,11 @@ function TopBarNarrow({ children }) { > - - + + + + Navigation + {/* Primary Menu Items - render with vertical orientation in Sheet */} {organizedChildren.primaryMenuItems.map((item) => ( void) | null | undefined} onDismiss + */ +export function useEscapeDismiss(active, onDismiss) { + useEffect(() => { + if (!active || typeof onDismiss !== 'function') { + return undefined; + } + + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + onDismiss(); + } + }; + + return addDocumentEventListener('keydown', handleKeyDown); + }, [active, onDismiss]); +} diff --git a/src/ui/hooks/useMenuVersion.js b/src/ui/hooks/useMenuVersion.js new file mode 100644 index 0000000..61b9a29 --- /dev/null +++ b/src/ui/hooks/useMenuVersion.js @@ -0,0 +1,14 @@ +import { useEffect, useState } from 'react'; +import { getMenuVersion, subscribeToMenuChanges } from '../../platform/menu.js'; + +export function useMenuVersion() { + const [version, setVersion] = useState(() => getMenuVersion()); + + useEffect(() => { + return subscribeToMenuChanges((newVersion) => { + setVersion(newVersion); + }); + }, []); + + return version; +} diff --git a/src/ui/hooks/useShellBrandConfig.js b/src/ui/hooks/useShellBrandConfig.js new file mode 100644 index 0000000..b988e6e --- /dev/null +++ b/src/ui/hooks/useShellBrandConfig.js @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; +import { getConfig, CONFIG_KEYS } from '../../platform/env.js'; + +export function useShellBrandConfig() { + const [brandLogo, setBrandLogo] = useState(null); + const [appName, setAppName] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function loadConfig() { + const logo = await getConfig(CONFIG_KEYS.BRAND_LOGO, null); + const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, null); + if (!cancelled) { + setBrandLogo(logo); + setAppName(name); + } + } + + loadConfig(); + return () => { + cancelled = true; + }; + }, []); + + return { brandLogo, appName }; +} diff --git a/src/ui/hooks/useShellLayout.js b/src/ui/hooks/useShellLayout.js new file mode 100644 index 0000000..6af9e83 --- /dev/null +++ b/src/ui/hooks/useShellLayout.js @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; + +/** Matches Tamagui `gtSm` (wide shell above this width). */ +export const SHELL_SM_MAX_WIDTH = 800; +export const SHELL_BELOW_SM_MEDIA_QUERY = `(max-width: ${SHELL_SM_MAX_WIDTH}px)`; + +export function getIsBelowSmBreakpoint() { + if (typeof window === 'undefined') { + return false; + } + + return window.matchMedia(SHELL_BELOW_SM_MEDIA_QUERY).matches; +} + +/** + * Responsive shell layout without Tamagui `useMedia()`. + * Avoids setState-during-render warnings when the Tamagui config changes. + */ +export function useIsBelowSmBreakpoint() { + const [isBelowSm, setIsBelowSm] = useState(getIsBelowSmBreakpoint); + + useEffect(() => { + if (typeof window === 'undefined') { + return undefined; + } + + const mediaQuery = window.matchMedia(SHELL_BELOW_SM_MEDIA_QUERY); + const handleChange = (event) => { + setIsBelowSm(event.matches); + }; + + setIsBelowSm(mediaQuery.matches); + mediaQuery.addEventListener('change', handleChange); + return () => { + mediaQuery.removeEventListener('change', handleChange); + }; + }, []); + + return isBelowSm; +} + +export function useIsWideShellLayout() { + return !useIsBelowSmBreakpoint(); +} diff --git a/src/ui/styles/index.js b/src/ui/styles/index.js index 86ac0bf..40443f2 100644 --- a/src/ui/styles/index.js +++ b/src/ui/styles/index.js @@ -107,16 +107,25 @@ export const ICON_WEIGHTS = Object.freeze([ 'thin', 'light', 'regular', 'bold', 'fill', 'duotone', ]); -export const STYLE_THEMES = { - azure: AzureTheme, - 'fluent-flat': FluentFlatTheme, - apple: AppleTheme, - material: MaterialTheme, - minimal: MinimalTheme, - colorful: ColorfulTheme, -}; +export const DEFAULT_STYLE_THEME = 'fluent-flat'; -export const DEFAULT_STYLE_THEME = 'azure'; +const BUILTIN_STYLE_THEME_IDS = Object.freeze([ + 'azure', + 'fluent-flat', + 'apple', + 'material', + 'minimal', + 'colorful', +]); + +const styleThemeRegistry = new Map([ + ['azure', AzureTheme], + ['fluent-flat', FluentFlatTheme], + ['apple', AppleTheme], + ['material', MaterialTheme], + ['minimal', MinimalTheme], + ['colorful', ColorfulTheme], +]); export const TYPOGRAPHY_ROLE_KEYS = Object.freeze([ 'fieldLabel', @@ -161,6 +170,83 @@ const DEFAULT_TYPOGRAPHY_ROLES = Object.freeze({ let activeStyleThemeName = DEFAULT_STYLE_THEME; +function normalizeStyleThemeId(themeName = '') { + return String(themeName || '').trim().toLowerCase(); +} + +function coerceStyleThemePreset(presetOrFactory) { + if (typeof presetOrFactory === 'function') { + return presetOrFactory(); + } + + if (presetOrFactory && typeof presetOrFactory === 'object') { + return presetOrFactory; + } + + throw new Error('Style theme preset must be an object or factory function'); +} + +/** + * Register a style theme preset by id. Apps can extend the built-in catalog. + * @param {string} themeName + * @param {Object|Function} presetOrFactory - Tamagui preset object or factory + * @returns {Object} Registered preset + */ +export function registerStyleTheme(themeName, presetOrFactory) { + const normalizedName = normalizeStyleThemeId(themeName); + if (!normalizedName) { + throw new Error('Style theme name is required'); + } + + const preset = coerceStyleThemePreset(presetOrFactory); + const resolvedName = normalizeStyleThemeId(preset.name || normalizedName); + validateStyleTheme({ ...preset, name: resolvedName }); + styleThemeRegistry.set(resolvedName, preset); + return preset; +} + +/** + * Remove a registered style theme. Built-in presets cannot be unregistered. + * @param {string} themeName + * @returns {boolean} + */ +export function unregisterStyleTheme(themeName) { + const normalizedName = normalizeStyleThemeId(themeName); + if (!normalizedName) { + return false; + } + + if (BUILTIN_STYLE_THEME_IDS.includes(normalizedName)) { + console.warn(`[styles] Refusing to unregister built-in style theme "${normalizedName}"`); + return false; + } + + return styleThemeRegistry.delete(normalizedName); +} + +/** + * @param {string} themeName + * @returns {Object|null} + */ +export function getRegisteredStyleTheme(themeName) { + return styleThemeRegistry.get(normalizeStyleThemeId(themeName)) || null; +} + +/** + * @returns {string[]} + */ +export function listStyleThemes() { + return Array.from(styleThemeRegistry.keys()).sort(); +} + +/** + * Read-only snapshot of the current registry (for debugging and tooling). + * @returns {Record} + */ +export function getStyleThemesSnapshot() { + return Object.fromEntries(styleThemeRegistry); +} + /** * Validate that a preset implements the contract. * Logs a warning per missing key per variant; never throws. @@ -204,24 +290,29 @@ export function validateStyleTheme(preset) { * Map arbitrary input (storage, profile, UI) to a registered style theme id. * Case-insensitive; unknown values fall back to {@link DEFAULT_STYLE_THEME}. * @param {string} [name] - * @returns {keyof typeof STYLE_THEMES} + * @param {{ fallback?: string }} [options] + * @returns {string} */ -export function normalizeStyleThemeName(name) { - const key = typeof name === 'string' ? name.trim().toLowerCase() : ''; - if (key && STYLE_THEMES[key]) { +export function normalizeStyleThemeName(name, { fallback = DEFAULT_STYLE_THEME } = {}) { + const key = normalizeStyleThemeId(name); + if (key && styleThemeRegistry.has(key)) { return key; } + const resolvedFallback = normalizeStyleThemeId(fallback); + if (resolvedFallback && styleThemeRegistry.has(resolvedFallback)) { + return resolvedFallback; + } return DEFAULT_STYLE_THEME; } /** * Get a style theme by name. - * @param {string} themeName - Theme name ('azure', 'fluent-flat', 'apple', 'material', 'minimal', 'colorful') + * @param {string} themeName * @returns {Object} Theme configuration */ export function getStyleTheme(themeName = DEFAULT_STYLE_THEME) { const key = normalizeStyleThemeName(themeName); - return STYLE_THEMES[key]; + return styleThemeRegistry.get(key) || styleThemeRegistry.get(DEFAULT_STYLE_THEME); } export function setActiveStyleThemeName(themeName) { @@ -257,7 +348,7 @@ export function getTypographyRoleProps(role, overrides = null, themeName = activ * @returns {string[]} Array of theme names */ export function getStyleThemeNames() { - return Object.keys(STYLE_THEMES); + return listStyleThemes(); } /** diff --git a/src/ui/styles/main.css b/src/ui/styles/main.css index d30c947..a11112c 100644 --- a/src/ui/styles/main.css +++ b/src/ui/styles/main.css @@ -9,8 +9,6 @@ body { font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; line-height: 1.6; - color: #333; - background-color: #fff; } #app { diff --git a/test/api-filters.test.js b/test/api-filters.test.js new file mode 100644 index 0000000..70e9894 --- /dev/null +++ b/test/api-filters.test.js @@ -0,0 +1,120 @@ +import { beforeEach, describe, test } from 'node:test'; +import assert from 'node:assert'; +import { + applyRequestAuthorization, + createAPIFilterRegistry, + normalizeRequestAuthorization +} from '../src/platform/api-filters.js'; +import { + SECURITY_REQUEST_FILTER, + createSecurityRequestFilter +} from '../src/security/runtime/api-auth.js'; + +describe('api-filters', () => { + /** @type {ReturnType} */ + let registry; + + beforeEach(() => { + registry = createAPIFilterRegistry(); + }); + + test('normalizeRequestAuthorization supports string, scheme/token, and custom headers', () => { + assert.deepStrictEqual( + normalizeRequestAuthorization('Bearer abc'), + { name: 'Authorization', value: 'Bearer abc' } + ); + assert.deepStrictEqual( + normalizeRequestAuthorization({ scheme: 'Basic', token: 'dXNlcjpwYXNz' }), + { name: 'Authorization', value: 'Basic dXNlcjpwYXNz' } + ); + assert.deepStrictEqual( + normalizeRequestAuthorization({ name: 'X-Api-Key', value: 'secret' }), + { name: 'X-Api-Key', value: 'secret' } + ); + }); + + test('applyRequestFilters runs in priority order and supports async filters', async () => { + const calls = []; + + registry.registerRequestFilter('second', async (ctx) => { + calls.push('second'); + return { + ...ctx, + headers: applyRequestAuthorization(ctx.headers, { name: 'X-Second', value: '2' }) + }; + }, { priority: 20 }); + + registry.registerRequestFilter('first', async (ctx) => { + calls.push('first'); + return { + ...ctx, + headers: applyRequestAuthorization(ctx.headers, { name: 'X-First', value: '1' }) + }; + }, { priority: 10 }); + + const result = await registry.applyRequestFilters({ + url: '/api/items', + endpoint: '/items', + headers: new Headers() + }); + + assert.deepStrictEqual(calls, ['first', 'second']); + assert.strictEqual(result.headers.get('X-First'), '1'); + assert.strictEqual(result.headers.get('X-Second'), '2'); + }); + + test('skipRequestFilters can skip named filters', async () => { + registry.registerRequestFilter('tenant', (ctx) => ({ + ...ctx, + headers: applyRequestAuthorization(ctx.headers, { name: 'X-Tenant', value: 'acme' }) + }), { priority: 10 }); + + registry.registerRequestFilter('auth', (ctx) => ({ + ...ctx, + headers: applyRequestAuthorization(ctx.headers, { scheme: 'Bearer', token: 'token' }) + }), { priority: 100 }); + + const result = await registry.applyRequestFilters({ + url: '/api/login', + endpoint: '/login', + headers: new Headers(), + skipRequestFilters: ['auth'] + }); + + assert.strictEqual(result.headers.get('X-Tenant'), 'acme'); + assert.strictEqual(result.headers.get('Authorization'), null); + }); + + test('security request filter delegates authorization to the active policy', async () => { + const securityService = { + state: { + enabled: true, + provider: 'basic', + isAuthenticated: true, + session: { jwt_token: 'ignored-if-policy-returns' }, + user: { id: 'user-1' }, + profile: null, + realm: null, + config: {}, + policy: { + async getRequestAuthorization() { + return { name: 'X-Api-Key', value: 'policy-key' }; + } + } + } + }; + + const result = await createSecurityRequestFilter(securityService)({ + url: '/api/items', + endpoint: '/items', + headers: new Headers() + }); + + assert.strictEqual(result.headers.get('X-Api-Key'), 'policy-key'); + assert.strictEqual(result.headers.get('Authorization'), null); + }); + + test('installable security filter id is stable', () => { + assert.strictEqual(SECURITY_REQUEST_FILTER, 'security.auth'); + }); +}); diff --git a/test/api-security-policy.test.js b/test/api-security-policy.test.js new file mode 100644 index 0000000..90f4556 --- /dev/null +++ b/test/api-security-policy.test.js @@ -0,0 +1,52 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert'; +import { ApiSecurityPolicy } from '../src/security/policy/ApiSecurityPolicy.js'; +import { Permit, Session, User } from '../src/security/model/index.js'; +import { SECURITY_RIGHTS } from '../src/security/model/rights.js'; + +describe('ApiSecurityPolicy permit evaluation', () => { + test('evaluateSync matches permits by principal, not path alone', () => { + const policy = new ApiSecurityPolicy({}); + policy.cache.user = new User({ id: 'user-1', role_ids: ['role-a'] }); + policy.cache.session = new Session({ jwt_token: 'jwt-test' }); + + policy.cache.permits = [ + new Permit({ + principal_type: 'user', + principal_id: 'other-user', + resource_path: '/app', + effect: 'allow', + rights: SECURITY_RIGHTS.read + }) + ]; + + const denied = policy.evaluateSync('user-1', 'read', '/app'); + assert.strictEqual(denied.allowed, false); + + policy.cache.permits = [ + new Permit({ + principal_type: 'user', + principal_id: 'user-1', + resource_path: '/app', + effect: 'allow', + rights: SECURITY_RIGHTS.read + }) + ]; + + const allowed = policy.evaluateSync('user-1', 'read', '/app'); + assert.strictEqual(allowed.allowed, true); + + policy.cache.permits = [ + new Permit({ + principal_type: 'role', + principal_id: 'role-a', + resource_path: '/app', + effect: 'allow', + rights: SECURITY_RIGHTS.read + }) + ]; + + const roleAllowed = policy.evaluateSync('user-1', 'read', '/app'); + assert.strictEqual(roleAllowed.allowed, true); + }); +}); diff --git a/test/env.test.js b/test/env.test.js index 1baa178..4c03640 100644 --- a/test/env.test.js +++ b/test/env.test.js @@ -7,13 +7,17 @@ import { test, describe, beforeEach } from 'node:test'; import assert from 'node:assert'; import { initEnv, - getConfig, + getConfig, + getConfigSync, setConfig, getConfigDict, isDevelopment, isProduction, + isServiceWorkerEnabledSync, + resolveServiceWorkerEnabled, CONFIG_KEYS } from '../src/platform/env.js'; +import { getProvider } from '../src/platform/storage.js'; describe('env.js', () => { beforeEach(() => { @@ -90,6 +94,34 @@ describe('env.js', () => { // May return null or undefined depending on import.meta.env availability assert.ok(value === null || value === undefined); }); + + test('locked keys ignore persisted storage overrides', async () => { + initEnv({ + name: 'TestApp', + api: { base_url: '/api/profile' }, + modules: ['rt', 'game'] + }); + + const configStorage = getProvider('kv', 'config'); + await configStorage.set(CONFIG_KEYS.API_BASE_URL, '/api/stale'); + await configStorage.set(CONFIG_KEYS.MODULES, ['stale']); + + assert.strictEqual(await getConfig(CONFIG_KEYS.API_BASE_URL), '/api/profile'); + assert.deepStrictEqual(await getConfig(CONFIG_KEYS.MODULES), ['rt', 'game']); + }); + }); + + describe('getConfigSync', () => { + test('should return in-memory config without storage reads', () => { + initEnv({ + name: 'SyncApp', + api: { base_url: '/api/sync' } + }); + + assert.strictEqual(getConfigSync(CONFIG_KEYS.APP_NAME), 'SyncApp'); + assert.strictEqual(getConfigSync(CONFIG_KEYS.API_BASE_URL), '/api/sync'); + assert.strictEqual(getConfigSync('MISSING', 'fallback'), 'fallback'); + }); }); describe('setConfig', () => { @@ -148,6 +180,24 @@ describe('env.js', () => { assert.ok('STORAGE_BACKEND' in CONFIG_KEYS); assert.ok('API_BASE_URL' in CONFIG_KEYS); assert.ok('MODULES' in CONFIG_KEYS); + assert.ok('SERVICE_WORKER_ENABLED' in CONFIG_KEYS); + }); + }); + + describe('service worker profile flag', () => { + test('resolveServiceWorkerEnabled defaults to true', () => { + assert.strictEqual(resolveServiceWorkerEnabled({}), true); + }); + + test('resolveServiceWorkerEnabled reads service_worker.enabled', () => { + assert.strictEqual(resolveServiceWorkerEnabled({ service_worker: { enabled: false } }), false); + assert.strictEqual(resolveServiceWorkerEnabled({ pwa: { service_worker: { enabled: false } } }), false); + }); + + test('initEnv seeds SERVICE_WORKER_ENABLED into config', () => { + initEnv({ service_worker: { enabled: false } }); + assert.strictEqual(isServiceWorkerEnabledSync(), false); + assert.strictEqual(getConfigDict()[CONFIG_KEYS.SERVICE_WORKER_ENABLED], false); }); }); diff --git a/test/security-policy.test.js b/test/security-policy.test.js new file mode 100644 index 0000000..33d8775 --- /dev/null +++ b/test/security-policy.test.js @@ -0,0 +1,19 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert'; +import { SecurityPolicy } from '../src/security/policy/SecurityPolicy.js'; + +describe('SecurityPolicy defaults', () => { + test('evaluate fails closed when not implemented by a concrete policy', async () => { + const policy = new SecurityPolicy(); + const result = await policy.evaluate('user-1', 'read', '/app'); + assert.strictEqual(result.allowed, false); + assert.strictEqual(result.requires_login, true); + }); + + test('evaluateSync fails closed when not implemented by a concrete policy', () => { + const policy = new SecurityPolicy(); + const result = policy.evaluateSync('user-1', 'read', '/app'); + assert.strictEqual(result.allowed, false); + assert.strictEqual(result.requires_login, true); + }); +}); diff --git a/test/session-utils.test.js b/test/session-utils.test.js new file mode 100644 index 0000000..53cec06 --- /dev/null +++ b/test/session-utils.test.js @@ -0,0 +1,22 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert'; +import { isSessionActive } from '../src/security/runtime/session-utils.js'; + +describe('session-utils', () => { + test('isSessionActive returns false for missing or expired sessions', () => { + assert.strictEqual(isSessionActive(null), false); + assert.strictEqual(isSessionActive({ status: 'expired' }), false); + assert.strictEqual(isSessionActive({ + status: 'active', + expires_on: new Date(Date.now() - 60_000).toISOString() + }), false); + }); + + test('isSessionActive returns true for active unexpired sessions', () => { + assert.strictEqual(isSessionActive({ status: 'active' }), true); + assert.strictEqual(isSessionActive({ + status: 'active', + expires_on: new Date(Date.now() + 60_000).toISOString() + }), true); + }); +}); diff --git a/test/storage.test.js b/test/storage.test.js new file mode 100644 index 0000000..71fa7ea --- /dev/null +++ b/test/storage.test.js @@ -0,0 +1,50 @@ +import { beforeEach, describe, test } from 'node:test'; +import assert from 'node:assert'; +import { KeyValueStore, KV_KEY_PREFIX } from '../src/platform/storage.js'; + +describe('KeyValueStore namespacing', () => { + beforeEach(() => { + localStorage.clear(); + }); + + test('isolates values by provider name', async () => { + const configStore = new KeyValueStore('config', 'localStorage'); + const securityStore = new KeyValueStore('security.basic', 'localStorage'); + + await configStore.set('theme.mode', 'dark'); + await securityStore.set('security.basic.session', { user_id: 'u1' }); + + assert.strictEqual(await configStore.get('theme.mode'), 'dark'); + assert.deepStrictEqual(await securityStore.get('security.basic.session'), { user_id: 'u1' }); + assert.strictEqual(await configStore.get('security.basic.session', null), null); + + assert.strictEqual( + localStorage.getItem(`${KV_KEY_PREFIX}config:theme.mode`), + JSON.stringify('dark') + ); + assert.strictEqual( + localStorage.getItem(`${KV_KEY_PREFIX}security.basic:security.basic.session`), + JSON.stringify({ user_id: 'u1' }) + ); + }); + + test('clear removes only keys owned by the provider', async () => { + const configStore = new KeyValueStore('config', 'localStorage'); + const securityStore = new KeyValueStore('security.basic', 'localStorage'); + + await configStore.set('theme.mode', 'dark'); + await securityStore.set('security.basic.session', { user_id: 'u1' }); + + await configStore.clear(); + + assert.strictEqual(await configStore.get('theme.mode', null), null); + assert.deepStrictEqual(await securityStore.get('security.basic.session'), { user_id: 'u1' }); + }); + + test('config provider reads legacy flat keys as fallback', async () => { + localStorage.setItem('theme.mode', JSON.stringify('light')); + + const configStore = new KeyValueStore('config', 'localStorage'); + assert.strictEqual(await configStore.get('theme.mode'), 'light'); + }); +}); diff --git a/test/worker.test.js b/test/worker.test.js new file mode 100644 index 0000000..15222a7 --- /dev/null +++ b/test/worker.test.js @@ -0,0 +1,16 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert'; +import { PWA_CACHE_SCOPE } from '../src/platform/worker.js'; + +describe('worker PWA cache scope', () => { + test('PWA_CACHE_SCOPE exposes composable bit flags', () => { + assert.strictEqual(PWA_CACHE_SCOPE.SERVICE_WORKERS, 1); + assert.strictEqual(PWA_CACHE_SCOPE.CACHES, 2); + assert.strictEqual(PWA_CACHE_SCOPE.STORAGE, 4); + assert.strictEqual(PWA_CACHE_SCOPE.ALL, 7); + assert.strictEqual( + PWA_CACHE_SCOPE.CACHES | PWA_CACHE_SCOPE.SERVICE_WORKERS, + 3 + ); + }); +}); diff --git a/vite.config.js b/vite.config.js index a3c128d..4956fe7 100644 --- a/vite.config.js +++ b/vite.config.js @@ -11,6 +11,7 @@ function isExternal(id) { if (id === 'react' || id === 'react/jsx-runtime' || id === 'react-dom' || id === 'react-dom/client') { return true; } + if (id === '@phosphor-icons/react' || id.startsWith('@phosphor-icons/react/')) return true; if (id === 'tamagui' || id.startsWith('tamagui/')) return true; if (id.startsWith('@tamagui/')) return true; return false; @@ -28,7 +29,12 @@ export default defineConfig({ ], build: { lib: { - entry: path.resolve(__dirname, 'src/index.js'), + entry: { + index: path.resolve(__dirname, 'src/index.js'), + 'ui/components/index': path.resolve(__dirname, 'src/ui/components/index.js'), + 'ui/route-loading': path.resolve(__dirname, 'src/ui/route-loading.js'), + 'ui/pages/SettingsPage': path.resolve(__dirname, 'src/ui/pages/SettingsPage.jsx') + }, formats: ['es'] }, rollupOptions: {