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