feat(ui): add Classic, Smart, and Compact desktop navigation styles (#1642)

* feat(ui): add Classic, Smart, and Compact desktop navigation styles

Introduce a shared app-nav registry and reachable model so TopBar, the
command palette, and mobile menus share one destination source. Smart bar
is the default; Appearance gains a Navigation subsection with quick links.

* fix(e2e): stop clearing top-nav prefs on every reload

The desktop-navigation suite used addInitScript to wipe mode storage,
which re-ran on reload and undid the classic/compact values under test.

* fix(ui): harden nav PR docs and Compact quick-link coverage

Drop the partial docs-refresh import that left missing image assets and a stale Display screenshot, keep navigation-scoped operator docs against main, and add an E2E path that proves Compact quick-link add, persist, and render after reload.

* fix(ui): polish Compact quick links and menu mastheads

Align Smart/Compact menus with Theme chrome, keep pin labels always visible, and drive add capacity from persisted pins (max five) with a trailing + picker and per-pin remove.

* test(e2e): exact-match Compact Networking pin locator

Avoid Playwright strict-mode clash with Actions for Networking.

* fix(ui): simplify Compact quick link removal and fix + button trailing

Remove the (...) dropdown per quick link in Compact mode. Right-click
context menu remains as the sole on-bar removal affordance. Fix the +
add-button to trail quick links rather than pinning to the far right
by removing flex-1 from the quick-link rail.
This commit is contained in:
Anso
2026-07-16 21:48:03 -04:00
committed by GitHub
parent d8e4ede94f
commit 25586fc8ab
24 changed files with 1871 additions and 216 deletions
+27 -1
View File
@@ -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<typeof item> => 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}
/>
</div>
);
@@ -1033,6 +1058,7 @@ export default function EditorLayout() {
headerActions={mobileMastheadActions}
selectedSection={mobileSettingsSection}
onSelectedSectionChange={setMobileSettingsSection}
quickLinkCandidates={navModel.quickLinkCandidates}
/>
);
case 'security':
@@ -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<string, StackUpdateInfo>;
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}
/>
);
}
@@ -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;
}
+593 -140
View File
@@ -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 (
<span
aria-hidden
className="pointer-events-none absolute inset-x-0 -bottom-px h-[2px] bg-brand"
/>
);
}
function TopBarMenuMasthead({ title }: { title: string }) {
return (
<div className="relative overflow-hidden">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand/[0.05] via-transparent to-transparent" />
<div className="absolute inset-y-0 left-0 w-[2px] bg-brand/60" />
<div className="relative flex items-center px-[var(--density-row-x)] py-[var(--density-tile-y)]">
<span className="font-heading text-xl leading-none text-stat-value">{title}</span>
</div>
</div>
);
}
function PanelMenuContent({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<DropdownMenuContent align="start" sideOffset={8} className="w-56 overflow-hidden rounded-md p-0">
<TopBarMenuMasthead title={title} />
<div className="border-t border-card-border/60 p-1">{children}</div>
</DropdownMenuContent>
);
}
function DesktopNavButton({
item,
isActive,
showLabels,
onNavigate,
}: {
item: TopBarNavItem;
isActive: boolean;
showLabels: boolean;
onNavigate: (value: string) => void;
}) {
const Icon = item.icon;
const button = (
<button
type="button"
onClick={() => onNavigate(item.value)}
aria-label={item.label}
aria-current={isActive ? 'page' : undefined}
className={navButtonClass(isActive)}
>
<Icon className="w-4 h-4 shrink-0" strokeWidth={1.5} />
{showLabels && <span className="hidden xl:inline">{item.label}</span>}
<ActiveUnderline active={isActive} />
</button>
);
if (showLabels) return <Fragment>{button}</Fragment>;
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="bottom">{item.label}</TooltipContent>
</Tooltip>
);
}
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<string>;
atCapacity?: boolean;
}) {
return (
<>
{groups.map((group, index) => (
<Fragment key={group.group}>
{index > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
{group.label}
</DropdownMenuLabel>
{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 = (
<DropdownMenuItem
key={item.value}
onSelect={() => 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',
)}
>
<Icon className="size-4 shrink-0" strokeWidth={1.5} />
{item.label}
</DropdownMenuItem>
);
if (!canContextAdd) return menuItem;
return (
<ContextMenu key={item.value}>
<ContextMenuTrigger asChild>{menuItem}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onSelect={() => onAddQuickLink?.(item.value)}
>
Add to quick links
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
})}
</Fragment>
))}
</>
);
}
function ClassicStrip({
navItems,
activeView,
showLabels,
onNavigate,
}: {
navItems: TopBarNavItem[];
activeView: string;
showLabels: boolean;
onNavigate: (value: string) => void;
}) {
return (
<>
{navItems.map((item) => (
<DesktopNavButton
key={item.value}
item={item}
isActive={activeView === item.value}
showLabels={showLabels}
onNavigate={onNavigate}
/>
))}
</>
);
}
function SmartStrip({
primaryItems,
overflowGroups,
activeView,
showLabels,
onNavigate,
}: {
primaryItems: TopBarNavItem[];
overflowGroups: NavGroupBucket[];
activeView: string;
showLabels: boolean;
onNavigate: (value: string) => void;
}) {
const overflowValues = useMemo(
() => new Set<string>(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) => (
<DesktopNavButton
key={item.value}
item={item}
isActive={activeView === item.value}
showLabels={showLabels}
onNavigate={onNavigate}
/>
))}
{hasOverflow && (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="More navigation"
aria-current={moreActive ? 'page' : undefined}
className={navButtonClass(moreActive)}
>
<MoreHorizontal className="w-4 h-4 shrink-0" strokeWidth={1.5} />
<span>More</span>
<ActiveUnderline active={moreActive} />
</button>
</DropdownMenuTrigger>
<PanelMenuContent title="More">
<GroupedMenuItems
groups={overflowGroups}
activeView={activeView}
onSelect={onNavigate}
/>
</PanelMenuContent>
</DropdownMenu>
)}
</>
);
}
function CompactQuickLink({
item,
isActive,
onNavigate,
onRemove,
}: {
item: NavDestination;
isActive: boolean;
onNavigate: (value: string) => void;
onRemove: (value: string) => void;
}) {
const Icon = item.icon;
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<button
type="button"
onClick={() => onNavigate(item.value)}
aria-label={item.label}
aria-current={isActive ? 'page' : undefined}
className={cn('relative inline-flex h-full shrink-0 items-stretch', navButtonClass(isActive))}
>
<Icon className="w-4 h-4 shrink-0" strokeWidth={1.5} />
<span className="inline">{item.label}</span>
<ActiveUnderline active={isActive} />
</button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => onRemove(item.value)}>Remove</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
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<string>(launcherGroups.flatMap((g) => g.items.map((i) => i.value))),
[launcherGroups],
);
const quickValues = useMemo(
() => new Set<string>(quickLinks.map((i) => i.value)),
[quickLinks],
);
const persistedSet = useMemo(
() => new Set<string>(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 (
<div className="flex min-w-0 flex-1 self-stretch items-stretch">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Open navigation launcher"
aria-current={launcherActive ? 'page' : undefined}
data-sn-launcher-active={launcherActive ? 'true' : 'false'}
className={navButtonClass(launcherActive)}
>
<Menu className="w-4 h-4 shrink-0" strokeWidth={1.5} />
<ActiveUnderline active={launcherActive} />
</button>
</DropdownMenuTrigger>
<PanelMenuContent title="Navigate">
<GroupedMenuItems
groups={launcherGroups}
activeView={activeView}
onSelect={selectDestination}
onAddQuickLink={onAddQuickLink}
persistedIds={persistedSet}
atCapacity={atCapacity}
/>
</PanelMenuContent>
</DropdownMenu>
<div
data-sn-quick-link-rail
className="flex min-w-0 self-stretch items-stretch overflow-x-auto [scrollbar-width:none]"
>
{quickLinks.map((item) => (
<CompactQuickLink
key={item.value}
item={item}
isActive={activeView === item.value}
onNavigate={onNavigate}
onRemove={(value) => onRemoveQuickLink?.(value)}
/>
))}
</div>
{addEnabled ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Add quick link"
className={navButtonClass(false)}
>
<Plus className="w-4 h-4 shrink-0" strokeWidth={1.5} />
</button>
</DropdownMenuTrigger>
<PanelMenuContent title="Add quick link">
{unpinnedCandidates.map((item) => {
const Icon = item.icon;
const isCurrent = item.value === activeView;
return (
<DropdownMenuItem
key={item.value}
onSelect={() => onAddQuickLink?.(item.value)}
className={cn(
'gap-2 font-mono text-[11px] uppercase tracking-[0.14em]',
isCurrent && 'bg-accent text-accent-foreground',
)}
>
<Icon className="size-4 shrink-0" strokeWidth={1.5} />
{item.label}
</DropdownMenuItem>
);
})}
</PanelMenuContent>
</DropdownMenu>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={0}
className="inline-flex h-full shrink-0 items-stretch"
title={addDisabledReason}
>
<button
type="button"
aria-label="Add quick link"
aria-disabled="true"
disabled
className={cn(navButtonClass(false), 'pointer-events-none opacity-40')}
>
<Plus className="w-4 h-4 shrink-0" strokeWidth={1.5} />
</button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">{addDisabledReason}</TooltipContent>
</Tooltip>
)}
</div>
);
}
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 (
<div
data-sn-chrome="topbar"
className={cn(
'relative flex h-14 items-center gap-3 px-4',
'border-b border-glass-border bg-sidebar backdrop-blur-md',
'shadow-chrome-top',
)}
const stripLabels = navMode !== 'compact' && showLabels;
const centered = navMode !== 'compact' && !stripLabels && navAlign === 'center';
const primaryItems = navModel?.primaryItems ?? navItems;
const overflowGroups = navModel?.overflowGroups ?? [];
const launcherGroups = navModel?.launcherGroups ?? [];
const quickLinkCandidates = navModel?.quickLinkCandidates ?? [];
return (
<div
data-sn-chrome="topbar"
data-sn-nav-mode={navMode}
className={cn(
'relative flex h-14 items-center gap-3 px-4',
'border-b border-glass-border bg-sidebar backdrop-blur-md',
'shadow-chrome-top',
)}
>
{centered && <div className="flex-1 min-w-0" />}
<TooltipProvider delayDuration={300} disableHoverableContent>
<nav
aria-label="Primary"
className={cn(
'hidden md:flex self-stretch items-stretch',
stripLabels && 'min-w-0 flex-1 overflow-x-auto [scrollbar-width:none]',
navMode === 'compact' && 'min-w-0 flex-1 overflow-x-visible',
!stripLabels && centered && 'shrink-0',
)}
>
{/* LEFT SPACER: balances the right utilities so the nav centers. */}
{centered && <div className="flex-1 min-w-0" />}
{navMode === 'classic' && (
<ClassicStrip
navItems={navItems}
activeView={activeView}
showLabels={stripLabels}
onNavigate={onNavigate}
/>
)}
{navMode === 'smart' && (
<SmartStrip
primaryItems={primaryItems}
overflowGroups={overflowGroups}
activeView={activeView}
showLabels={stripLabels}
onNavigate={onNavigate}
/>
)}
{navMode === 'compact' && (
<CompactStrip
launcherGroups={launcherGroups}
quickLinks={quickLinks}
quickLinkCandidates={quickLinkCandidates}
persistedQuickLinkIds={persistedQuickLinkIds}
activeView={activeView}
onNavigate={onNavigate}
onAddQuickLink={onAddQuickLink}
onRemoveQuickLink={onRemoveQuickLink}
onOpenSettings={onOpenSettings}
/>
)}
</nav>
</TooltipProvider>
{/* NAV ZONE: Navigation (hidden on mobile) */}
<TooltipProvider delayDuration={300} disableHoverableContent>
<nav
aria-label="Primary"
className={cn(
'hidden md:flex self-stretch items-stretch',
showLabels && 'min-w-0 flex-1 overflow-x-auto [scrollbar-width:none]',
!showLabels && centered && 'shrink-0',
)}
>
{navItems.map(({ value, label, icon: Icon }) => {
const isActive = activeView === value;
const button = (
<button
onClick={() => onNavigate(value)}
aria-label={label}
aria-current={isActive ? 'page' : undefined}
className={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',
)}
>
<Icon className="w-4 h-4 shrink-0" strokeWidth={1.5} />
{showLabels && <span className="hidden xl:inline">{label}</span>}
{isActive && (
<span
aria-hidden
className="pointer-events-none absolute inset-x-0 -bottom-px h-[2px] bg-brand"
/>
)}
</button>
);
// Icon-only mode: a tooltip names the destination on hover/focus. With
// labels on, the visible text carries it, so the button renders bare.
return showLabels ? (
<Fragment key={value}>{button}</Fragment>
) : (
<Tooltip key={value}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
})}
</nav>
</TooltipProvider>
<div
className={cn(
'flex items-center justify-end gap-2',
centered ? 'flex-1 min-w-0' : 'relative z-10 shrink-0',
!centered && !stripLabels && navMode !== 'compact' && 'flex-1 min-w-0',
)}
>
{search}
{themeSwitch}
{notifications}
{userMenu}
{/* RIGHT ZONE: Utilities + identity pin */}
<div
className={cn(
'flex items-center justify-end gap-2',
centered ? 'flex-1 min-w-0' : showLabels ? 'relative z-10 shrink-0' : 'flex-1 min-w-0',
)}
<Sheet open={mobileNavOpen} onOpenChange={onMobileNavOpenChange}>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label="Open navigation menu"
className="h-8 w-8 rounded-lg md:hidden"
>
{search}
{themeSwitch}
{notifications}
{userMenu}
{/* Mobile nav trigger */}
<Sheet open={mobileNavOpen} onOpenChange={onMobileNavOpenChange}>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label="Open navigation menu"
className="h-8 w-8 rounded-lg md:hidden"
>
<Menu className="w-4 h-4" strokeWidth={1.5} />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-64 p-0">
<div className="p-4 border-b">
<p className="text-sm font-medium">Navigation</p>
</div>
<nav className="flex flex-col p-2 gap-1">
{navItems.map(({ value, label, icon: Icon }) => (
<button
key={value}
onClick={() => {
onNavigate(value);
onMobileNavOpenChange(false);
}}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors',
activeView === value
? 'bg-glass-highlight font-medium text-foreground'
: 'text-muted-foreground hover:bg-glass-highlight hover:text-foreground',
)}
>
<Icon className="w-4 h-4" strokeWidth={1.5} />
{label}
</button>
))}
</nav>
</SheetContent>
</Sheet>
<Menu className="w-4 h-4" strokeWidth={1.5} />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-64 p-0">
<div className="p-4 border-b">
<p className="text-sm font-medium">Navigation</p>
</div>
</div>
);
<nav className="flex flex-col p-2 gap-1">
{navItems.map(({ value, label, icon: Icon }) => (
<button
key={value}
type="button"
onClick={() => {
onNavigate(value);
onMobileNavOpenChange(false);
}}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors',
activeView === value
? 'bg-glass-highlight font-medium text-foreground'
: 'text-muted-foreground hover:bg-glass-highlight hover:text-foreground',
)}
>
<Icon className="w-4 h-4" strokeWidth={1.5} />
{label}
</button>
))}
</nav>
</SheetContent>
</Sheet>
</div>
</div>
);
}
@@ -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();
});
});
@@ -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({
<span className="font-heading text-[30px] leading-[34px] text-stat-value">{item.label}</span>
</div>
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden px-4 pb-8 pt-4 flex flex-col gap-6">
<SettingsSectionContent sectionId={activeSection} onDirtyChange={NOOP} showDescription />
<SettingsSectionContent
sectionId={activeSection}
onDirtyChange={NOOP}
showDescription
quickLinkCandidates={quickLinkCandidates}
/>
</div>
</div>
);
@@ -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() {
</SettingsField>
<SettingsField
label="Top navigation labels"
helper="Show text labels beside top navigation icons. Turn off for a more compact navigation bar."
label="Log chip color"
helper="Unified uses the accent color for all service chips. Per-service assigns each service a stable label color for faster visual scanning."
>
<TogglePill checked={topNavLabels} onChange={setTopNavLabels} />
<SegmentedControl
value={chipColorMode}
options={CHIP_COLOR_OPTIONS}
onChange={setChipColorMode}
ariaLabel="Log chip color mode"
/>
</SettingsField>
</SettingsSection>
<SettingsSection title="Navigation" kicker="this browser">
<SettingsField
label="Navigation style"
helper="Smart bar is the recommended default: primary destinations stay visible, and the rest live under More. Classic keeps the full horizontal strip. Compact launcher puts destinations in a menu with optional quick links."
>
<SegmentedControl
value={topNavMode}
options={TOP_NAV_MODE_OPTIONS}
onChange={setTopNavMode}
ariaLabel="Navigation style"
/>
</SettingsField>
{!topNavLabels && (
{(topNavMode === 'classic' || topNavMode === 'smart') && (
<SettingsField
label="Top navigation labels"
helper="Show text labels beside top navigation icons. Turn off for a more compact navigation bar."
>
<TogglePill checked={topNavLabels} onChange={setTopNavLabels} />
</SettingsField>
)}
{(topNavMode === 'classic' || topNavMode === 'smart') && !topNavLabels && (
<SettingsField
label="Top navigation alignment"
helper="Place the icon-only navigation against the left edge or centered in the bar."
@@ -433,17 +494,64 @@ export function AppearanceSection() {
</SettingsField>
)}
<SettingsField
label="Log chip color"
helper="Unified uses the accent color for all service chips. Per-service assigns each service a stable label color for faster visual scanning."
>
<SegmentedControl
value={chipColorMode}
options={CHIP_COLOR_OPTIONS}
onChange={setChipColorMode}
ariaLabel="Log chip color mode"
/>
</SettingsField>
{topNavMode === 'compact' && (
<SettingsField
label="Quick links"
helper="Up to five pinned destinations on the top bar. Defaults are a starting set; add reachable destinations here or with the trailing + on the Compact bar."
>
<div className="flex w-full flex-col gap-2">
{quickLinkIds.length === 0 ? (
<p className="font-mono text-[11px] text-stat-subtitle">No quick links pinned.</p>
) : (
<ul className="flex flex-col gap-1">
{quickLinkIds.map((id) => {
const item = getAppNavItem(id);
const label = item?.label ?? id;
return (
<li
key={id}
className="flex items-center justify-between gap-2 rounded-md border border-glass-border px-2 py-1.5"
>
<span className="font-mono text-[11px] uppercase tracking-[0.14em]">
{label}
</span>
<SettingsSecondaryButton
type="button"
aria-label={`Remove ${label}`}
onClick={() => removeQuickLink(id)}
>
Remove
</SettingsSecondaryButton>
</li>
);
})}
</ul>
)}
<div className="flex flex-col gap-1.5">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Add quick link
</span>
{addEnabled ? (
<Combobox
options={addOptions}
value=""
onValueChange={(value) => {
if (value) addQuickLink(value as ActiveView);
}}
placeholder="Choose a destination"
/>
) : (
<p className="font-mono text-[11px] text-stat-subtitle">{addDisabledReason}</p>
)}
</div>
<SettingsActions>
<SettingsSecondaryButton type="button" onClick={resetQuickLinks}>
Reset to defaults
</SettingsSecondaryButton>
</SettingsActions>
</div>
</SettingsField>
)}
</SettingsSection>
<p className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/70">
@@ -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}
/>
</div>
</ScrollArea>
@@ -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 <AccountSection />;
case 'appearance': return <AppearanceSection />;
case 'appearance': return <AppearanceSection quickLinkCandidates={quickLinkCandidates} />;
case 'license': return <LicenseSection />;
case 'users': return <UsersSection />;
case 'sso': return <SSOSection />;
@@ -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 (
<>
@@ -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(<AppearanceSection />);
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();
});
});
+2 -2
View File
@@ -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',
},
@@ -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');
});
});
@@ -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']);
});
});
+56
View File
@@ -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<string> = 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<TopNavMode>(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];
}
@@ -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<string>();
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<ActiveView[]>(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,
};
}
@@ -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);
});
});
@@ -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 };
}
@@ -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> = {}): 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');
});
});
@@ -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<NavGroup, NavDestination[]>();
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,
};
}