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',
},