import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Bell, Receipt, type LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
import { InstallAppButton } from './InstallAppButton';
import { SwitchbarPopover } from './SwitchbarPopover';
import { useQuery, useQueries } from '@tanstack/react-query';
import { api, supportApi, pendingUploadsApi, type Permission } from '../api/client';
import { getIconByName } from './IconPicker';
import { useIsSidebarCompact } from '../hooks/useIsSidebarCompact';
import { useColorCatalogVersion } from '../hooks/useColorCatalogVersion';
import { useSponsorPrompt } from '../hooks/useSponsorPrompt';
import { useUnknownTagPrompt } from '../hooks/useUnknownTagPrompt';
import { UnknownSpoolModal } from './UnknownSpoolModal';
import { ConfirmOutcomeDialog } from './ConfirmOutcomeDialog';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
import { Card, CardHeader, CardContent } from './Card';
import { parseUTCDate } from '../utils/date';
import { Button } from './Button';
import { BugReportBubble } from './BugReportBubble';
import {
getHiddenSidebarSystemItemIds,
getSidebarOrder,
isExternalSidebarItemId,
saveHiddenSidebarSystemItemIds,
saveSidebarOrder,
SIDEBAR_LAYOUT_CHANGED_EVENT,
} from '../utils/sidebarLayout';
interface NavItem {
id: string;
to: string;
icon: LucideIcon;
labelKey: string; // Translation key
}
export const defaultNavItems: NavItem[] = [
{ id: 'printers', to: '/', icon: Printer, labelKey: 'nav.printers' },
{ id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },
{ id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
{ id: 'queue', to: '/queue', icon: ListOrdered, labelKey: 'nav.queue' },
{ id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
{ id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
{ id: 'makerworld', to: '/makerworld', icon: Globe, labelKey: 'nav.makerworld' },
{ id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
{ id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
{ id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },
// Opt-in feature: gated in isHidden() on the billing_enabled setting, so the
// entry stays out of the sidebar entirely until an admin turns billing on.
{ id: 'finance', to: '/finance', icon: Receipt, labelKey: 'nav.finance' },
// User-account feature: gated in isHidden() on advanced auth + user_notifications
// + the notifications:user_email permission. Kept adjacent to Settings
// intentionally. Do not drop this entry — without it the /notifications page
// is orphaned (route + page still exist but no nav link) (#1901).
{ id: 'notifications', to: '/notifications', icon: Bell, labelKey: 'nav.notifications' },
{ id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
];
// Get default view from localStorage
export function getDefaultView(): string {
return localStorage.getItem('defaultView') || '/';
}
// Save default view to localStorage
export function setDefaultView(path: string) {
localStorage.setItem('defaultView', path);
}
export function Layout() {
const navigate = useNavigate();
const location = useLocation();
const { mode, resolvedMode, toggleMode } = useTheme();
const { t } = useTranslation();
const isSidebarCompact = useIsSidebarCompact();
// Bug-report panel state lives here because the trigger moves (#2750,
// reporter @goodjaltman). Below the sidebar-compact breakpoint the floating
// disc is replaced by a button in the compact header: the bottom-right corner
// is the most contended region in the app — the Profiles scroll-to-top FAB,
// the floating camera window and its resize handle, the Group Edit save bar,
// the bulk-selection toolbars, and the File Manager / Archives per-card
// action buttons all live there — and a fixed 48px disc sits on top of
// whichever of them happens to be underneath. Moving out of the corner is
// the only fix that covers in-flow content as well as fixed overlays.
const [bugReportOpen, setBugReportOpen] = useState(false);
// A bug-report logging run survives the panel being closed (#2847). The
// floating disc shows that itself; the compact header's button and the
// debug-logging banner need telling.
const [bugReportLogging, setBugReportLogging] = useState(false);
// Theme toggle: mode → icon and tooltip
const ThemeIcon = { dark: Sun, light: Monitor, system: Moon }[mode];
const themeSwitchTitle = t({ dark: 'nav.switchToLight', light: 'nav.switchToSystem', system: 'nav.switchToDark' }[mode]);
// Re-render Layout (and the page rendered inside ) whenever the
// backend color catalog is (re)populated, so pages that mounted before the
// catalog fetched — and cached HSL-fallback color names during their first
// render — refresh with the real catalog names. See #857.
useColorCatalogVersion();
const { user, authEnabled, logout, hasPermission, hasAnyPermission, loading: authLoading } = useAuth();
const { showToast } = useToast();
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
const [changePasswordData, setChangePasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
const [changePasswordLoading, setChangePasswordLoading] = useState(false);
const [sidebarExpanded, setSidebarExpanded] = useState(() => {
const stored = localStorage.getItem('sidebarExpanded');
return stored !== 'false';
});
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false);
const [showSwitchbar, setShowSwitchbar] = useState(false);
const defaultSidebarOrder = useMemo(() => defaultNavItems.map(i => i.id), []);
const [sidebarOrder, setSidebarOrder] = useState(() => getSidebarOrder(defaultNavItems.map(i => i.id)));
const [hiddenSystemItemIds, setHiddenSystemItemIds] = useState(getHiddenSidebarSystemItemIds);
const hasRedirected = useRef(false);
const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState(() =>
sessionStorage.getItem('dismissedUpdateVersion')
);
const [plateDetectionAlert, setPlateDetectionAlert] = useState<{
printer_id: number;
printer_name: string;
message: string;
} | null>(null);
// Post-print outcome confirmation (#1898): archive waiting for a verdict,
// set by the print_confirm_request WebSocket event.
const [confirmOutcomeArchiveId, setConfirmOutcomeArchiveId] = useState(null);
// Check for updates
const { data: versionInfo } = useQuery({
queryKey: ['version'],
queryFn: api.getVersion,
staleTime: Infinity,
});
// GET /settings requires settings:read, so for every non-admin this query
// 403'd and each of the four gates below silently took its fallback: Finance
// vanished from the sidebar for the users cost_centers:read_own exists for,
// a disabled user_notifications setting stopped applying to them, the sponsor
// prompt showed EUR whatever the install uses, and the update check ran where
// it had been switched off. Two of those were invisible to an administrator
// testing it, because an administrator can read /settings (#3023).
const { data: uiFlags } = useQuery({
queryKey: ['ui-flags'],
queryFn: api.getUiFlags,
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Sponsor-prompt toast — fires once per session post-auth if a milestone is eligible.
useSponsorPrompt(uiFlags?.currency ?? 'USD');
// Unknown-spool prompt — surfaces a confirmation modal when the AMS reports a
// tag with no inventory match (only when `auto_add_unknown_rfid` is off).
const unknownSpool = useUnknownTagPrompt();
// Fetch default sidebar order via a public endpoint (no settings:read needed)
const { data: defaultSidebarData } = useQuery({
queryKey: ['default-sidebar-order'],
queryFn: api.getDefaultSidebarOrder,
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Apply admin default sidebar order once per user (skipped if already applied).
// Uses a per-user localStorage flag to prevent re-application.
useEffect(() => {
const defaultOrder = defaultSidebarData?.default_sidebar_order;
if (!defaultOrder) return;
// Wait for auth state to settle before applying to avoid double-execution
if (authEnabled && !user) return;
const appliedKey = user ? `sidebarDefaultApplied_${user.id}` : 'sidebarDefaultApplied';
if (localStorage.getItem(appliedKey)) return;
try {
const parsed = JSON.parse(defaultOrder);
const orderArr = Array.isArray(parsed) ? parsed : parsed.order;
if (!Array.isArray(orderArr) || orderArr.length === 0) return;
// Filter to valid sidebar item IDs only
const validIds = new Set(defaultNavItems.map(i => i.id));
const filtered = orderArr.filter((id: string) => typeof id === 'string' && (validIds.has(id) || isExternalSidebarItemId(id)));
if (filtered.length > 0) {
setSidebarOrder(filtered);
saveSidebarOrder(filtered);
const hiddenIds = Array.isArray(parsed) ? [] : parsed.hiddenSystemItemIds;
if (Array.isArray(hiddenIds)) {
const filteredHiddenIds = hiddenIds.filter((id: string) => typeof id === 'string' && validIds.has(id) && id !== 'settings');
setHiddenSystemItemIds(filteredHiddenIds);
saveHiddenSidebarSystemItemIds(filteredHiddenIds);
}
localStorage.setItem(appliedKey, '1');
}
} catch (e) {
console.error('Failed to apply default sidebar order:', e);
}
}, [defaultSidebarData?.default_sidebar_order, setSidebarOrder, user, authEnabled]);
// Check advanced auth status — the notifications nav item is gated on it
// (rendered only when authEnabled && advanced_auth_enabled && user_notifications_enabled).
const { data: advancedAuthStatus } = useQuery({
queryKey: ['advancedAuthStatus'],
queryFn: api.getAdvancedAuthStatus,
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: authEnabled,
});
const { data: updateCheck } = useQuery({
queryKey: ['updateCheck'],
queryFn: api.checkForUpdates,
enabled: uiFlags?.check_updates !== false,
staleTime: 60 * 60 * 1000, // 1 hour
refetchInterval: 60 * 60 * 1000, // Check every hour
});
// Fetch external links for sidebar
const { data: externalLinks } = useQuery({
queryKey: ['external-links'],
queryFn: api.getExternalLinks,
});
// Fetch smart plugs to check for switchbar items
const { data: smartPlugs } = useQuery({
queryKey: ['smart-plugs'],
queryFn: api.getSmartPlugs,
staleTime: 30 * 1000, // 30 seconds
});
const hasSwitchbarPlugs = smartPlugs?.some(p => p.show_in_switchbar) ?? false;
// Check debug logging state
const { data: debugLoggingState } = useQuery({
queryKey: ['debugLogging'],
queryFn: supportApi.getDebugLoggingState,
staleTime: 60 * 1000, // 1 minute
refetchInterval: 60 * 1000, // Refresh every minute
});
// Check developer LAN mode warnings
const { data: devModeWarnings } = useQuery({
queryKey: ['developer-mode-warnings'],
queryFn: api.getDeveloperModeWarnings,
staleTime: 10 * 1000,
refetchInterval: 30 * 1000,
refetchOnWindowFocus: true,
});
// Fetch pending queue items count for badge
const { data: queueItems } = useQuery({
queryKey: ['queue', 'pending'],
queryFn: () => api.getQueue(undefined, 'pending'),
staleTime: 5 * 1000, // 5 seconds
refetchInterval: 5 * 1000, // Refresh every 5 seconds
refetchOnWindowFocus: true,
});
const pendingQueueCount = queueItems?.length ?? 0;
// Fetch pending uploads count for archive badge (virtual printer review items)
const { data: pendingUploadsData } = useQuery({
queryKey: ['pending-uploads', 'count'],
queryFn: pendingUploadsApi.getCount,
staleTime: 5 * 1000, // 5 seconds
refetchInterval: 5 * 1000, // Refresh every 5 seconds
refetchOnWindowFocus: true,
});
const pendingUploadsCount = pendingUploadsData?.count ?? 0;
// Check if any printer with pending queue items needs plate clearing
const queuePrinterIds = useMemo(() => {
const ids = new Set();
queueItems?.forEach(item => {
if (item.printer_id) ids.add(item.printer_id);
});
return Array.from(ids);
}, [queueItems]);
const printerStatusQueries = useQueries({
queries: queuePrinterIds.map(id => ({
queryKey: ['printerStatus', id],
queryFn: () => api.getPrinterStatus(id),
staleTime: 30 * 1000, // WebSocket keeps this warm
})),
});
const needsClearPlate = printerStatusQueries.some(result => {
const status = result.data;
if (!status) return false;
return !!status.awaiting_plate_clear;
});
// Calculate debug duration client-side for real-time updates
const [debugDuration, setDebugDuration] = useState(null);
useEffect(() => {
if (!debugLoggingState?.enabled || !debugLoggingState.enabled_at) {
setDebugDuration(null);
return;
}
const enabledAt = parseUTCDate(debugLoggingState.enabled_at)?.getTime() ?? Date.now();
const updateDuration = () => {
setDebugDuration(Math.floor((Date.now() - enabledAt) / 1000));
};
updateDuration();
const interval = setInterval(updateDuration, 1000);
return () => clearInterval(interval);
}, [debugLoggingState?.enabled, debugLoggingState?.enabled_at]);
// Build the unified sidebar items list - memoized to prevent re-renders
const navItemsMap = useMemo(() => new Map(defaultNavItems.map(item => [item.id, item])), []);
const extLinksMap = useMemo(() => new Map((externalLinks || []).map(link => [`ext-${link.id}`, link])), [externalLinks]);
// Compute the ordered sidebar: include stored order + any new items
// Hide nav items the user doesn't have read permission for
const orderedSidebarIds = (() => {
const result: string[] = [];
const seen = new Set();
// Map nav item IDs to the permission(s) required to see them. Resources
// that ship in three tiers (legacy `*:read` + granular `*:read_own` /
// `*:read_all`) list all three: the default Operators group is seeded
// with `_own` only, so gating on the legacy alone hides the entry from
// every non-admin user even though the underlying API accepts their
// request (#1755).
const navPermissions: Record = {
archives: ['archives:read', 'archives:read_own', 'archives:read_all'],
queue: ['queue:read', 'queue:read_own', 'queue:read_all'],
stats: 'stats:read',
profiles: 'kprofiles:read',
maintenance: 'maintenance:read',
projects: 'projects:read',
inventory: 'inventory:read',
finance: 'cost_centers:read_own',
files: ['library:read', 'library:read_own', 'library:read_all'],
makerworld: 'makerworld:view',
settings: 'settings:read',
// The user-email-preferences API requires notifications:user_email, so
// gate the nav item on the same permission (both default groups —
// Administrators and Operators — hold it). The advanced-auth /
// user_notifications enablement gate is applied separately below.
notifications: 'notifications:user_email',
};
const isHidden = (id: string) => {
// User-toggled hide (#1673) wins first — cheapest check, explicit intent.
if (hiddenSystemItemIds.includes(id)) return true;
// Permission gate accepts Permission | Permission[] so resources with
// granular `*:read_own` / `*:read_all` tiers (default Operators group)
// don't get hidden from users who only hold the granular variant (#1755).
if (authEnabled && id in navPermissions) {
const required = navPermissions[id];
const granted = Array.isArray(required)
? required.some((p) => hasPermission(p))
: hasPermission(required);
if (!granted) return true;
}
// notifications nav item also requires advanced auth to be enabled and user_notifications_enabled setting
if (id === 'notifications' && (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || (uiFlags?.user_notifications_enabled === false))) return true;
// Finance is off by default and the page is meaningless without it, so it
// stays hidden until billing is explicitly on. Tested for `true` rather
// than `!== false` on purpose: the flags are undefined on the first
// render, and a nav entry that appears and then vanishes reads as a
// glitch. That polarity is also why reading this from /settings hid the
// entry outright for anyone without settings:read, rather than failing
// open the way the notifications gate two lines up did (#3023).
if (id === 'finance' && uiFlags?.billing_enabled !== true) return true;
return false;
};
// Add items in stored order
for (const id of sidebarOrder) {
if (isHidden(id)) continue;
if (navItemsMap.has(id) || extLinksMap.has(id)) {
result.push(id);
seen.add(id);
}
}
// Add any new internal nav items not in stored order
for (const item of defaultNavItems) {
if (isHidden(item.id)) continue;
if (!seen.has(item.id)) {
result.push(item.id);
seen.add(item.id);
}
}
// Add any new external links not in stored order
for (const link of externalLinks || []) {
const extId = `ext-${link.id}`;
if (!seen.has(extId)) {
result.push(extId);
seen.add(extId);
}
}
return result;
})();
// Show update banner if update available and not dismissed for this version.
// Suppressed when running as a Home Assistant addon — HA Supervisor surfaces
// its own update notification in the HA UI, so the in-app banner is duplicate
// noise that links to a page that just says "update via HA."
const showUpdateBanner = updateCheck?.update_available &&
updateCheck.latest_version &&
updateCheck.latest_version !== dismissedUpdateVersion &&
!updateCheck.is_ha_addon;
const dismissUpdateBanner = () => {
if (updateCheck?.latest_version) {
sessionStorage.setItem('dismissedUpdateVersion', updateCheck.latest_version);
setDismissedUpdateVersion(updateCheck.latest_version);
}
};
// Redirect to default view on initial load
useEffect(() => {
if (!hasRedirected.current && location.pathname === '/') {
const defaultView = getDefaultView();
if (defaultView !== '/') {
hasRedirected.current = true;
navigate(defaultView, { replace: true });
}
}
}, [location.pathname, navigate]);
useEffect(() => {
localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
}, [sidebarExpanded]);
useEffect(() => {
const refreshSidebarLayout = () => {
setSidebarOrder(getSidebarOrder(defaultSidebarOrder));
setHiddenSystemItemIds(getHiddenSidebarSystemItemIds());
};
window.addEventListener(SIDEBAR_LAYOUT_CHANGED_EVENT, refreshSidebarLayout);
window.addEventListener('storage', refreshSidebarLayout);
return () => {
window.removeEventListener(SIDEBAR_LAYOUT_CHANGED_EVENT, refreshSidebarLayout);
window.removeEventListener('storage', refreshSidebarLayout);
};
}, [defaultSidebarOrder]);
// Close compact drawer on navigation
useEffect(() => {
if (isSidebarCompact) {
setMobileDrawerOpen(false);
}
}, [location.pathname, isSidebarCompact]);
// Listen for plate detection warnings (objects on plate, print paused)
// Only show to users with printers:control permission
useEffect(() => {
const handlePlateNotEmpty = (event: Event) => {
// Only show alert to users who can control printers
if (!hasPermission('printers:control')) {
return;
}
const detail = (event as CustomEvent).detail;
setPlateDetectionAlert({
printer_id: detail.printer_id,
printer_name: detail.printer_name,
message: detail.message,
});
};
window.addEventListener('plate-not-empty', handlePlateNotEmpty);
return () => window.removeEventListener('plate-not-empty', handlePlateNotEmpty);
}, [hasPermission]);
// A completed print asked for its outcome verdict (#1898). Same CustomEvent
// relay as plate-not-empty, and gated the same way: the PATCH route decides
// who may record a verdict, so a user who cannot should not be handed a
// dialog whose only outcome is a 403.
// Held until the auth state has landed: while it is loading, `authEnabled`
// is still false and every permission check answers yes, which would open
// the dialog for exactly the user this gate exists to spare.
const canConfirmOutcome = !authLoading && hasAnyPermission('archives:update_all', 'archives:update_own');
useEffect(() => {
const handleConfirmRequest = (event: Event) => {
const detail = (event as CustomEvent).detail;
if (typeof detail?.archive_id === 'number') {
setConfirmOutcomeArchiveId(detail.archive_id);
}
};
window.addEventListener('print-confirm-request', handleConfirmRequest);
return () => window.removeEventListener('print-confirm-request', handleConfirmRequest);
}, []);
// The ?confirm= deep link a push notification carries (#1898). It is read
// here and not on ArchivesPage because that page renders inside this Layout's
// : React flushes a child's effects before its parent's, so a page
// dispatching `print-confirm-request` on mount fired before the listener above
// existed — which is exactly the cold load a notification tap produces. The
// parameter is stripped afterwards so a reload does not re-open the dialog.
useEffect(() => {
const params = new URLSearchParams(location.search);
const confirmId = params.get('confirm');
if (!confirmId || !/^\d+$/.test(confirmId)) {
return;
}
setConfirmOutcomeArchiveId(Number(confirmId));
params.delete('confirm');
const rest = params.toString();
navigate({ pathname: location.pathname, search: rest ? `?${rest}` : '' }, { replace: true });
}, [location.pathname, location.search, navigate]);
// Global keyboard shortcuts for navigation
const handleKeyDown = useCallback((e: KeyboardEvent) => {
const target = e.target as HTMLElement;
// Ignore if typing in an input/textarea
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
// Number keys for navigation (1-9) - follows sidebar order including external links
if (!e.metaKey && !e.ctrlKey && !e.altKey) {
const keyNum = parseInt(e.key);
if (keyNum >= 1 && keyNum <= orderedSidebarIds.length && keyNum <= 9) {
const id = orderedSidebarIds[keyNum - 1];
e.preventDefault();
if (isExternalSidebarItemId(id)) {
// External link
const extLink = extLinksMap.get(id);
if (extLink?.open_in_new_tab) {
window.open(extLink.url, '_blank', 'noopener,noreferrer');
} else {
const linkId = id.replace('ext-', '');
navigate(`/external/${linkId}`);
}
} else {
// Internal nav item
const navItem = navItemsMap.get(id);
if (navItem) {
navigate(navItem.to);
}
}
return;
}
switch (e.key) {
case '?':
e.preventDefault();
setShowShortcuts(true);
break;
case 'Escape':
setShowShortcuts(false);
break;
}
}
}, [navigate, orderedSidebarIds, navItemsMap, extLinksMap]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
return (
{/* Compact Header */}
{isSidebarCompact && (
{/* Bug report — the compact-layout home of the floating bubble. */}
)}
{/* Compact Drawer Backdrop */}
{isSidebarCompact && mobileDrawerOpen && (
{t('support.debugLoggingActive', { defaultValue: 'Debug logging is active' })}
{debugDuration !== null && (
({Math.floor(debugDuration / 60)}m {debugDuration % 60}s)
)}
{/* A run started from the bug-report panel ends at that panel's
Stop & Submit button, so send the user back there rather than
to the System page's raw toggle, which would drop the logs
and the description they already wrote (#2847). */}
{bugReportLogging && (
)}
{t('printers.developerModeWarning', {
names: devModeWarnings.map(w => w.name).join(', '),
defaultValue: `Developer LAN mode is not enabled on: ${devModeWarnings.map(w => w.name).join(', ')}. Some features may not work.`
})}
{t('printers.howToEnable', { defaultValue: 'How to enable' })}
)}
{/* Post-print outcome confirmation (#1898) */}
{/* The dialog's only outcome for a user who may not record a verdict is
a 403 from the PATCH, so it is gated on the permissions that route
enforces — the names the June migration left in the default groups.
Gated on the render rather than on the two ways a request arrives:
one of them can land before /auth/me has answered, and a check made
then would drop it for everybody. */}
{canConfirmOutcome && confirmOutcomeArchiveId !== null && (
setConfirmOutcomeArchiveId(null)}
/>
)}
{/* Change Password Modal */}
{showChangePasswordModal && (
)}
{/* The panel always mounts here, at the Layout root. It must not move
into the header alongside its compact-layout trigger: the header is
`fixed z-40` and so its own stacking context, which would cap the
z-50 panel at the header's level and bury it under every ordinary
modal in the app. */}