diff --git a/docs/features/appearance.mdx b/docs/features/appearance.mdx index 01ef39d2..61449a9e 100644 --- a/docs/features/appearance.mdx +++ b/docs/features/appearance.mdx @@ -79,15 +79,21 @@ Sencho uses three type contexts. The interface and data faces are yours to chang ## Display -The **Display** group holds the remaining per-browser preferences: +The **Display** group holds layout and log-chip preferences for this browser: - **Density** switches between **Comfortable** (roomy rows, the default) and **Compact** (tighter rows and tiles that fit more on screen for dense dashboards). -- **Top navigation labels** shows text labels beside the top navigation icons. Turn it off for an icon-only bar; the destinations stay reachable by hover tooltip, accessible name, and the command palette. On the phone layout the navigation always keeps its labels. With labels off, **Top navigation alignment** chooses whether the icon-only bar sits to the left or centered. - **Log chip color** controls how service chips are colored in log views. **Unified** uses the accent color for all service chips. **Per service** assigns each service a stable label color for faster visual scanning when following multiple services at once. - - The Appearance Display section with a Density selector set to Comfortable, a Top navigation labels toggle turned off revealing a Top navigation alignment control with Left selected and Center, and a Log chip color control with Unified selected alongside Per service. - +## Navigation + +The **Navigation** group chooses how the desktop top bar presents page destinations. Phone navigation is unchanged. + +- **Navigation style** + - **Smart bar** (recommended default): keeps a short set of primary destinations visible and moves the rest into a grouped **More** menu. + - **Classic bar**: shows the full horizontal destination strip. + - **Compact launcher**: puts destinations in a left-side launcher menu and optionally pins up to five **quick links** on the bar. +- **Top navigation labels** (Classic and Smart): shows text beside top navigation icons. Turn it off for an icon-only bar; destinations stay reachable by tooltip, accessible name, and the command palette. Phone layout always keeps labels. With labels off, **Top navigation alignment** places the icon-only bar left or centered. +- **Quick links** (Compact launcher): labeled pins after the launcher, with a trailing **+** that opens reachable unpinned destinations. Right-click a pin and choose Remove, or manage the full list under Appearance. Up to five pins; recommended defaults start you with four. Deploy-progress behavior and the diff-preview-before-save step are stack workflow preferences, so they live in **Settings → Infrastructure → Stacks**, not here. diff --git a/docs/features/global-search.mdx b/docs/features/global-search.mdx index 7ed0739a..cff5aeec 100644 --- a/docs/features/global-search.mdx +++ b/docs/features/global-search.mdx @@ -3,7 +3,7 @@ title: Global Search description: Jump to any page, node, or stack from anywhere in the app with a single keystroke. --- -The **global search palette** lets you move around Sencho without reaching for the mouse. It covers the destinations the top bar exposes for your tier and role, every configured node, and every stack on every online node in your fleet. +The **global search palette** lets you move around Sencho without reaching for the mouse. It covers the reachable page destinations for your tier and role (the same page list Classic top navigation, Smart primary and More, and mobile navigation use), every configured node, and every stack on every online node in your fleet. Compact launcher can also open **Settings** from its menu; Settings is not a palette page row. Sencho global search palette open with no query, the Pages group listing Home, Fleet, Resources, Networking, App Store, Logs, and Auto-Update each with a leading icon. @@ -25,7 +25,7 @@ The palette groups results into three sections. | Group | What it contains | What happens when you pick one | |-------|------------------|--------------------------------| -| **Pages** | The same set of destinations the top bar shows you. Home, Fleet, Resources, Networking, App Store, and Logs always appear; Update and Schedules appear for admins; Console appears for admins when that limited-availability surface is present; Audit appears on Admiral for roles with the audit permission (the full Audit view, statistics, export, anomalies, and extended retention). Community keeps a rolling 14-day recent-activity audit API window without the Audit view in navigation. | Navigates to that page | +| **Pages** | The reachable page destinations for your tier and role (the same set Classic / Smart / mobile navigation use). **Home**, **Resources**, **Networking**, **Security**, and **App Store** appear for signed-in operators; **Fleet** appears when your role holds the `node:read` permission; **Logs**, **Update**, and **Schedules** appear for admins; **Console** is a limited-availability operator surface shown when enabled on an instance; **Audit** appears on Admiral for any role with the `system:audit` permission. See [RBAC & User Management](/features/rbac) for the full permission matrix. | Navigates to that page | | **Nodes** | Every node in your fleet, with a green dot for online and a grey dot for offline. The currently active node carries a small **ACTIVE** chip on the right. | Switches the active node without leaving the current page | | **Stacks** | Every compose stack on every online node, matched on the compose filename (extension included). | Switches to the stack's node and opens it in the editor | diff --git a/docs/getting-started/introduction.mdx b/docs/getting-started/introduction.mdx index fc5479ce..50fdbad8 100644 --- a/docs/getting-started/introduction.mdx +++ b/docs/getting-started/introduction.mdx @@ -34,7 +34,7 @@ The **Home** view is the default landing page. It is designed for a fast operati - The activity panel shows **Fleet Heartbeat** when remote nodes exist, or **Stack Restarts (7d)** on a local-only install. - **Recent Alerts** shows the latest notification feed and includes **Clear All Notifications** when there is anything to clear. -The top navigation strip starts with **Home**, **Fleet**, **Resources**, **Security**, and **App Store**. Additional operator views (**Logs**, **Update**, **Schedules**, **Console**, and **Audit**) appear based on your role and license tier. Fleet-wide views describe the control instance, so they are hidden while a remote node is active. +The top navigation starts with **Home**, **Resources**, **Networking**, **Security**, and **App Store**. **Fleet** appears when your role can read nodes. Additional operator views (**Logs**, **Update**, **Schedules**, and **Audit**) appear based on your role and license tier. **Console** is a limited-availability operator surface documented on its own page when enabled on an instance. Fleet-wide views describe the control instance, so they are hidden while a remote node is active. Choose Classic, Smart, or Compact desktop navigation under **Settings → Appearance → Navigation**; phone navigation stays on its own layout. ## Stack workspace diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx index 7b12806a..0b9c9d33 100644 --- a/docs/getting-started/quickstart.mdx +++ b/docs/getting-started/quickstart.mdx @@ -107,7 +107,7 @@ You land on **Home**, the default operational view. The health masthead reports Below the stack table, **Configuration Status** summarizes notifications, alerts, automation, security, backups, thresholds, and crash detection. The neighboring activity card shows **Fleet Heartbeat** when remote nodes exist, or **Stack Restarts (7d)** on a local-only install. **Recent Alerts** shows the latest notification feed and includes **Clear All Notifications** when there is anything to clear. -On the local node, the top navigation includes **Home**, **Fleet**, **Resources**, **Security**, **App Store**, and **Logs**. Depending on license, role, and node context, it can also include **Update**, **Schedules**, **Console**, and **Audit**; hub-only views are hidden when a remote node is active. The right side of the top bar holds global search, notifications, and the profile menu entries **Settings**, **Documentation**, **Feedback**, **Appearance**, and **Log Out**. +On the local node, baseline top navigation includes **Home**, **Resources**, **Networking**, **Security**, and **App Store**. **Fleet** appears when your role can read nodes. **Logs**, **Update**, and **Schedules** appear for admins. **Console** and **Audit** depend on license, role, and whether those surfaces are enabled; hub-only views are hidden when a remote node is active. Desktop presentation (Classic bar, Smart bar, or Compact launcher) is chosen under **Settings → Appearance → Navigation**. The right side of the top bar holds global search, notifications, and the profile menu entries **Settings**, **Documentation**, **Feedback**, **Appearance**, and **Log Out**. The left sidebar is the stack workspace. Below the Sencho brand, it starts with the node switcher, then **Create Stack**, a bulk-mode toggle, and **Scan stacks folder** for re-indexing compose projects added outside Sencho. Use **Search stacks...** with the **All**, **Up**, **Down**, and **Updates** chips to narrow the list. On a fresh install with an empty stack list, Sencho scans your mounted compose directory automatically and shows what it found, including compose files that still need to be adopted into their own subfolder. diff --git a/e2e/desktop-navigation.spec.ts b/e2e/desktop-navigation.spec.ts new file mode 100644 index 00000000..eaf6bedf --- /dev/null +++ b/e2e/desktop-navigation.spec.ts @@ -0,0 +1,86 @@ +/** + * Desktop navigation styles: Smart default, Compact quick-link picker, + * labeled pins, and persistence. + */ +import { test, expect } from '@playwright/test'; +import { loginAs, waitForStacksLoaded } from './helpers'; + +async function setTopNavMode(page: import('@playwright/test').Page, mode: 'classic' | 'smart' | 'compact' | null) { + await page.evaluate((next) => { + if (next === null) { + window.localStorage.removeItem('sencho.appearance.topNavMode'); + window.localStorage.removeItem('sencho.appearance.topNavQuickLinks'); + return; + } + window.localStorage.setItem('sencho.appearance.topNavMode', next); + }, mode); + await page.reload(); + await loginAs(page); + await waitForStacksLoaded(page); +} + +test.describe('Desktop navigation styles', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => { + window.localStorage.removeItem('sencho.appearance.topNavMode'); + window.localStorage.removeItem('sencho.appearance.topNavQuickLinks'); + }); + await loginAs(page); + await waitForStacksLoaded(page); + }); + + test('defaults to Smart bar with a More control', async ({ page }) => { + const topbar = page.locator('[data-sn-chrome="topbar"]'); + await expect(topbar).toHaveAttribute('data-sn-nav-mode', 'smart'); + await expect(page.getByRole('button', { name: 'More navigation' })).toBeVisible(); + }); + + test('persists mode across reload and navigates via Smart More', async ({ page }) => { + await setTopNavMode(page, 'classic'); + await expect(page.locator('[data-sn-chrome="topbar"]')).toHaveAttribute('data-sn-nav-mode', 'classic'); + + await setTopNavMode(page, 'smart'); + await expect(page.locator('[data-sn-chrome="topbar"]')).toHaveAttribute('data-sn-nav-mode', 'smart'); + await page.getByRole('button', { name: 'More navigation' }).click(); + await expect(page.locator('.font-heading').filter({ hasText: 'More' })).toBeVisible(); + await page.getByRole('menuitem', { name: /Logs/i }).click(); + await expect(page.locator('body')).toContainText(/Logs|Central|Observability/i); + }); + + test('Compact launcher opens Settings', async ({ page }) => { + await setTopNavMode(page, 'compact'); + await expect(page.locator('[data-sn-chrome="topbar"]')).toHaveAttribute('data-sn-nav-mode', 'compact'); + await page.getByRole('button', { name: 'Open navigation launcher' }).click(); + await page.getByRole('menuitem', { name: /^Settings$/i }).click(); + await expect(page.getByText('Appearance', { exact: true }).first()).toBeVisible({ timeout: 10_000 }); + }); + + test('Compact trailing + adds a labeled pin that survives reload', async ({ page }) => { + await page.setViewportSize({ width: 1100, height: 800 }); + await page.evaluate(() => { + window.localStorage.setItem('sencho.appearance.topNavQuickLinks', '[]'); + }); + await setTopNavMode(page, 'compact'); + + const topbar = page.locator('[data-sn-chrome="topbar"]'); + await expect(topbar).toHaveAttribute('data-sn-nav-mode', 'compact'); + + await page.getByRole('button', { name: 'Add quick link' }).click(); + await page.getByRole('menuitem', { name: /Networking/i }).click(); + + const pin = topbar.getByRole('button', { name: 'Networking', exact: true }); + await expect(pin).toBeVisible(); + await expect(pin.locator('span.inline')).toBeVisible(); + + const stored = await page.evaluate(() => window.localStorage.getItem('sencho.appearance.topNavQuickLinks')); + expect(stored).toContain('networking'); + + await page.reload(); + await loginAs(page); + await waitForStacksLoaded(page); + + await expect(page.locator('[data-sn-chrome="topbar"]').getByRole('button', { name: 'Networking', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Add quick link' })).toBeVisible(); + }); +}); diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index db8ae710..811cd15e 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { Button } from './ui/button'; import { Plus, Loader2, ChevronLeft, AlertCircle, RefreshCw } from 'lucide-react'; import { UserProfileDropdown } from './UserProfileDropdown'; @@ -44,6 +44,9 @@ import type { SidebarActivityAction } from '@/components/sidebar/SidebarActivity import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled'; import { useTopNavLabels } from '@/hooks/use-top-nav-labels'; import { useTopNavAlign } from '@/hooks/use-top-nav-align'; +import { useTopNavMode } from '@/hooks/use-top-nav-mode'; +import { useTopNavQuickLinks } from '@/hooks/use-top-nav-quick-links'; +import { getAppNavItem } from '@/lib/navigation/appNavRegistry'; import { useStackMuteActions } from '@/hooks/useMuteRuleActions'; import { toast } from '@/components/ui/toast-store'; import { useIsMobile } from '@/hooks/use-is-mobile'; @@ -195,6 +198,8 @@ export default function EditorLayout() { const [diffPreviewEnabled] = useComposeDiffPreviewEnabled(); const [topNavLabels] = useTopNavLabels(); const [topNavAlign] = useTopNavAlign(); + const [topNavMode] = useTopNavMode(); + const { persistedIds: quickLinkIds, addQuickLink, removeQuickLink } = useTopNavQuickLinks(); // Use a ref to break the circular dependency: // useViewNavigationState needs onNavigateToDashboard -> resetEditorState @@ -220,10 +225,22 @@ export default function EditorLayout() { handleMutePrefillConsumed, handleNavigate, navItems, + navModel, openMuteRulesWithPrefill, reachCtx, } = navState; + const visibleQuickLinks = useMemo(() => { + const candidateSet = new Set(navModel.quickLinkCandidates.map((item) => item.value)); + return quickLinkIds + .filter((id) => candidateSet.has(id)) + .map((id) => { + const item = getAppNavItem(id); + return item ? { value: item.value, label: item.label, icon: item.icon } : null; + }) + .filter((item): item is NonNullable => item !== null); + }, [quickLinkIds, navModel.quickLinkCandidates]); + const { notifications, tickerConnected, @@ -908,6 +925,13 @@ export default function EditorLayout() { userMenu={userMenuEl} showLabels={topNavLabels} navAlign={topNavAlign} + navMode={topNavMode} + navModel={navModel} + quickLinks={visibleQuickLinks} + persistedQuickLinkIds={quickLinkIds} + onAddQuickLink={(value) => addQuickLink(value as typeof quickLinkIds[number])} + onRemoveQuickLink={(value) => removeQuickLink(value as typeof quickLinkIds[number])} + onOpenSettings={() => openSettings()} /> ); @@ -962,6 +986,7 @@ export default function EditorLayout() { stackUpdates={stackUpdates} urlHydratingStack={urlHydratingStack} isFileLoading={isFileLoading} + quickLinkCandidates={navModel.quickLinkCandidates} /> ); @@ -1033,6 +1058,7 @@ export default function EditorLayout() { headerActions={mobileMastheadActions} selectedSection={mobileSettingsSection} onSelectedSectionChange={setMobileSettingsSection} + quickLinkCandidates={navModel.quickLinkCandidates} /> ); case 'security': diff --git a/frontend/src/components/EditorLayout/ViewRouter.tsx b/frontend/src/components/EditorLayout/ViewRouter.tsx index b5deaf1f..a27f4602 100644 --- a/frontend/src/components/EditorLayout/ViewRouter.tsx +++ b/frontend/src/components/EditorLayout/ViewRouter.tsx @@ -18,6 +18,7 @@ import type { ActiveView } from './hooks/useViewNavigationState'; import type { StackUpdateInfo } from '@/types/imageUpdates'; import type { SecurityTab, FleetTab } from '@/lib/events'; import { isStackEditorDeepLink } from '@/lib/router/readUrlRouteState'; +import type { NavDestination } from '@/lib/navigation/appNavRegistry'; // Paid-tier views are loaded on demand. Their internal PaidGate / // CapabilityGate wrappers render @@ -109,6 +110,7 @@ export interface ViewRouterProps { stackUpdates: Record; urlHydratingStack: string | null; isFileLoading: boolean; + quickLinkCandidates?: NavDestination[]; } export function ViewRouter({ @@ -142,6 +144,7 @@ export function ViewRouter({ stackUpdates, urlHydratingStack, isFileLoading, + quickLinkCandidates, }: ViewRouterProps): ReactNode { const { can } = useAuth(); const { experimental, experimentalReady } = useExperimental(); @@ -153,6 +156,7 @@ export function ViewRouter({ muteRulePrefill={muteRulePrefill} onMutePrefillConsumed={onMutePrefillConsumed} onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill} + quickLinkCandidates={quickLinkCandidates} /> ); } diff --git a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts index 4d6e1888..811a7f10 100644 --- a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts +++ b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts @@ -1,9 +1,4 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; -import { - Terminal, CloudDownload, Home, HardDrive, ScrollText, - Activity, Radar, RefreshCw, Clock, ShieldCheck, Network, -} from 'lucide-react'; -import type { LucideIcon } from 'lucide-react'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { useNodes } from '@/context/NodeContext'; @@ -18,20 +13,18 @@ import { HUB_ONLY_VIEWS } from '@/lib/router/routeTypes'; import { readUrlRouteState } from '@/lib/router/readUrlRouteState'; import { authzReady, - isViewHidden, normalizeHiddenView, type ReachabilityContext, } from '@/lib/routing/reachability'; import { useExperimental } from '@/hooks/useExperimental'; +import { buildNavigationModel } from '@/lib/navigation/buildNavigationModel'; +import type { NavDestination } from '@/lib/navigation/appNavRegistry'; export type { ActiveView }; export { HUB_ONLY_VIEWS }; -export interface NavItem { - value: ActiveView; - label: string; - icon: LucideIcon; -} +/** @deprecated Prefer NavDestination from appNavRegistry; alias kept for mobile/palette imports. */ +export type NavItem = NavDestination; interface UseViewNavigationStateOptions { onNavigateToDashboard?: () => void; @@ -124,39 +117,8 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) return () => window.removeEventListener(SENCHO_NAVIGATE_EVENT, handler); }, []); - const navItems = useMemo((): NavItem[] => { - const items: NavItem[] = [ - { value: 'dashboard', label: 'Home', icon: Home }, - ]; - if (!isViewHidden('fleet', reachCtx)) { - items.push({ value: 'fleet', label: 'Fleet', icon: Radar }); - } - items.push( - { value: 'resources', label: 'Resources', icon: HardDrive }, - { value: 'networking', label: 'Networking', icon: Network }, - { value: 'security', label: 'Security', icon: ShieldCheck }, - { value: 'templates', label: 'App Store', icon: CloudDownload }, - ); - if (!isViewHidden('global-observability', reachCtx)) { - items.push({ value: 'global-observability', label: 'Logs', icon: Activity }); - } - if (!isViewHidden('auto-updates', reachCtx)) { - items.push({ value: 'auto-updates', label: 'Update', icon: RefreshCw }); - } - if (!isViewHidden('scheduled-ops', reachCtx)) { - items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock }); - } - // Visual discovery fail-closed: omit Console until /meta settles and the - // flag is on. URL normalization still waits on experimentalReady inside - // isViewHidden so enabled deep links are not rewritten during cold load. - if (experimentalReady && experimental && !isViewHidden('host-console', reachCtx)) { - items.push({ value: 'host-console', label: 'Console', icon: Terminal }); - } - if (!isViewHidden('audit-log', reachCtx)) { - items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText }); - } - return items; - }, [reachCtx, experimentalReady, experimental]); + const navModel = useMemo(() => buildNavigationModel(reachCtx), [reachCtx]); + const navItems = navModel.allPageItems; useEffect(() => { if (!authzReady(reachCtx)) return; @@ -183,6 +145,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) openMuteRulesWithPrefill, handleNavigate, navItems, + navModel, reachCtx, } as const; } diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index bafa76ce..df3206e2 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -1,161 +1,614 @@ -import { Fragment, type ReactNode } from 'react'; +import { Fragment, type ReactNode, useMemo } from 'react'; import type { LucideIcon } from 'lucide-react'; -import { Menu } from 'lucide-react'; +import { Menu, MoreHorizontal, Plus } from 'lucide-react'; import { Button } from './ui/button'; import { Sheet, SheetContent, SheetTrigger } from './ui/sheet'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from './ui/dropdown-menu'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from './ui/context-menu'; import type { TopNavAlign } from '@/hooks/use-top-nav-align'; +import type { TopNavMode } from '@/hooks/use-top-nav-mode'; +import { MAX_QUICK_LINKS } from '@/hooks/use-top-nav-quick-links'; +import type { NavDestination } from '@/lib/navigation/appNavRegistry'; +import type { NavGroupBucket, ReachableNavigationModel } from '@/lib/navigation/buildNavigationModel'; import { cn } from '@/lib/utils'; export interface TopBarNavItem { - value: string; - label: string; - icon: LucideIcon; + value: string; + label: string; + icon: LucideIcon; } interface TopBarProps { - activeView: string; - navItems: TopBarNavItem[]; - onNavigate: (value: string) => void; - mobileNavOpen: boolean; - onMobileNavOpenChange: (open: boolean) => void; - search?: ReactNode; - themeSwitch?: ReactNode; - notifications: ReactNode; - userMenu: ReactNode; - /** Show text labels beside the desktop nav icons. When false, the bar is icon-only. */ - showLabels?: boolean; - /** Desktop nav placement in icon-only mode. Ignored while labels are shown (always left). */ - navAlign?: TopNavAlign; + activeView: string; + /** Flat page destinations for Classic strip and the mobile sheet. */ + navItems: TopBarNavItem[]; + onNavigate: (value: string) => void; + mobileNavOpen: boolean; + onMobileNavOpenChange: (open: boolean) => void; + search?: ReactNode; + themeSwitch?: ReactNode; + notifications: ReactNode; + userMenu: ReactNode; + showLabels?: boolean; + navAlign?: TopNavAlign; + navMode?: TopNavMode; + navModel?: ReachableNavigationModel; + /** Visible (reachable) quick links for Compact mode. */ + quickLinks?: NavDestination[]; + /** Persisted pin IDs (including temporarily unreachable). Capacity is length. */ + persistedQuickLinkIds?: readonly string[]; + onAddQuickLink?: (value: string) => void; + onRemoveQuickLink?: (value: string) => void; + onOpenSettings?: () => void; +} + +const navButtonClass = (isActive: boolean) => + cn( + 'relative inline-flex h-full shrink-0 items-center gap-2 px-4', + 'font-mono text-[10px] uppercase tracking-[0.18em] transition-colors', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50', + isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', + ); + +function ActiveUnderline({ active }: { active: boolean }) { + if (!active) return null; + return ( + + ); +} + +function TopBarMenuMasthead({ title }: { title: string }) { + return ( +
+
+
+
+ {title} +
+
+ ); +} + +function PanelMenuContent({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( + + +
{children}
+
+ ); +} + +function DesktopNavButton({ + item, + isActive, + showLabels, + onNavigate, +}: { + item: TopBarNavItem; + isActive: boolean; + showLabels: boolean; + onNavigate: (value: string) => void; +}) { + const Icon = item.icon; + const button = ( + + ); + if (showLabels) return {button}; + return ( + + {button} + {item.label} + + ); +} + +function GroupedMenuItems({ + groups, + activeView, + onSelect, + onAddQuickLink, + persistedIds, + atCapacity, +}: { + groups: NavGroupBucket[]; + activeView: string; + onSelect: (value: string) => void; + /** When set, Compact launcher rows get a context Add action. */ + onAddQuickLink?: (value: string) => void; + persistedIds?: ReadonlySet; + atCapacity?: boolean; +}) { + return ( + <> + {groups.map((group, index) => ( + + {index > 0 && } + + {group.label} + + {group.items.map((item) => { + const Icon = item.icon; + const isActive = activeView === item.value; + const canContextAdd = + Boolean(onAddQuickLink) + && item.value !== 'settings' + && !persistedIds?.has(item.value) + && !atCapacity; + + const menuItem = ( + onSelect(item.value)} + data-active={isActive ? 'true' : undefined} + className={cn( + 'gap-2 font-mono text-[11px] uppercase tracking-[0.14em]', + isActive && 'bg-accent text-accent-foreground', + )} + > + + {item.label} + + ); + + if (!canContextAdd) return menuItem; + + return ( + + {menuItem} + + onAddQuickLink?.(item.value)} + > + Add to quick links + + + + ); + })} + + ))} + + ); +} + +function ClassicStrip({ + navItems, + activeView, + showLabels, + onNavigate, +}: { + navItems: TopBarNavItem[]; + activeView: string; + showLabels: boolean; + onNavigate: (value: string) => void; +}) { + return ( + <> + {navItems.map((item) => ( + + ))} + + ); +} + +function SmartStrip({ + primaryItems, + overflowGroups, + activeView, + showLabels, + onNavigate, +}: { + primaryItems: TopBarNavItem[]; + overflowGroups: NavGroupBucket[]; + activeView: string; + showLabels: boolean; + onNavigate: (value: string) => void; +}) { + const overflowValues = useMemo( + () => new Set(overflowGroups.flatMap((g) => g.items.map((i) => i.value))), + [overflowGroups], + ); + const moreActive = overflowValues.has(activeView); + const hasOverflow = overflowGroups.some((g) => g.items.length > 0); + + return ( + <> + {primaryItems.map((item) => ( + + ))} + {hasOverflow && ( + + + + + + + + + )} + + ); +} + +function CompactQuickLink({ + item, + isActive, + onNavigate, + onRemove, +}: { + item: NavDestination; + isActive: boolean; + onNavigate: (value: string) => void; + onRemove: (value: string) => void; +}) { + const Icon = item.icon; + + return ( + + + + + + onRemove(item.value)}>Remove + + + ); +} + +function CompactStrip({ + launcherGroups, + quickLinks, + quickLinkCandidates, + persistedQuickLinkIds, + activeView, + onNavigate, + onAddQuickLink, + onRemoveQuickLink, + onOpenSettings, +}: { + launcherGroups: NavGroupBucket[]; + quickLinks: NavDestination[]; + quickLinkCandidates: NavDestination[]; + persistedQuickLinkIds: readonly string[]; + activeView: string; + onNavigate: (value: string) => void; + onAddQuickLink?: (value: string) => void; + onRemoveQuickLink?: (value: string) => void; + onOpenSettings?: () => void; +}) { + const launcherValues = useMemo( + () => new Set(launcherGroups.flatMap((g) => g.items.map((i) => i.value))), + [launcherGroups], + ); + const quickValues = useMemo( + () => new Set(quickLinks.map((i) => i.value)), + [quickLinks], + ); + const persistedSet = useMemo( + () => new Set(persistedQuickLinkIds), + [persistedQuickLinkIds], + ); + const atCapacity = persistedQuickLinkIds.length >= MAX_QUICK_LINKS; + const unpinnedCandidates = useMemo( + () => quickLinkCandidates.filter((item) => !persistedSet.has(item.value)), + [quickLinkCandidates, persistedSet], + ); + const addEnabled = !atCapacity && unpinnedCandidates.length > 0; + const addDisabledReason = atCapacity + ? 'Remove a quick link to free a slot' + : 'No more destinations available'; + + const launcherActive = + launcherValues.has(activeView) && !quickValues.has(activeView); + + const selectDestination = (value: string) => { + if (value === 'settings') { + onOpenSettings?.(); + return; + } + onNavigate(value); + }; + + return ( +
+ + + + + + + + + +
+ {quickLinks.map((item) => ( + onRemoveQuickLink?.(value)} + /> + ))} +
+ + {addEnabled ? ( + + + + + + {unpinnedCandidates.map((item) => { + const Icon = item.icon; + const isCurrent = item.value === activeView; + return ( + onAddQuickLink?.(item.value)} + className={cn( + 'gap-2 font-mono text-[11px] uppercase tracking-[0.14em]', + isCurrent && 'bg-accent text-accent-foreground', + )} + > + + {item.label} + + ); + })} + + + ) : ( + + + + + + + {addDisabledReason} + + )} +
+ ); } export function TopBar({ - activeView, - navItems, - onNavigate, - mobileNavOpen, - onMobileNavOpenChange, - search, - themeSwitch, - notifications, - userMenu, - showLabels = true, - navAlign = 'left', + activeView, + navItems, + onNavigate, + mobileNavOpen, + onMobileNavOpenChange, + search, + themeSwitch, + notifications, + userMenu, + showLabels = true, + navAlign = 'left', + navMode = 'smart', + navModel, + quickLinks = [], + persistedQuickLinkIds = [], + onAddQuickLink, + onRemoveQuickLink, + onOpenSettings, }: TopBarProps) { - // Centering applies only to the icon-only bar; with labels on the nav stays - // left so the long labels read from the edge. - const centered = !showLabels && navAlign === 'center'; - return ( -
+ {centered &&
} + + + + - {/* NAV ZONE: Navigation (hidden on mobile) */} - - - +
+ {search} + {themeSwitch} + {notifications} + {userMenu} - {/* RIGHT ZONE: Utilities + identity pin */} -
+ + - - -
-

Navigation

-
- -
- + + + + +
+

Navigation

-
- ); + + + +
+
+ ); } diff --git a/frontend/src/components/__tests__/TopBar.test.tsx b/frontend/src/components/__tests__/TopBar.test.tsx index c8b010d4..e8b9e2b0 100644 --- a/frontend/src/components/__tests__/TopBar.test.tsx +++ b/frontend/src/components/__tests__/TopBar.test.tsx @@ -7,6 +7,7 @@ */ import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { Home, Radar } from 'lucide-react'; import { TopBar, type TopBarNavItem } from '../TopBar'; @@ -87,3 +88,215 @@ describe('TopBar showLabels', () => { expect(screen.getByRole('navigation', { name: 'Primary' }).previousElementSibling).toBeNull(); }); }); + +describe('TopBar smart and compact modes', () => { + const overflowGroups = [ + { + group: 'operations' as const, + label: 'Operations', + items: [{ value: 'global-observability' as const, label: 'Logs', icon: Home }], + }, + ]; + const launcherGroups = [ + { + group: 'overview' as const, + label: 'Overview', + items: [{ value: 'dashboard' as const, label: 'Home', icon: Home }], + }, + { + group: 'settings' as const, + label: 'Settings', + items: [{ value: 'settings' as const, label: 'Settings', icon: Radar }], + }, + ]; + const emptyModel = { + allPageItems: [ + { value: 'dashboard' as const, label: 'Home', icon: Home }, + { value: 'fleet' as const, label: 'Fleet', icon: Radar }, + ], + primaryItems: [ + { value: 'dashboard' as const, label: 'Home', icon: Home }, + { value: 'fleet' as const, label: 'Fleet', icon: Radar }, + ], + overflowGroups: [] as typeof overflowGroups, + launcherGroups: [] as typeof launcherGroups, + quickLinkCandidates: [ + { value: 'dashboard' as const, label: 'Home', icon: Home }, + { value: 'fleet' as const, label: 'Fleet', icon: Radar }, + ], + }; + + it('marks More with aria-current when the active page is in overflow', () => { + renderTopBar({ + navMode: 'smart', + activeView: 'global-observability', + navModel: { + ...emptyModel, + overflowGroups, + }, + }); + expect(screen.getByRole('button', { name: 'More navigation' })).toHaveAttribute( + 'aria-current', + 'page', + ); + expect(screen.getByRole('button', { name: 'More navigation' })).toHaveTextContent('More'); + }); + + it('opens the More menu with masthead chrome and keeps overflow labels', async () => { + const onNavigate = vi.fn(); + renderTopBar({ + navMode: 'smart', + onNavigate, + navModel: { + ...emptyModel, + overflowGroups, + }, + }); + const more = screen.getByRole('button', { name: 'More navigation' }); + more.focus(); + fireEvent.keyDown(more, { key: 'Enter' }); + expect(await screen.findByText('More', { selector: '.font-heading' })).toBeInTheDocument(); + expect(await screen.findByRole('menuitem', { name: /Logs/i })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('menuitem', { name: /Logs/i })); + expect(onNavigate).toHaveBeenCalledWith('global-observability'); + }); + + it('renders Compact pins with always-inline labels and a trailing Add control', async () => { + const user = userEvent.setup(); + const onNavigate = vi.fn(); + const onAddQuickLink = vi.fn(); + const onRemoveQuickLink = vi.fn(); + const onOpenSettings = vi.fn(); + renderTopBar({ + navMode: 'compact', + activeView: 'dashboard', + onNavigate, + onAddQuickLink, + onRemoveQuickLink, + onOpenSettings, + persistedQuickLinkIds: ['dashboard'], + quickLinks: [{ value: 'dashboard', label: 'Home', icon: Home }], + navModel: { + ...emptyModel, + launcherGroups, + quickLinkCandidates: [ + { value: 'dashboard' as const, label: 'Home', icon: Home }, + { value: 'fleet' as const, label: 'Fleet', icon: Radar }, + ], + }, + }); + + const home = screen.getByRole('button', { name: 'Home' }); + expect(home.querySelector('span.inline')).toBeTruthy(); + expect(home.querySelector('span.hidden')).toBeNull(); + + await user.click(screen.getByRole('button', { name: 'Add quick link' })); + await user.click(await screen.findByRole('menuitem', { name: /Fleet/i })); + expect(onAddQuickLink).toHaveBeenCalledWith('fleet'); + + await user.click(screen.getByRole('button', { name: 'Home' })); + expect(onNavigate).toHaveBeenCalledWith('dashboard'); + + await user.pointer({ keys: '[MouseRight]', target: screen.getByRole('button', { name: 'Home' }) }); + await user.click(await screen.findByRole('menuitem', { name: /^Remove$/i })); + expect(onRemoveQuickLink).toHaveBeenCalledWith('dashboard'); + expect(onNavigate).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole('button', { name: 'Open navigation launcher' })); + expect(await screen.findByText('Navigate', { selector: '.font-heading' })).toBeInTheDocument(); + await user.click(await screen.findByRole('menuitem', { name: /Settings/i })); + expect(onOpenSettings).toHaveBeenCalled(); + }); + + it('disables Add when persisted capacity is full even if fewer pins are visible', () => { + renderTopBar({ + navMode: 'compact', + persistedQuickLinkIds: ['dashboard', 'fleet', 'resources', 'security', 'networking'], + quickLinks: [{ value: 'dashboard', label: 'Home', icon: Home }], + navModel: { + ...emptyModel, + launcherGroups, + quickLinkCandidates: emptyModel.quickLinkCandidates, + }, + }); + expect(screen.getByRole('button', { name: 'Add quick link' })).toBeDisabled(); + }); + + it('offers Compact launcher context Add for unpinned destinations', async () => { + const user = userEvent.setup(); + const onAddQuickLink = vi.fn(); + const compactLauncher = [ + { + group: 'overview' as const, + label: 'Overview', + items: [ + { value: 'dashboard' as const, label: 'Home', icon: Home }, + { value: 'fleet' as const, label: 'Fleet', icon: Radar }, + ], + }, + ]; + renderTopBar({ + navMode: 'compact', + onAddQuickLink, + persistedQuickLinkIds: ['dashboard'], + quickLinks: [{ value: 'dashboard', label: 'Home', icon: Home }], + navModel: { + ...emptyModel, + launcherGroups: compactLauncher, + quickLinkCandidates: [ + { value: 'dashboard' as const, label: 'Home', icon: Home }, + { value: 'fleet' as const, label: 'Fleet', icon: Radar }, + ], + }, + }); + + await user.click(screen.getByRole('button', { name: 'Open navigation launcher' })); + fireEvent.contextMenu(await screen.findByRole('menuitem', { name: /Fleet/i })); + await user.click(await screen.findByRole('menuitem', { name: /Add to quick links/i })); + expect(onAddQuickLink).toHaveBeenCalledWith('fleet'); + }); + + it('hides Compact launcher context Add for already-pinned destinations', async () => { + const user = userEvent.setup(); + const compactLauncher = [ + { + group: 'overview' as const, + label: 'Overview', + items: [ + { value: 'dashboard' as const, label: 'Home', icon: Home }, + { value: 'fleet' as const, label: 'Fleet', icon: Radar }, + ], + }, + ]; + renderTopBar({ + navMode: 'compact', + onAddQuickLink: vi.fn(), + persistedQuickLinkIds: ['dashboard'], + quickLinks: [{ value: 'dashboard', label: 'Home', icon: Home }], + navModel: { + ...emptyModel, + launcherGroups: compactLauncher, + }, + }); + + await user.click(screen.getByRole('button', { name: 'Open navigation launcher' })); + fireEvent.contextMenu(await screen.findByRole('menuitem', { name: /Home/i })); + expect(screen.queryByRole('menuitem', { name: /Add to quick links/i })).toBeNull(); + }); + + it('does not offer Add to quick links on Smart More', async () => { + const user = userEvent.setup(); + renderTopBar({ + navMode: 'smart', + onAddQuickLink: vi.fn(), + navModel: { + ...emptyModel, + primaryItems: [{ value: 'dashboard' as const, label: 'Home', icon: Home }], + overflowGroups, + }, + }); + await user.click(screen.getByRole('button', { name: 'More navigation' })); + fireEvent.contextMenu(await screen.findByRole('menuitem', { name: /Logs/i })); + expect(screen.queryByRole('menuitem', { name: /Add to quick links/i })).toBeNull(); + }); +}); diff --git a/frontend/src/components/mobile/MobileSettings.tsx b/frontend/src/components/mobile/MobileSettings.tsx index 0106475c..08fbbb98 100644 --- a/frontend/src/components/mobile/MobileSettings.tsx +++ b/frontend/src/components/mobile/MobileSettings.tsx @@ -14,11 +14,13 @@ import { import type { SectionId } from '@/components/settings'; import { SettingsSectionContent } from '@/components/settings/SettingsSectionContent'; import { BackChip, Kicker, Masthead } from './mobile-ui'; +import type { NavDestination } from '@/lib/navigation/appNavRegistry'; interface MobileSettingsProps { headerActions: ReactNode; selectedSection: SectionId | null; onSelectedSectionChange: (section: SectionId | null) => void; + quickLinkCandidates?: NavDestination[]; } const NOOP = () => {}; @@ -27,6 +29,7 @@ export function MobileSettings({ headerActions, selectedSection, onSelectedSectionChange, + quickLinkCandidates, }: MobileSettingsProps) { const { isAdmin } = useAuth(); const { isPaid } = useLicense(); @@ -60,7 +63,12 @@ export function MobileSettings({ {item.label}
- +
); diff --git a/frontend/src/components/settings/AppearanceSection.tsx b/frontend/src/components/settings/AppearanceSection.tsx index 76b578ce..05099453 100644 --- a/frontend/src/components/settings/AppearanceSection.tsx +++ b/frontend/src/components/settings/AppearanceSection.tsx @@ -9,6 +9,11 @@ import type { Density } from '@/hooks/use-density'; import { useLogChipColorMode, type LogChipColorMode } from '@/hooks/use-log-chip-color-mode'; import { useTopNavLabels } from '@/hooks/use-top-nav-labels'; import { useTopNavAlign, type TopNavAlign } from '@/hooks/use-top-nav-align'; +import { useTopNavMode, type TopNavMode } from '@/hooks/use-top-nav-mode'; +import { useTopNavQuickLinks, MAX_QUICK_LINKS } from '@/hooks/use-top-nav-quick-links'; +import { getAppNavItem } from '@/lib/navigation/appNavRegistry'; +import type { NavDestination } from '@/lib/navigation/appNavRegistry'; +import type { ActiveView } from '@/lib/router/routeTypes'; import { useTheme, activeVisualStyle, THEME_MODE_OPTIONS, ACCENTS, CONTRAST, BORDER_BOOST, GLOW, TYPE_SCALE, type VisualStyle, type HeadingStyle, type ChartStyle, @@ -37,6 +42,12 @@ const TOP_NAV_ALIGN_OPTIONS: { value: TopNavAlign; label: string }[] = [ { value: 'center', label: 'Center' }, ]; +const TOP_NAV_MODE_OPTIONS: { value: TopNavMode; label: string }[] = [ + { value: 'classic', label: 'Classic bar' }, + { value: 'smart', label: 'Smart bar' }, + { value: 'compact', label: 'Compact launcher' }, +]; + const CHART_STYLE_OPTIONS: { value: ChartStyle; label: string }[] = [ { value: 'muted', label: 'Muted' }, { value: 'heat', label: 'Heat' }, @@ -134,11 +145,33 @@ function VisualCard({ ); } -export function AppearanceSection() { +export function AppearanceSection({ + quickLinkCandidates = [], +}: { + quickLinkCandidates?: NavDestination[]; +}) { const [density, setDensity] = useDensity(); const [chipColorMode, setChipColorMode] = useLogChipColorMode(); const [topNavLabels, setTopNavLabels] = useTopNavLabels(); const [topNavAlign, setTopNavAlign] = useTopNavAlign(); + const [topNavMode, setTopNavMode] = useTopNavMode(); + const { + persistedIds: quickLinkIds, + addQuickLink, + removeQuickLink, + resetQuickLinks, + } = useTopNavQuickLinks(); + const persistedSet = new Set(quickLinkIds); + const unpinnedCandidates = quickLinkCandidates.filter((item) => !persistedSet.has(item.value)); + const atCapacity = quickLinkIds.length >= MAX_QUICK_LINKS; + const addEnabled = !atCapacity && unpinnedCandidates.length > 0; + const addDisabledReason = atCapacity + ? 'Remove a pin or reset to free a slot' + : 'No more destinations available on this node'; + const addOptions = unpinnedCandidates.map((item) => ({ + value: item.value, + label: item.label, + })); const { theme, accent, borderBoost, glow, contrast, uiFont, monoFont, typeScale, headingStyle, chartStyle, reducedEffects, reducedMotion, readability, @@ -413,13 +446,41 @@ export function AppearanceSection() { - + + + + + + + - {!topNavLabels && ( + {(topNavMode === 'classic' || topNavMode === 'smart') && ( + + + + )} + + {(topNavMode === 'classic' || topNavMode === 'smart') && !topNavLabels && ( )} - - - + {topNavMode === 'compact' && ( + +
+ {quickLinkIds.length === 0 ? ( +

No quick links pinned.

+ ) : ( +
    + {quickLinkIds.map((id) => { + const item = getAppNavItem(id); + const label = item?.label ?? id; + return ( +
  • + + {label} + + removeQuickLink(id)} + > + Remove + +
  • + ); + })} +
+ )} +
+ + Add quick link + + {addEnabled ? ( + { + if (value) addQuickLink(value as ActiveView); + }} + placeholder="Choose a destination" + /> + ) : ( +

{addDisabledReason}

+ )} +
+ + + Reset to defaults + + +
+
+ )}

diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index 3b46040a..083108da 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -28,6 +28,7 @@ import type { MuteRuleDraft } from '@/lib/muteRules'; import { SettingsSidebar } from './SettingsSidebar'; import { SettingsSectionContent } from './SettingsSectionContent'; import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsContext'; +import type { NavDestination } from '@/lib/navigation/appNavRegistry'; interface SettingsPageProps { currentSection: SectionId; @@ -35,6 +36,7 @@ interface SettingsPageProps { muteRulePrefill?: MuteRuleDraft | null; onMutePrefillConsumed?: () => void; onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; + quickLinkCandidates?: NavDestination[]; } export function SettingsPage(props: SettingsPageProps) { @@ -51,6 +53,7 @@ function SettingsPageInner({ muteRulePrefill = null, onMutePrefillConsumed, onOpenMuteRulesWithPrefill, + quickLinkCandidates, }: SettingsPageProps) { const { isAdmin } = useAuth(); const { isPaid } = useLicense(); @@ -210,6 +213,7 @@ function SettingsPageInner({ muteRulePrefill={muteRulePrefill} onMutePrefillConsumed={onMutePrefillConsumed} onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill} + quickLinkCandidates={quickLinkCandidates} />

diff --git a/frontend/src/components/settings/SettingsSectionContent.tsx b/frontend/src/components/settings/SettingsSectionContent.tsx index 90b37554..fc769562 100644 --- a/frontend/src/components/settings/SettingsSectionContent.tsx +++ b/frontend/src/components/settings/SettingsSectionContent.tsx @@ -25,6 +25,7 @@ import type { SectionId } from './index'; import type { MuteRuleDraft } from '@/lib/muteRules'; import LazyBoundary from '../LazyBoundary'; import { SectionGate } from './SectionGate'; +import type { NavDestination } from '@/lib/navigation/appNavRegistry'; // Paid-tier sections are loaded on demand. SectionGate returns null for // Community / unentitled operators before reaching the JSX that would mount @@ -79,10 +80,11 @@ function renderSection( muteRulePrefill: MuteRuleDraft | null | undefined, onMutePrefillConsumed: (() => void) | undefined, onOpenMuteRulesWithPrefill: ((draft: MuteRuleDraft) => void) | undefined, + quickLinkCandidates: NavDestination[] | undefined, ) { switch (sectionId) { case 'account': return ; - case 'appearance': return ; + case 'appearance': return ; case 'license': return ; case 'users': return ; case 'sso': return ; @@ -125,6 +127,7 @@ interface SettingsSectionContentProps { muteRulePrefill?: MuteRuleDraft | null; onMutePrefillConsumed?: () => void; onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; + quickLinkCandidates?: NavDestination[]; } /** @@ -140,11 +143,19 @@ export function SettingsSectionContent({ muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill, + quickLinkCandidates, }: SettingsSectionContentProps) { const item = getSettingsItem(sectionId); const element = useMemo( - () => renderSection(sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill), - [sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill], + () => renderSection( + sectionId, + onDirtyChange, + muteRulePrefill, + onMutePrefillConsumed, + onOpenMuteRulesWithPrefill, + quickLinkCandidates, + ), + [sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill, quickLinkCandidates], ); return ( <> diff --git a/frontend/src/components/settings/__tests__/AppearanceSection.test.tsx b/frontend/src/components/settings/__tests__/AppearanceSection.test.tsx index a0bd0417..a18e32d2 100644 --- a/frontend/src/components/settings/__tests__/AppearanceSection.test.tsx +++ b/frontend/src/components/settings/__tests__/AppearanceSection.test.tsx @@ -161,4 +161,22 @@ describe('AppearanceSection', () => { fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' })); expect((screen.getByRole('button', { name: 'Reset to default' }) as HTMLButtonElement).disabled).toBe(true); }); + + it('shows Navigation style and mode-conditional controls', () => { + localStorage.clear(); + render(); + expect(screen.getByText('Navigation')).toBeTruthy(); + expect(screen.getByRole('radiogroup', { name: 'Navigation style' })).toBeTruthy(); + // Smart default shows label toggle, hides quick links. + expect(screen.getByText('Top navigation labels')).toBeTruthy(); + expect(screen.queryByText('Quick links')).toBeNull(); + + fireEvent.click(screen.getByRole('radio', { name: 'Compact launcher' })); + expect(screen.getByText('Quick links')).toBeTruthy(); + expect(screen.queryByText('Top navigation labels')).toBeNull(); + + fireEvent.click(screen.getByRole('radio', { name: 'Classic bar' })); + expect(screen.getByText('Top navigation labels')).toBeTruthy(); + expect(screen.queryByText('Quick links')).toBeNull(); + }); }); diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index 1cac1953..de879f69 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -61,8 +61,8 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ id: 'appearance', group: 'personal', label: 'Appearance', - description: 'Visual style, readability, theme, accent, charts, and display preferences saved to this browser.', - keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display', 'calm', 'signature', 'readability', 'heading', 'chart', 'motion', 'effects'], + description: 'Visual style, readability, theme, accent, charts, display, and navigation preferences saved to this browser.', + keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display', 'calm', 'signature', 'readability', 'heading', 'chart', 'motion', 'effects', 'navigation', 'smart', 'launcher', 'quick links', 'topbar', 'top nav'], tier: null, scope: 'browser', }, diff --git a/frontend/src/hooks/__tests__/use-top-nav-mode.test.ts b/frontend/src/hooks/__tests__/use-top-nav-mode.test.ts new file mode 100644 index 00000000..61d090a6 --- /dev/null +++ b/frontend/src/hooks/__tests__/use-top-nav-mode.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useTopNavMode, TOP_NAV_MODE_KEY, parseTopNavMode } from '../use-top-nav-mode'; + +describe('useTopNavMode', () => { + beforeEach(() => localStorage.clear()); + afterEach(() => localStorage.clear()); + + it('defaults to smart when no value is stored', () => { + const { result } = renderHook(() => useTopNavMode()); + expect(result.current[0]).toBe('smart'); + }); + + it('falls back to smart for invalid storage', () => { + expect(parseTopNavMode('nope')).toBe('smart'); + localStorage.setItem(TOP_NAV_MODE_KEY, 'nope'); + const { result } = renderHook(() => useTopNavMode()); + expect(result.current[0]).toBe('smart'); + }); + + it('reads each valid stored mode', () => { + for (const mode of ['classic', 'smart', 'compact'] as const) { + localStorage.setItem(TOP_NAV_MODE_KEY, mode); + const { result, unmount } = renderHook(() => useTopNavMode()); + expect(result.current[0]).toBe(mode); + unmount(); + } + }); + + it('persists mode changes and syncs same-tab listeners', () => { + const a = renderHook(() => useTopNavMode()); + const b = renderHook(() => useTopNavMode()); + act(() => a.result.current[1]('compact')); + expect(a.result.current[0]).toBe('compact'); + expect(b.result.current[0]).toBe('compact'); + expect(localStorage.getItem(TOP_NAV_MODE_KEY)).toBe('compact'); + }); +}); diff --git a/frontend/src/hooks/__tests__/use-top-nav-quick-links.test.ts b/frontend/src/hooks/__tests__/use-top-nav-quick-links.test.ts new file mode 100644 index 00000000..f8e6228b --- /dev/null +++ b/frontend/src/hooks/__tests__/use-top-nav-quick-links.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { recommendedQuickLinkIds } from '@/lib/navigation/appNavRegistry'; +import { + useTopNavQuickLinks, + TOP_NAV_QUICK_LINKS_KEY, + parseStoredQuickLinks, + sanitizeQuickLinkIds, + MAX_QUICK_LINKS, +} from '../use-top-nav-quick-links'; + +describe('useTopNavQuickLinks', () => { + beforeEach(() => localStorage.clear()); + afterEach(() => localStorage.clear()); + + it('uses registry recommended defaults when the key is missing', () => { + const { result } = renderHook(() => useTopNavQuickLinks()); + expect(result.current.persistedIds).toEqual([...recommendedQuickLinkIds]); + }); + + it('uses registry recommended defaults for malformed JSON', () => { + localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, '{not-json'); + expect(parseStoredQuickLinks(localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY))).toEqual([ + ...recommendedQuickLinkIds, + ]); + }); + + it('keeps a valid empty array empty', () => { + localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, '[]'); + const { result } = renderHook(() => useTopNavQuickLinks()); + expect(result.current.persistedIds).toEqual([]); + }); + + it('sanitizes unknown, ineligible, and duplicate IDs and caps at five', () => { + expect( + sanitizeQuickLinkIds([ + 'dashboard', + 'dashboard', + 'settings', + 'not-a-view', + 'fleet', + 'security', + 'resources', + 'networking', + 'templates', + ]), + ).toEqual(['dashboard', 'fleet', 'security', 'resources', 'networking']); + expect( + sanitizeQuickLinkIds([ + 'dashboard', + 'fleet', + 'security', + 'resources', + 'networking', + 'templates', + ]).length, + ).toBe(MAX_QUICK_LINKS); + }); + + it('reset writes recommendedQuickLinkIds', () => { + localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, '[]'); + const { result } = renderHook(() => useTopNavQuickLinks()); + act(() => result.current.resetQuickLinks()); + expect(result.current.persistedIds).toEqual([...recommendedQuickLinkIds]); + expect(JSON.parse(localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY)!)).toEqual([ + ...recommendedQuickLinkIds, + ]); + }); + + it('remove can clear all pins without repopulating', () => { + const { result } = renderHook(() => useTopNavQuickLinks()); + act(() => { + for (const id of [...result.current.persistedIds]) { + result.current.removeQuickLink(id); + } + }); + expect(result.current.persistedIds).toEqual([]); + expect(JSON.parse(localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY)!)).toEqual([]); + }); + + it('add refuses beyond the persisted max of five', () => { + const { result } = renderHook(() => useTopNavQuickLinks()); + act(() => result.current.setPersistedIds([ + 'dashboard', + 'fleet', + 'security', + 'resources', + 'networking', + ])); + act(() => result.current.addQuickLink('templates')); + expect(result.current.persistedIds).toEqual([ + 'dashboard', + 'fleet', + 'security', + 'resources', + 'networking', + ]); + }); + + it('syncs a second hook in the same tab', () => { + const a = renderHook(() => useTopNavQuickLinks()); + const b = renderHook(() => useTopNavQuickLinks()); + act(() => a.result.current.setPersistedIds(['networking'])); + expect(b.result.current.persistedIds).toEqual(['networking']); + }); +}); diff --git a/frontend/src/hooks/use-top-nav-mode.ts b/frontend/src/hooks/use-top-nav-mode.ts new file mode 100644 index 00000000..34931b73 --- /dev/null +++ b/frontend/src/hooks/use-top-nav-mode.ts @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useState } from 'react'; +import { SENCHO_SETTINGS_CHANGED } from '@/lib/events'; + +export const TOP_NAV_MODE_KEY = 'sencho.appearance.topNavMode'; + +export type TopNavMode = 'classic' | 'smart' | 'compact'; + +const VALID: ReadonlySet = new Set(['classic', 'smart', 'compact']); + +/** Missing or invalid storage resolves to Smart (recommended default). */ +export function parseTopNavMode(raw: string | null): TopNavMode { + if (raw && VALID.has(raw)) return raw as TopNavMode; + return 'smart'; +} + +function readStored(): TopNavMode { + if (typeof window === 'undefined') return 'smart'; + try { + return parseTopNavMode(window.localStorage.getItem(TOP_NAV_MODE_KEY)); + } catch { + return 'smart'; + } +} + +export function useTopNavMode(): [TopNavMode, (next: TopNavMode) => void] { + const [mode, setModeState] = useState(readStored); + + useEffect(() => { + function onSettingsChanged() { + setModeState(readStored()); + } + window.addEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged); + return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged); + }, []); + + useEffect(() => { + function onStorage(event: StorageEvent) { + if (event.key !== TOP_NAV_MODE_KEY) return; + setModeState(parseTopNavMode(event.newValue)); + } + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, []); + + const setMode = useCallback((next: TopNavMode) => { + try { + window.localStorage.setItem(TOP_NAV_MODE_KEY, next); + } catch { + // ignore; localStorage may be unavailable + } + setModeState(next); + window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED)); + }, []); + + return [mode, setMode]; +} diff --git a/frontend/src/hooks/use-top-nav-quick-links.ts b/frontend/src/hooks/use-top-nav-quick-links.ts new file mode 100644 index 00000000..8adf69fb --- /dev/null +++ b/frontend/src/hooks/use-top-nav-quick-links.ts @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + isQuickLinkEligibleId, + recommendedQuickLinkIds, +} from '@/lib/navigation/appNavRegistry'; +import type { ActiveView } from '@/lib/router/routeTypes'; +import { SENCHO_SETTINGS_CHANGED } from '@/lib/events'; + +export const TOP_NAV_QUICK_LINKS_KEY = 'sencho.appearance.topNavQuickLinks'; +export const MAX_QUICK_LINKS = 5; + +/** + * Sanitize a candidate ID list: keep registry-known eligible IDs, dedupe, + * and cap at MAX_QUICK_LINKS. Does not expand empty arrays to defaults. + */ +export function sanitizeQuickLinkIds(ids: unknown): ActiveView[] { + if (!Array.isArray(ids)) return [...recommendedQuickLinkIds]; + const seen = new Set(); + const out: ActiveView[] = []; + for (const raw of ids) { + if (typeof raw !== 'string' || !isQuickLinkEligibleId(raw)) continue; + if (seen.has(raw)) continue; + seen.add(raw); + out.push(raw); + if (out.length >= MAX_QUICK_LINKS) break; + } + return out; +} + +/** + * Parse stored JSON. Missing key or malformed JSON → recommended defaults. + * Valid JSON array (including []) is sanitized and returned as-is (empty stays empty). + */ +export function parseStoredQuickLinks(raw: string | null): ActiveView[] { + if (raw === null) return [...recommendedQuickLinkIds]; + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return [...recommendedQuickLinkIds]; + return sanitizeQuickLinkIds(parsed); + } catch { + return [...recommendedQuickLinkIds]; + } +} + +function readStored(): ActiveView[] { + if (typeof window === 'undefined') return [...recommendedQuickLinkIds]; + try { + return parseStoredQuickLinks(window.localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY)); + } catch { + return [...recommendedQuickLinkIds]; + } +} + +function writeStored(ids: ActiveView[]): void { + try { + window.localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, JSON.stringify(ids)); + } catch { + // ignore + } +} + +export interface TopNavQuickLinksApi { + persistedIds: ActiveView[]; + setPersistedIds: (next: ActiveView[]) => void; + addQuickLink: (value: ActiveView) => void; + removeQuickLink: (value: ActiveView) => void; + resetQuickLinks: () => void; +} + +export function useTopNavQuickLinks(): TopNavQuickLinksApi { + const [persistedIds, setPersistedState] = useState(readStored); + + useEffect(() => { + function onSettingsChanged() { + setPersistedState(readStored()); + } + window.addEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged); + return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged); + }, []); + + useEffect(() => { + function onStorage(event: StorageEvent) { + if (event.key !== TOP_NAV_QUICK_LINKS_KEY) return; + setPersistedState(parseStoredQuickLinks(event.newValue)); + } + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, []); + + const commit = useCallback((next: ActiveView[]) => { + const sanitized = sanitizeQuickLinkIds(next); + writeStored(sanitized); + setPersistedState(sanitized); + window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED)); + }, []); + + const setPersistedIds = useCallback((next: ActiveView[]) => { + commit(next); + }, [commit]); + + const addQuickLink = useCallback((value: ActiveView) => { + setPersistedState((prev) => { + if (prev.includes(value) || prev.length >= MAX_QUICK_LINKS) return prev; + if (!isQuickLinkEligibleId(value)) return prev; + const next = [...prev, value]; + writeStored(next); + window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED)); + return next; + }); + }, []); + + const removeQuickLink = useCallback((value: ActiveView) => { + setPersistedState((prev) => { + const next = prev.filter((id) => id !== value); + writeStored(next); + window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED)); + return next; + }); + }, []); + + const resetQuickLinks = useCallback(() => { + commit([...recommendedQuickLinkIds]); + }, [commit]); + + return { + persistedIds, + setPersistedIds, + addQuickLink, + removeQuickLink, + resetQuickLinks, + }; +} diff --git a/frontend/src/lib/navigation/appNavRegistry.test.ts b/frontend/src/lib/navigation/appNavRegistry.test.ts new file mode 100644 index 00000000..a488a773 --- /dev/null +++ b/frontend/src/lib/navigation/appNavRegistry.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { + APP_NAV_REGISTRY, + recommendedQuickLinkIds, +} from './appNavRegistry'; + +describe('appNavRegistry', () => { + it('has unique ActiveView values', () => { + const values = APP_NAV_REGISTRY.map((item) => item.value); + expect(new Set(values).size).toBe(values.length); + }); + + it('gives every non-Settings destination exactly one Smart placement of primary or overflow', () => { + for (const item of APP_NAV_REGISTRY) { + if (item.value === 'settings') { + expect(item.smart).toBe('launcher-only'); + continue; + } + expect(item.smart === 'primary' || item.smart === 'overflow').toBe(true); + } + }); + + it('exports recommendedQuickLinkIds from defaultQuickLink metadata in that order', () => { + const flagged = APP_NAV_REGISTRY.filter((item) => item.defaultQuickLink).map((item) => item.value); + expect(new Set(recommendedQuickLinkIds)).toEqual(new Set(flagged)); + expect([...recommendedQuickLinkIds]).toEqual([ + 'dashboard', + 'fleet', + 'security', + 'resources', + ]); + for (const id of recommendedQuickLinkIds) { + const item = APP_NAV_REGISTRY.find((entry) => entry.value === id); + expect(item?.quickLinkEligible).toBe(true); + expect(item?.defaultQuickLink).toBe(true); + } + }); + + it('keeps Networking after Resources in Classic order metadata', () => { + const resources = APP_NAV_REGISTRY.find((item) => item.value === 'resources'); + const networking = APP_NAV_REGISTRY.find((item) => item.value === 'networking'); + expect(resources).toBeDefined(); + expect(networking).toBeDefined(); + expect(networking!.classicOrder).toBeGreaterThan(resources!.classicOrder); + }); +}); diff --git a/frontend/src/lib/navigation/appNavRegistry.ts b/frontend/src/lib/navigation/appNavRegistry.ts new file mode 100644 index 00000000..f40e2937 --- /dev/null +++ b/frontend/src/lib/navigation/appNavRegistry.ts @@ -0,0 +1,192 @@ +import { + Terminal, CloudDownload, Home, HardDrive, ScrollText, + Activity, Radar, RefreshCw, Clock, ShieldCheck, Network, Settings, +} from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import type { ActiveView } from '@/lib/router/routeTypes'; + +export type NavGroup = + | 'overview' + | 'stack-workspace' + | 'fleet' + | 'security-review' + | 'operations' + | 'tools' + | 'settings'; + +export type SmartPlacement = 'primary' | 'overflow' | 'launcher-only'; + +/** Shared destination shape for TopBar, palette, and mobile consumers. */ +export interface NavDestination { + value: ActiveView; + label: string; + icon: LucideIcon; +} + +export interface AppNavItem extends NavDestination { + group: NavGroup; + /** Classic strip order (ascending). Settings uses a high value and is excluded from Classic. */ + classicOrder: number; + smart: SmartPlacement; + quickLinkEligible: boolean; + defaultQuickLink: boolean; +} + +export const NAV_GROUP_META: readonly { id: NavGroup; label: string }[] = [ + { id: 'overview', label: 'Overview' }, + { id: 'stack-workspace', label: 'Stack workspace' }, + { id: 'fleet', label: 'Fleet' }, + { id: 'security-review', label: 'Security & review' }, + { id: 'operations', label: 'Operations' }, + { id: 'tools', label: 'Tools' }, + { id: 'settings', label: 'Settings' }, +] as const; + +export const APP_NAV_REGISTRY: readonly AppNavItem[] = [ + { + value: 'dashboard', + label: 'Home', + icon: Home, + group: 'overview', + classicOrder: 10, + smart: 'primary', + quickLinkEligible: true, + defaultQuickLink: true, + }, + { + value: 'fleet', + label: 'Fleet', + icon: Radar, + group: 'fleet', + classicOrder: 20, + smart: 'primary', + quickLinkEligible: true, + defaultQuickLink: true, + }, + { + value: 'resources', + label: 'Resources', + icon: HardDrive, + group: 'fleet', + classicOrder: 30, + smart: 'primary', + quickLinkEligible: true, + defaultQuickLink: true, + }, + { + value: 'networking', + label: 'Networking', + icon: Network, + group: 'fleet', + classicOrder: 40, + smart: 'primary', + quickLinkEligible: true, + defaultQuickLink: false, + }, + { + value: 'security', + label: 'Security', + icon: ShieldCheck, + group: 'security-review', + classicOrder: 50, + smart: 'primary', + quickLinkEligible: true, + defaultQuickLink: true, + }, + { + value: 'templates', + label: 'App Store', + icon: CloudDownload, + group: 'stack-workspace', + classicOrder: 60, + smart: 'primary', + quickLinkEligible: true, + defaultQuickLink: false, + }, + { + value: 'global-observability', + label: 'Logs', + icon: Activity, + group: 'operations', + classicOrder: 70, + smart: 'overflow', + quickLinkEligible: true, + defaultQuickLink: false, + }, + { + value: 'auto-updates', + label: 'Update', + icon: RefreshCw, + group: 'operations', + classicOrder: 80, + smart: 'overflow', + quickLinkEligible: true, + defaultQuickLink: false, + }, + { + value: 'scheduled-ops', + label: 'Schedules', + icon: Clock, + group: 'operations', + classicOrder: 90, + smart: 'overflow', + quickLinkEligible: true, + defaultQuickLink: false, + }, + { + value: 'host-console', + label: 'Console', + icon: Terminal, + group: 'tools', + classicOrder: 100, + smart: 'overflow', + quickLinkEligible: true, + defaultQuickLink: false, + }, + { + value: 'audit-log', + label: 'Audit', + icon: ScrollText, + group: 'security-review', + classicOrder: 110, + smart: 'overflow', + quickLinkEligible: true, + defaultQuickLink: false, + }, + { + value: 'settings', + label: 'Settings', + icon: Settings, + group: 'settings', + classicOrder: 999, + smart: 'launcher-only', + quickLinkEligible: false, + defaultQuickLink: false, + }, +] as const; + +/** + * Recommended quick-link pins for missing/malformed storage and Reset. + * Order is intentional (Home, Fleet, Security, Resources), not Classic strip order. + */ +export const recommendedQuickLinkIds: readonly ActiveView[] = [ + 'dashboard', + 'fleet', + 'security', + 'resources', +] as const; + +const BY_VALUE = new Map(APP_NAV_REGISTRY.map((item) => [item.value, item])); + +export function getAppNavItem(value: ActiveView): AppNavItem | undefined { + return BY_VALUE.get(value); +} + +export function isQuickLinkEligibleId(value: string): value is ActiveView { + const item = BY_VALUE.get(value as ActiveView); + return Boolean(item?.quickLinkEligible); +} + +export function toNavDestination(item: AppNavItem): NavDestination { + return { value: item.value, label: item.label, icon: item.icon }; +} diff --git a/frontend/src/lib/navigation/buildNavigationModel.test.ts b/frontend/src/lib/navigation/buildNavigationModel.test.ts new file mode 100644 index 00000000..d6ffba00 --- /dev/null +++ b/frontend/src/lib/navigation/buildNavigationModel.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { buildNavigationModel } from './buildNavigationModel'; +import type { ReachabilityContext } from '@/lib/routing/reachability'; + +function makeCtx(overrides: Partial = {}): ReachabilityContext { + return { + isAdmin: true, + isPaid: true, + can: () => true, + isRemote: false, + hasFleetCapability: true, + containerLabelsEnabled: true, + permissionsStatus: 'ready', + licenseStatus: 'ready', + experimental: true, + experimentalReady: true, + ...overrides, + }; +} + +describe('buildNavigationModel', () => { + it('returns exact Classic page order including Networking after Resources', () => { + const model = buildNavigationModel(makeCtx()); + expect(model.allPageItems.map((item) => item.value)).toEqual([ + 'dashboard', + 'fleet', + 'resources', + 'networking', + 'security', + 'templates', + 'global-observability', + 'auto-updates', + 'scheduled-ops', + 'host-console', + 'audit-log', + ]); + expect(model.allPageItems.some((item) => item.value === 'settings')).toBe(false); + }); + + it('partitions Smart primary and overflow disjointly covering all page destinations', () => { + const model = buildNavigationModel(makeCtx()); + const primary = model.primaryItems.map((item) => item.value); + const overflow = model.overflowGroups.flatMap((g) => g.items.map((i) => i.value)); + expect(primary).toEqual([ + 'dashboard', + 'fleet', + 'resources', + 'networking', + 'security', + 'templates', + ]); + expect(primary.filter((v) => overflow.includes(v))).toEqual([]); + expect(new Set([...primary, ...overflow])).toEqual( + new Set(model.allPageItems.map((item) => item.value)), + ); + }); + + it('includes Settings only in launcher groups', () => { + const model = buildNavigationModel(makeCtx()); + const launcherValues = model.launcherGroups.flatMap((g) => g.items.map((i) => i.value)); + expect(launcherValues).toContain('settings'); + expect(model.allPageItems.map((i) => i.value)).not.toContain('settings'); + expect(model.primaryItems.map((i) => i.value)).not.toContain('settings'); + }); + + it('keeps Networking reachable on a remote node while dropping hub-only pages', () => { + const model = buildNavigationModel(makeCtx({ isRemote: true })); + const values = model.allPageItems.map((item) => item.value); + expect(values).toContain('networking'); + expect(values).toContain('resources'); + expect(values).toContain('security'); + expect(values).toContain('templates'); + expect(values).not.toContain('fleet'); + expect(values).not.toContain('global-observability'); + expect(values).not.toContain('auto-updates'); + expect(values).not.toContain('scheduled-ops'); + expect(values).not.toContain('audit-log'); + }); + + it('omits Console until experimental discovery is ready and enabled via reachCtx only', () => { + expect( + buildNavigationModel(makeCtx({ experimentalReady: false, experimental: false })) + .allPageItems.map((i) => i.value), + ).not.toContain('host-console'); + expect( + buildNavigationModel(makeCtx({ experimentalReady: true, experimental: false })) + .allPageItems.map((i) => i.value), + ).not.toContain('host-console'); + expect( + buildNavigationModel(makeCtx({ experimentalReady: true, experimental: true })) + .allPageItems.map((i) => i.value), + ).toContain('host-console'); + }); + + it('excludes hidden views from quick-link candidates', () => { + const model = buildNavigationModel(makeCtx({ isRemote: true, isPaid: false })); + const values = model.quickLinkCandidates.map((i) => i.value); + expect(values).toContain('networking'); + expect(values).not.toContain('fleet'); + expect(values).not.toContain('settings'); + }); +}); diff --git a/frontend/src/lib/navigation/buildNavigationModel.ts b/frontend/src/lib/navigation/buildNavigationModel.ts new file mode 100644 index 00000000..82eb59d4 --- /dev/null +++ b/frontend/src/lib/navigation/buildNavigationModel.ts @@ -0,0 +1,83 @@ +import { + APP_NAV_REGISTRY, + NAV_GROUP_META, + toNavDestination, + type AppNavItem, + type NavDestination, + type NavGroup, +} from '@/lib/navigation/appNavRegistry'; +import { isViewHidden, type ReachabilityContext } from '@/lib/routing/reachability'; + +export interface NavGroupBucket { + group: NavGroup; + label: string; + items: NavDestination[]; +} + +export interface ReachableNavigationModel { + /** Classic / palette / mobile page list (excludes Settings). Exact Classic order. */ + allPageItems: NavDestination[]; + primaryItems: NavDestination[]; + overflowGroups: NavGroupBucket[]; + launcherGroups: NavGroupBucket[]; + quickLinkCandidates: NavDestination[]; +} + +function isVisuallyDiscoverable(item: AppNavItem, reachCtx: ReachabilityContext): boolean { + if (item.smart === 'launcher-only') { + // Settings is always discoverable in the launcher when the operator can open Settings. + return true; + } + // Console: fail-closed visual discovery until /meta settles and the flag is on. + // URL normalization still uses isViewHidden cold-load deferral separately. + if (item.value === 'host-console') { + if (!reachCtx.experimentalReady || !reachCtx.experimental) return false; + return !isViewHidden(item.value, reachCtx); + } + return !isViewHidden(item.value, reachCtx); +} + +function bucketByGroup(items: AppNavItem[]): NavGroupBucket[] { + const byGroup = new Map(); + for (const item of items) { + const list = byGroup.get(item.group) ?? []; + list.push(toNavDestination(item)); + byGroup.set(item.group, list); + } + return NAV_GROUP_META + .map(({ id, label }) => { + const groupItems = byGroup.get(id); + if (!groupItems?.length) return null; + return { group: id, label, items: groupItems }; + }) + .filter((bucket): bucket is NavGroupBucket => bucket !== null); +} + +/** Derive reachable navigation collections from a single ReachabilityContext. */ +export function buildNavigationModel(reachCtx: ReachabilityContext): ReachableNavigationModel { + const reachable = APP_NAV_REGISTRY.filter((item) => isVisuallyDiscoverable(item, reachCtx)); + + const pages = reachable + .filter((item) => item.smart !== 'launcher-only') + .slice() + .sort((a, b) => a.classicOrder - b.classicOrder); + + const allPageItems = pages.map(toNavDestination); + const primaryItems = pages + .filter((item) => item.smart === 'primary') + .map(toNavDestination); + const overflowItems = pages.filter((item) => item.smart === 'overflow'); + const overflowGroups = bucketByGroup(overflowItems); + const launcherGroups = bucketByGroup(reachable); + const quickLinkCandidates = reachable + .filter((item) => item.quickLinkEligible) + .map(toNavDestination); + + return { + allPageItems, + primaryItems, + overflowGroups, + launcherGroups, + quickLinkCandidates, + }; +}