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
@@ -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,
};
}