diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx
index 3340101e..c73d63de 100644
--- a/frontend/src/components/EditorLayout.tsx
+++ b/frontend/src/components/EditorLayout.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
+import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { Button } from './ui/button';
import { Plus, Loader2, ChevronLeft } from 'lucide-react';
import { UserProfileDropdown } from './UserProfileDropdown';
@@ -52,6 +52,11 @@ import { deriveMobileSurface, type MobileView } from './EditorLayout/mobile-surf
import { BESPOKE_MOBILE_VIEWS } from './EditorLayout/mobile-treatments';
import type { SectionId } from './settings/types';
+// The Security page is heavy (charts, scan sheets) and already code-split on the
+// desktop content path, so the bespoke phone screen reuses the same lazy chunk
+// rather than pulling SecurityView into the main shell bundle.
+const SecurityView = lazy(() => import('./SecurityView').then(m => ({ default: m.SecurityView })));
+
export default function EditorLayout() {
const { isAdmin, can } = useAuth();
const { status: trivy } = useTrivyStatus();
@@ -760,6 +765,22 @@ export default function EditorLayout() {
return ;
case 'settings':
return ;
+ case 'security':
+ return (
+
+
+
+ )}
+ >
+
+
+ );
default:
return workspaceEl;
}
diff --git a/frontend/src/components/EditorLayout/mobile-treatments.test.ts b/frontend/src/components/EditorLayout/mobile-treatments.test.ts
index d40e0824..25d797a9 100644
--- a/frontend/src/components/EditorLayout/mobile-treatments.test.ts
+++ b/frontend/src/components/EditorLayout/mobile-treatments.test.ts
@@ -13,8 +13,8 @@ describe('mobile treatments', () => {
}
});
- it('treats the Security view as responsive (reflowed, not bespoke or desktop-only)', () => {
- expect(MOBILE_TREATMENTS.security).toBe('responsive');
+ it('treats the Security view as a bespoke masthead-led phone screen', () => {
+ expect(MOBILE_TREATMENTS.security).toBe('bespoke');
});
it('keeps BESPOKE_MOBILE_VIEWS in lockstep with the bespoke treatments', () => {
@@ -27,10 +27,12 @@ describe('mobile treatments', () => {
it('pins the set of bespoke phone screens (update deliberately when adding one)', () => {
// A change here means a top-level view gained or lost a bespoke phone screen.
- // Updating this list should go hand in hand with adding the screen under
- // components/mobile/ and wiring its case in EditorLayout's renderMobileBespoke.
+ // Updating this list should go hand in hand with wiring the screen's case in
+ // EditorLayout's renderMobileBespoke (a dedicated component under
+ // components/mobile/, or a masthead-led mobile branch of the desktop view as
+ // Security does).
expect([...BESPOKE_MOBILE_VIEWS].sort()).toEqual(
- ['dashboard', 'fleet', 'scheduled-ops', 'settings'],
+ ['dashboard', 'fleet', 'scheduled-ops', 'security', 'settings'],
);
});
});
diff --git a/frontend/src/components/EditorLayout/mobile-treatments.ts b/frontend/src/components/EditorLayout/mobile-treatments.ts
index 70dcee31..960a9ed7 100644
--- a/frontend/src/components/EditorLayout/mobile-treatments.ts
+++ b/frontend/src/components/EditorLayout/mobile-treatments.ts
@@ -22,7 +22,7 @@ export const MOBILE_TREATMENTS: Record = {
settings: 'bespoke',
editor: 'detail',
resources: 'responsive',
- security: 'responsive',
+ security: 'bespoke',
templates: 'responsive',
'global-observability': 'responsive',
'auto-updates': 'responsive',
diff --git a/frontend/src/components/MobileMoreMenu.test.tsx b/frontend/src/components/MobileMoreMenu.test.tsx
new file mode 100644
index 00000000..7879a431
--- /dev/null
+++ b/frontend/src/components/MobileMoreMenu.test.tsx
@@ -0,0 +1,49 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { Home, Radar, Boxes, ShieldCheck } from 'lucide-react';
+import { MobileMoreMenu } from './MobileMoreMenu';
+import type { NavItem } from './EditorLayout/hooks/useViewNavigationState';
+
+// Includes a bottom-tab primary (dashboard) and secondary destinations; the
+// menu must list every one so navigation is identical on every screen.
+const navItems: NavItem[] = [
+ { value: 'dashboard', label: 'Home', icon: Home },
+ { value: 'fleet', label: 'Fleet', icon: Radar },
+ { value: 'resources', label: 'Resources', icon: Boxes },
+ { value: 'security', label: 'Security', icon: ShieldCheck },
+];
+
+function open(over: Partial> = {}) {
+ const props: React.ComponentProps = {
+ navItems,
+ activeView: 'security',
+ onNavigate: vi.fn(),
+ ...over,
+ };
+ render();
+ // The destination list lives inside a Sheet that is closed until the trigger
+ // is clicked, so open it before querying the nav buttons.
+ fireEvent.click(screen.getByRole('button', { name: 'More destinations' }));
+ return props;
+}
+
+describe('MobileMoreMenu', () => {
+ it('lists every nav item, including the bottom-tab primaries', () => {
+ open();
+ for (const item of navItems) {
+ expect(screen.getByRole('button', { name: item.label })).toBeInTheDocument();
+ }
+ });
+
+ it('navigates to the chosen destination on click', () => {
+ const { onNavigate } = open();
+ fireEvent.click(screen.getByRole('button', { name: 'Resources' }));
+ expect(onNavigate).toHaveBeenCalledWith('resources');
+ });
+
+ it('marks the active destination', () => {
+ open({ activeView: 'security' });
+ expect(screen.getByRole('button', { name: 'Security' }).className).toContain('bg-glass-highlight');
+ expect(screen.getByRole('button', { name: 'Resources' }).className).not.toContain('font-medium');
+ });
+});
diff --git a/frontend/src/components/MobileMoreMenu.tsx b/frontend/src/components/MobileMoreMenu.tsx
index be70082b..2da98aa2 100644
--- a/frontend/src/components/MobileMoreMenu.tsx
+++ b/frontend/src/components/MobileMoreMenu.tsx
@@ -5,11 +5,6 @@ import { Sheet, SheetContent, SheetTrigger } from './ui/sheet';
import { cn } from '@/lib/utils';
import type { NavItem, ActiveView } from './EditorLayout/hooks/useViewNavigationState';
-// Destinations the bottom tab bar already exposes; the "more" menu omits these
-// so it lists only the secondary destinations (auto-updates, app store,
-// observability, etc.). Kept in sync with MobileTabBar's tab set.
-const TAB_BAR_VIEWS = new Set(['dashboard', 'fleet', 'scheduled-ops', 'settings']);
-
interface MobileMoreMenuProps {
/** The already-gated nav items (admin / remote / paid filtering applied). */
navItems: NavItem[];
@@ -22,12 +17,12 @@ interface MobileMoreMenuProps {
/**
* The "more destinations" affordance for the mobile content screens. On a phone
* the global TopBar is dropped and each screen's masthead leads, so this hosts
- * every secondary destination the bottom tab bar does not (auto-updates, app
- * store, observability, etc.) plus the theme and account controls.
+ * the full destination list plus the theme and account controls. It lists every
+ * gated nav item (the bottom tab bar's primaries included) so the same menu
+ * opens the same set on every screen, rather than changing contents per page.
*/
export function MobileMoreMenu({ navItems, activeView, onNavigate, footer }: MobileMoreMenuProps) {
const [open, setOpen] = useState(false);
- const secondaryItems = navItems.filter(item => !TAB_BAR_VIEWS.has(item.value));
return (
@@ -46,7 +41,7 @@ export function MobileMoreMenu({ navItems, activeView, onNavigate, footer }: Mob