@@ -26,7 +26,7 @@ The palette groups results into three sections.
| Group | What it contains | What happens when you pick one |
|-------|------------------|--------------------------------|
-| **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**, **Schedules**, and **Console** appear for admins; **Audit** appears for any role with the `system:audit` permission. See [RBAC & User Management](/features/rbac) for the full permission matrix. | Navigates to that page |
+| **Pages** | The reachable page destinations for your tier and role (the same set top navigation and 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**, **Schedules**, and **Console** appear for admins; **Audit** appears 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 4d7f88ec..c8421d31 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 starts with **Home**, **Resources**, **Networking**, **Security**, and **App Store**. **Fleet** appears when your role can read nodes. Additional operator views (**Logs**, **Update**, **Schedules**, and **Console**) appear for admins. **Audit** appears based on your role and license tier. 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.
+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 **Console**) appear for admins. **Audit** appears based on your role and license tier. Fleet-wide views describe the control instance, so they are hidden while a remote node is active. Choose Compact (the default) or Smart 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 fc099426..5d17f00a 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, baseline top navigation includes **Home**, **Resources**, **Networking**, **Security**, and **App Store**. **Fleet** appears when your role can read nodes. **Logs**, **Update**, **Schedules**, and **Console** appear for admins. **Audit** depends on license and role; 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**, **Billing** (when a paid license is active), **Documentation**, **Open New Issue**, 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**, **Schedules**, and **Console** appear for admins. **Audit** depends on license and role; hub-only views are hidden when a remote node is active. Desktop presentation (Compact launcher, the default, or Smart bar) is chosen under **Settings → Appearance → Navigation**. The right side of the top bar holds global search, notifications, and the profile menu entries **Settings**, **Billing** (when a paid license is active), **Documentation**, **Open New Issue**, 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. Once stacks carry Docker Compose labels, the list groups them under those labels, with pinned stacks always floating to the top and unlabeled stacks collected at the bottom.
diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx
index 82e74f43..44bc7625 100644
--- a/docs/reference/settings.mdx
+++ b/docs/reference/settings.mdx
@@ -145,7 +145,7 @@ A live preview card shows a sample fleet-status tile so you can see a color choi
| Control | What it does |
|---------|--------------|
-| **Navigation style** | **Smart bar** (recommended default): primary destinations stay visible in the top bar and the rest live under **More**. **Classic bar** keeps the full horizontal strip of destinations (retiring soon; a callout appears while it is selected). **Compact launcher** puts every destination in a menu, with optional quick links. |
+| **Navigation style** | **Compact launcher** (recommended default): puts every destination in a menu, with optional quick links. **Smart bar** keeps primary destinations visible in the top bar and the rest live under **More**. |
| **Top navigation labels** | On by default. Shows text labels beside the top navigation icons; turn off for a more compact bar with icons only. |
Deploy-progress behavior and the diff-preview-before-save step are stack workflow preferences and live in their own [Stacks](#stacks) section under Infrastructure.
diff --git a/docs/tutorials/configure-auto-update-policies.mdx b/docs/tutorials/configure-auto-update-policies.mdx
index a716530f..3da659b0 100644
--- a/docs/tutorials/configure-auto-update-policies.mdx
+++ b/docs/tutorials/configure-auto-update-policies.mdx
@@ -36,7 +36,7 @@ The worked example is `jackett`, a small self-hosted indexer proxy pinned to `ls
This is cosmetic confirmation, not the policy itself: the schedule you create next is what actually drives updates.
-**The Update readiness board.** Open **More → Update**. Where jackett's card used to show `Rebuild available`, the board now reads `Everything is up to date`, and the sidebar's **Updates** filter chip is back to `0`.
+**The Update readiness board.** Open the navigation launcher and, under **Operations**, pick **Update** (Smart bar: **More → Update**). Where jackett's card used to show `Rebuild available`, the board now reads `Everything is up to date`, and the sidebar's **Updates** filter chip is back to `0`.
diff --git a/docs/tutorials/grant-scoped-stack-access.mdx b/docs/tutorials/grant-scoped-stack-access.mdx
index 83c55c21..034cc3ef 100644
--- a/docs/tutorials/grant-scoped-stack-access.mdx
+++ b/docs/tutorials/grant-scoped-stack-access.mdx
@@ -83,7 +83,7 @@ Check from two places, since a single screen showing "it looks right" isn't proo
-**The audit log.** Open **Audit** from the navigation's **More** menu. Two entries confirm the setup, both attributed to your account: a **created user** entry (`POST /api/users`) and an **assigned role** entry (`POST /api/users/:id/roles`). If the teammate restarted the stack in Step 3, a third entry and a `manual` count on the dashboard's **Stack Restarts (7d)** card confirm the permission was exercised, not just granted.
+**The audit log.** Open the navigation launcher and pick **Audit** (Smart bar: open **More** instead). Two entries confirm the setup, both attributed to your account: a **created user** entry (`POST /api/users`) and an **assigned role** entry (`POST /api/users/:id/roles`). If the teammate restarted the stack in Step 3, a third entry and a `manual` count on the dashboard's **Stack Restarts (7d)** card confirm the permission was exercised, not just granted.
diff --git a/docs/tutorials/schedule-an-operation.mdx b/docs/tutorials/schedule-an-operation.mdx
index efb74e50..a96e37ad 100644
--- a/docs/tutorials/schedule-an-operation.mdx
+++ b/docs/tutorials/schedule-an-operation.mdx
@@ -22,10 +22,10 @@ This tutorial covers one recurring **Restart Stack** task on the hub. It does no
+
The page opens on the **Timeline** view: a 24-hour strip with five lanes, one per operation category (Lifecycle, Updates, Security, Upkeep, Backups). With no tasks yet, it shows the empty-state message `Nothing scheduled in the next 24 hours`.
@@ -100,7 +100,7 @@ The footer confirms the schedule is alive: the next run time is shown there too,
## If something goes wrong
-**You don't see Schedules in the More menu.** Schedules is a hub-level view. If a remote node is the active selection, the More menu shows only **Console**, and the hub-only views (Schedules, Audit, Logs, Update) are missing. Click the node switcher next to the Sencho logo, pick **Local**, and reopen **More**: Schedules is back under **Operations**.
+**You don't see Schedules in the launcher or More menu.** Schedules is a hub-level view. If a remote node is the active selection, the hub-only views (Schedules, Audit, Logs, Update) are missing. Click the node switcher next to the Sencho logo, pick **Local**, and reopen the launcher (or **More**): Schedules is back under **Operations**.
A run that fails shows a red **Failed** badge in the row and an error-level notification; the failure notification carries the task name and the error so you can diagnose without opening the run history. The task stays enabled and fires again at its next cron tick, or you can click **Run now** to retry immediately. See [Scheduled Operations · Troubleshooting](/features/scheduled-operations#troubleshooting) for the full failure list.
diff --git a/docs/tutorials/set-up-deploy-enforcement.mdx b/docs/tutorials/set-up-deploy-enforcement.mdx
index 7ffb0b3e..0ec4b270 100644
--- a/docs/tutorials/set-up-deploy-enforcement.mdx
+++ b/docs/tutorials/set-up-deploy-enforcement.mdx
@@ -69,7 +69,7 @@ Check from two independent surfaces so you're not trusting a single UI element.
**The stack itself.** `prod-web` shows **RUNNING**, with `prod-web-web-1` up and its port mapping live, as in the screenshot above.
-**The audit log.** Open **More** → **Audit**. Reading newest first, you'll see the successful bypass deploy, a `policy.bypass` entry naming the policy, the violation count, and the offending image (`policy.bypass stack="prod-web" policy="Production block on critical" violations=1 images=[nginx:1.14]`), and below that the original blocked attempt with a `409` status in red. All three carry the account that triggered them, so the override is attributable, not anonymous.
+**The audit log.** Open the navigation launcher and, under **Security & review**, pick **Audit** (Smart bar: **More** → **Audit**). Reading newest first, you'll see the successful bypass deploy, a `policy.bypass` entry naming the policy, the violation count, and the offending image (`policy.bypass stack="prod-web" policy="Production block on critical" violations=1 images=[nginx:1.14]`), and below that the original blocked attempt with a `409` status in red. All three carry the account that triggered them, so the override is attributable, not anonymous.
diff --git a/docs/tutorials/set-up-fleet-secrets.mdx b/docs/tutorials/set-up-fleet-secrets.mdx
index 6a979c02..3e5fb220 100644
--- a/docs/tutorials/set-up-fleet-secrets.mdx
+++ b/docs/tutorials/set-up-fleet-secrets.mdx
@@ -90,7 +90,7 @@ Check from two independent surfaces so you're not trusting a single UI element.
**The Results tab itself**, shown above: both nodes report success with the counts matching the bundle's key count.
-**The Audit Log.** Open **More → Audit**. Reading newest first, the top entry reads `pushed secret: 1` and the one just below it `previewed secret push: 1`, both attributed to the account that ran the push.
+**The Audit Log.** Open the navigation launcher and, under **Security & review**, pick **Audit** (Smart bar: **More → Audit**). Reading newest first, the top entry reads `pushed secret: 1` and the one just below it `previewed secret push: 1`, both attributed to the account that ran the push.
diff --git a/e2e/desktop-navigation.spec.ts b/e2e/desktop-navigation.spec.ts
index 58195ca9..ef8a646f 100644
--- a/e2e/desktop-navigation.spec.ts
+++ b/e2e/desktop-navigation.spec.ts
@@ -1,11 +1,11 @@
/**
- * Desktop navigation styles: Smart default, Compact quick-link picker,
- * labeled pins, and persistence.
+ * Desktop navigation styles: Compact default, Smart alternate, labeled pins,
+ * launcher animation, Navigate panel scrolling, 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) {
+async function setTopNavMode(page: import('@playwright/test').Page, mode: 'smart' | 'compact' | null) {
await page.evaluate((next) => {
if (next === null) {
window.localStorage.removeItem('sencho.appearance.topNavMode');
@@ -30,16 +30,23 @@ test.describe('Desktop navigation styles', () => {
await waitForStacksLoaded(page);
});
- test('defaults to Smart bar with a More control', async ({ page }) => {
+ test('defaults to Compact launcher with an Open navigation launcher 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();
+ await expect(topbar).toHaveAttribute('data-sn-nav-mode', 'compact');
+ await expect(page.getByRole('button', { name: 'Open navigation launcher' })).toBeVisible();
+ });
+
+ test('a legacy classic preference migrates to compact on load', async ({ page }) => {
+ await page.evaluate(() => {
+ window.localStorage.setItem('sencho.appearance.topNavMode', 'classic');
+ });
+ await page.reload();
+ await loginAs(page);
+ await waitForStacksLoaded(page);
+ await expect(page.locator('[data-sn-chrome="topbar"]')).toHaveAttribute('data-sn-nav-mode', 'compact');
});
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();
@@ -50,7 +57,6 @@ test.describe('Desktop navigation styles', () => {
});
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();
@@ -84,4 +90,152 @@ test.describe('Desktop navigation styles', () => {
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();
});
+
+ test('the launcher hamburger morphs open/closed and does not animate under Reduced motion', async ({ page }) => {
+ const trigger = page.getByRole('button', { name: 'Open navigation launcher' });
+ await expect(trigger).toHaveAttribute('data-state', 'closed');
+ await trigger.click();
+ await expect(trigger).toHaveAttribute('data-state', 'open');
+ await page.keyboard.press('Escape');
+ await expect(trigger).toHaveAttribute('data-state', 'closed');
+
+ // The bar actually moves open vs. closed, not just a duration-clamp check.
+ // Read translate and rotate alongside transform: Tailwind v4 compiles these
+ // utilities to the standalone `translate` and `rotate` properties, so reading
+ // `transform` alone reports "none" in both states and proves nothing. Keeping
+ // transform in the snapshot means this still holds if that ever changes back.
+ const bar = trigger.locator('span > span').first();
+ const morphState = (el: Element) => {
+ const s = getComputedStyle(el);
+ return `${s.translate}|${s.rotate}|${s.transform}`;
+ };
+ const closedMorph = await bar.evaluate(morphState);
+ await trigger.click();
+ await expect(trigger).toHaveAttribute('data-state', 'open');
+ const openMorph = await bar.evaluate(morphState);
+ expect(openMorph).not.toBe(closedMorph);
+ await page.keyboard.press('Escape');
+ await expect(trigger).toHaveAttribute('data-state', 'closed');
+
+ // Drive Reduced motion explicitly in both directions rather than assuming the
+ // starting state: a fresh install defaults to the Calm visual style, which
+ // turns Reduced motion on, so the clamp is already active before any toggle.
+ // The top bar stays mounted on the Settings view, so the bar can be measured
+ // from there without navigating back.
+ await trigger.click();
+ await page.getByRole('menuitem', { name: /^Settings$/i }).click();
+ await page.getByText('Appearance', { exact: true }).first().waitFor();
+ const reducedMotion = page.getByRole('switch', { name: 'Reduced motion' });
+ const durationMs = () => bar.evaluate((el) => parseFloat(getComputedStyle(el).transitionDuration) * 1000);
+
+ if (await reducedMotion.getAttribute('aria-checked') === 'true') {
+ await reducedMotion.click();
+ }
+ await expect(page.locator('html')).not.toHaveAttribute('data-motion', 'reduced');
+ expect(await durationMs()).toBeGreaterThan(1);
+
+ await reducedMotion.click();
+ await expect(page.locator('html')).toHaveAttribute('data-motion', 'reduced');
+ expect(await durationMs()).toBeLessThan(1);
+ });
+
+ test('the Navigate panel actually scrolls to reach destinations below the fold', async ({ page }) => {
+ await page.setViewportSize({ width: 1200, height: 420 });
+ const trigger = page.getByRole('button', { name: 'Open navigation launcher' });
+ await trigger.click();
+
+ const panel = page.getByRole('menu').filter({ has: page.getByText('Navigate', { exact: true }) });
+ const viewport = panel.locator('[data-radix-scroll-area-viewport]');
+ await expect(viewport).toBeVisible();
+
+ // A shrunken nav set would trip the overflow assertion below with a confusing
+ // message, so fail here first, naming the real cause.
+ expect(await panel.getByRole('menuitem').count()).toBeGreaterThan(8);
+
+ // The viewport must have real internal overflow. This is the assertion that
+ // matters: the panel previously rendered at its full content height, reported
+ // scrollHeight === clientHeight, and was merely clipped by an ancestor, so it
+ // looked capped while ignoring every wheel event. Checking only the computed
+ // overflow-y properties passes in exactly that broken state.
+ const metrics = await viewport.evaluate((el) => ({
+ clientHeight: el.clientHeight,
+ scrollHeight: el.scrollHeight,
+ scrollWidth: el.scrollWidth,
+ clientWidth: el.clientWidth,
+ viewportOverflowX: getComputedStyle(el).overflowX,
+ outerOverflowY: getComputedStyle(el.closest('[role="menu"]') as HTMLElement).overflowY,
+ }));
+ expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight);
+
+ // Exactly one scroll owner: the outer menu clips rather than scrolling. Its
+ // scrollHeight is not asserted, because the menu's 1px border alone puts it a
+ // couple of pixels over its clientHeight without it being scrollable at all.
+ expect(metrics.outerOverflowY).toBe('hidden');
+
+ // No horizontal overflow, and the panel stays inside the browser viewport.
+ // overflow-x is checked directly, not just measured: a reserved scrollbar
+ // gutter from overflow-x: scroll would pass the width comparison below with
+ // no actual overflow present.
+ expect(metrics.viewportOverflowX).not.toBe('scroll');
+ expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth + 1);
+ const box = await panel.boundingBox();
+ expect(box).not.toBeNull();
+ expect(box!.y + box!.height).toBeLessThanOrEqual(420 + 1);
+
+ // Keyboard reaches a destination below the fold and brings it fully into view.
+ // End rather than ArrowDown, because ArrowDown landing on the first item is
+ // stock roving focus and holds whether or not anything scrolls. Done before
+ // any pointer movement, since Radix focuses a menu item on pointermove.
+ const last = panel.getByRole('menuitem').last();
+ await page.keyboard.press('End');
+ await expect(last).toBeFocused();
+ await expect(last).toBeInViewport({ ratio: 1 });
+
+ // Back to the top so the wheel below starts from a known position.
+ await viewport.evaluate((el) => { el.scrollTop = 0; });
+
+ // Genuine mouse-wheel input over the panel must move it. Wheel input is the
+ // exact path the regression ignored, so drive it rather than assigning scrollTop.
+ const vpBox = await viewport.boundingBox();
+ await page.mouse.move(vpBox!.x + vpBox!.width / 2, vpBox!.y + vpBox!.height / 2);
+ await page.mouse.wheel(0, 200);
+ await expect.poll(() => viewport.evaluate((el) => el.scrollTop)).toBeGreaterThan(0);
+
+ // ...and it moved the viewport only, leaving the outer menu at rest: an
+ // overflow-hidden element cannot be wheel-scrolled, so this is a fixed
+ // invariant rather than something to poll for.
+ expect(await panel.evaluate((el) => el.scrollTop)).toBe(0);
+
+ // Keep wheeling to the bottom rather than assuming one gesture covers the whole
+ // range, so adding destinations later cannot fail this for a reason unrelated
+ // to scrolling.
+ await expect.poll(async () => {
+ await page.mouse.wheel(0, 200);
+ return viewport.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop);
+ }).toBeLessThanOrEqual(1);
+
+ // The last destination is genuinely reachable by mouse, not just present.
+ await expect(last).toBeInViewport({ ratio: 1 });
+ });
+
+ test('the Navigate panel sizes to its content when the viewport is tall', async ({ page }) => {
+ // The mirror of the test above, guarding the other direction: the cap has to
+ // track the popper's available height rather than a fixed pixel value. A
+ // hardcoded cap would keep every assertion above green while needlessly
+ // cropping the panel on a roomy screen.
+ await page.setViewportSize({ width: 1400, height: 900 });
+ const trigger = page.getByRole('button', { name: 'Open navigation launcher' });
+ await trigger.click();
+
+ const panel = page.getByRole('menu').filter({ has: page.getByText('Navigate', { exact: true }) });
+ const viewport = panel.locator('[data-radix-scroll-area-viewport]');
+ await expect(viewport).toBeVisible();
+
+ // Content fits without being clipped when the viewport is roomy enough.
+ const metrics = await viewport.evaluate((el) => ({
+ clientHeight: el.clientHeight,
+ scrollHeight: el.scrollHeight,
+ }));
+ expect(metrics.scrollHeight).toBeLessThanOrEqual(metrics.clientHeight + 1);
+ });
});
diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx
index d39bdaa4..bd982186 100644
--- a/frontend/src/components/EditorLayout.tsx
+++ b/frontend/src/components/EditorLayout.tsx
@@ -247,7 +247,6 @@ export default function EditorLayout() {
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
@@ -280,8 +279,13 @@ export default function EditorLayout() {
navModel,
openMuteRulesWithPrefill,
reachCtx,
+ defaultQuickLinkEligibility,
} = navState;
+ // Called after navState so it can be seeded from navState.defaultQuickLinkEligibility
+ // (settled, role- and capability-aware defaults) rather than the raw recommended list.
+ const { persistedIds: quickLinkIds, addQuickLink, removeQuickLink } = useTopNavQuickLinks(defaultQuickLinkEligibility);
+
const visibleQuickLinks = useMemo(() => {
const candidateSet = new Set(navModel.quickLinkCandidates.map((item) => item.value));
return quickLinkIds
@@ -1125,6 +1129,7 @@ export default function EditorLayout() {
urlHydratingStack={urlHydratingStack}
isFileLoading={isFileLoading}
quickLinkCandidates={navModel.quickLinkCandidates}
+ defaultQuickLinkEligibility={defaultQuickLinkEligibility}
/>
);
@@ -1204,6 +1209,7 @@ export default function EditorLayout() {
selectedSection={mobileSettingsSection}
onSelectedSectionChange={setMobileSettingsSection}
quickLinkCandidates={navModel.quickLinkCandidates}
+ defaultQuickLinkEligibility={defaultQuickLinkEligibility}
/>
);
case 'security':
diff --git a/frontend/src/components/EditorLayout/ViewRouter.tsx b/frontend/src/components/EditorLayout/ViewRouter.tsx
index ff147b99..7703fd15 100644
--- a/frontend/src/components/EditorLayout/ViewRouter.tsx
+++ b/frontend/src/components/EditorLayout/ViewRouter.tsx
@@ -114,6 +114,7 @@ export interface ViewRouterProps {
urlHydratingStack: string | null;
isFileLoading: boolean;
quickLinkCandidates?: NavDestination[];
+ defaultQuickLinkEligibility?: ActiveView[] | null;
}
export function ViewRouter({
@@ -148,6 +149,7 @@ export function ViewRouter({
urlHydratingStack,
isFileLoading,
quickLinkCandidates,
+ defaultQuickLinkEligibility,
}: ViewRouterProps): ReactNode {
const { can, permissionsStatus } = useAuth();
const { isPaid, licenseReady } = useLicense();
@@ -161,6 +163,7 @@ export function ViewRouter({
onMutePrefillConsumed={onMutePrefillConsumed}
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
quickLinkCandidates={quickLinkCandidates}
+ defaultQuickLinkEligibility={defaultQuickLinkEligibility}
/>
);
}
diff --git a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx
index c437817e..797a2ed0 100644
--- a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx
+++ b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx
@@ -391,6 +391,51 @@ describe('useViewNavigationState', () => {
expect(values).toContain('audit-log');
});
+ // ── defaultQuickLinkEligibility: settled default eligibility for quick links ─
+
+ it('defaultQuickLinkEligibility is null while permissions are still loading', () => {
+ mockAuth(true, () => true, 'loading');
+ mockLicense(true, 'ready');
+ const { result } = renderHook(() => useViewNavigationState());
+ expect(result.current.defaultQuickLinkEligibility).toBeNull();
+ });
+
+ it('defaultQuickLinkEligibility is null while license status is still loading', () => {
+ mockAuth(true, () => true, 'ready');
+ mockLicense(true, 'loading');
+ const { result } = renderHook(() => useViewNavigationState());
+ expect(result.current.defaultQuickLinkEligibility).toBeNull();
+ });
+
+ it('defaultQuickLinkEligibility is a non-null, role-filtered list once settled', () => {
+ mockPaidAdmin();
+ const { result } = renderHook(() => useViewNavigationState());
+ expect(result.current.defaultQuickLinkEligibility).toEqual([
+ 'dashboard', 'fleet', 'resources', 'security', 'auto-updates', 'scheduled-ops',
+ ]);
+ });
+
+ it('excludes admin-only and scheduling-gated defaults for a non-admin community user', () => {
+ // mockCommunityUser grants node:read (so Fleet, which is gated on that permission
+ // alone, stays included) but not admin or any scheduling-capable permission.
+ mockCommunityUser();
+ const { result } = renderHook(() => useViewNavigationState());
+ expect(result.current.defaultQuickLinkEligibility).toEqual(['dashboard', 'fleet', 'resources', 'security']);
+ });
+
+ it('still includes hub-only defaults on a remote node, unlike the display-time navItems list', () => {
+ mockPaidAdmin();
+ mockActiveNode('remote');
+ const { result } = renderHook(() => useViewNavigationState());
+ // Contrast with the "hides hub-only views" test above: navItems (display) drops
+ // these on a remote node, but defaultQuickLinkEligibility must not, since
+ // recommended defaults reflect the operator's role, not the active node tab.
+ expect(result.current.navItems.map(i => i.value)).not.toContain('fleet');
+ expect(result.current.defaultQuickLinkEligibility).toEqual([
+ 'dashboard', 'fleet', 'resources', 'security', 'auto-updates', 'scheduled-ops',
+ ]);
+ });
+
// ── auto-redirect when on a hub-only view and node switches to remote ──────
it('auto-redirects to dashboard when active view is hub-only and node becomes remote', () => {
diff --git a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts
index 75b1975e..eae77707 100644
--- a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts
+++ b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts
@@ -13,13 +13,14 @@ 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 { canScheduleAny } from '@/lib/scheduledActions';
import { buildNavigationModel } from '@/lib/navigation/buildNavigationModel';
-import type { NavDestination } from '@/lib/navigation/appNavRegistry';
+import { recommendedQuickLinkIds, type NavDestination } from '@/lib/navigation/appNavRegistry';
export type { ActiveView };
export { HUB_ONLY_VIEWS };
@@ -128,6 +129,20 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
const navModel = useMemo(() => buildNavigationModel(reachCtx), [reachCtx]);
const navItems = navModel.allPageItems;
+ // Settled default eligibility for quick-link seeding/reset: distinct from navModel's
+ // quickLinkCandidates (current-context, fail-open display filtering). Requires authzReady
+ // (role/license settled) before returning anything, so a still-loading permissions/license
+ // fetch never causes an incomplete default set to be seeded/persisted. isRemote is
+ // deliberately overridden to false: default eligibility reflects the operator's role, not
+ // which node tab happens to be open. Three of the six recommended defaults (fleet,
+ // auto-updates, scheduled-ops) are HUB_ONLY_VIEWS, and evaluating with the real isRemote
+ // would silently drop them whenever a remote node is active.
+ const defaultQuickLinkEligibility = useMemo(() => {
+ if (!authzReady(reachCtx)) return null;
+ const roleCtx: ReachabilityContext = { ...reachCtx, isRemote: false };
+ return recommendedQuickLinkIds.filter((id) => !isViewHidden(id, roleCtx));
+ }, [reachCtx]);
+
useEffect(() => {
if (!authzReady(reachCtx)) return;
const normalized = normalizeHiddenView(activeView, reachCtx);
@@ -155,5 +170,6 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
navItems,
navModel,
reachCtx,
+ defaultQuickLinkEligibility,
} as const;
}
diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx
index 8101ee1e..dba9af92 100644
--- a/frontend/src/components/TopBar.tsx
+++ b/frontend/src/components/TopBar.tsx
@@ -2,6 +2,7 @@ import { Fragment, type ReactNode, useMemo } from 'react';
import type { LucideIcon } from 'lucide-react';
import { Menu, MoreHorizontal, Plus } from 'lucide-react';
import { Button } from './ui/button';
+import { ScrollArea } from './ui/scroll-area';
import { Sheet, SheetContent, SheetTrigger } from './ui/sheet';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import {
@@ -33,7 +34,7 @@ export interface TopBarNavItem {
interface TopBarProps {
activeView: string;
- /** Flat page destinations for Classic strip and the mobile sheet. */
+ /** Flat page destinations for the mobile sheet. */
navItems: TopBarNavItem[];
onNavigate: (value: string) => void;
mobileNavOpen: boolean;
@@ -74,6 +75,28 @@ function ActiveUnderline({ active }: { active: boolean }) {
);
}
+// Two bars that morph into an X, keyed off Radix's data-state attribute (already
+// stamped on the trigger button by DropdownMenuTrigger asChild, so no extra React
+// state is needed). A plain CSS transition, not a JS animation loop: Sencho's global
+// [data-motion="reduced"] clamp (index.css) forces transition-duration to ~0ms for
+// every element, so this automatically just swaps state under Reduced motion with
+// zero extra code. The trigger button must carry the `group` class for the
+// group-data-[state=open]: variants below to apply.
+function LauncherHamburgerIcon() {
+ // Both bars share everything but the direction they start from and rotate to.
+ const bar = cn(
+ 'absolute h-[1.5px] w-3.5 rounded-full bg-current',
+ 'transition-transform duration-[var(--duration-fast)] ease-[var(--ease-out-expo)]',
+ 'group-data-[state=open]:translate-y-0',
+ );
+ return (
+
+
+
+
+ );
+}
+
function TopBarMenuMasthead({ title }: { title: string }) {
return (