feat(navigation): make Compact Launcher the default desktop style (#1890)

* feat(navigation): make Compact Launcher the default desktop style

Compact Launcher replaces Smart bar as the default desktop navigation
style; Classic bar is fully retired (rendering, settings option, and
persisted-value migration to Compact). Quick-link capacity increases
from 7 to 8, and the recommended default set becomes Home, Fleet,
Resources, Security, Update, and Schedules.

Quick-link seeding and Reset now use a settled, role-aware eligibility
computation distinct from the existing current-context display
filtering, so an operator's recommended defaults reflect their role
rather than which node happens to be active, and a Reset produces the
same result regardless of node context.

Also fixes two issues surfaced while touching this code: a disabled
quick-link control showing a duplicate tooltip (native title plus the
Radix tooltip), and the Navigate launcher panel being unable to scroll
at constrained viewport heights (now uses the shared ScrollArea
component). The launcher hamburger icon animates into an X on open,
respecting Reduced motion.

* test(e2e): fix the launcher morph and panel scroll navigation assertions

Three assertions in the new navigation specs were wrong against a real
browser, all in the tests rather than the product:

- The morph check read getComputedStyle().transform, which Tailwind v4
  no longer writes: rotate-45 compiles to `rotate:45deg` and the
  translate utilities to the standalone `translate` property, so both
  the open and closed reads returned "none". It now snapshots translate
  and rotate alongside transform. The animation itself was always
  correct, since Tailwind's transition-transform covers translate,
  scale, and rotate.
- The reduced-motion check assumed motion starts enabled. A fresh
  install defaults to the Calm visual style, which turns Reduced motion
  on, so the clamp was already active and the pre-toggle duration
  assertion could never hold. It now drives the toggle in both
  directions from a known state.
- The panel scroll check asserted a specific overflow measurement,
  which depends on Radix having applied its available-height variable
  at read time and on how many destinations the account can reach. It
  now asserts the property the fix actually guarantees: the ScrollArea
  viewport owns vertical scrolling while the outer menu only clips,
  with no horizontal overflow and the panel inside the viewport.

Verified by running the spec against live dev servers: 7 passed, twice.

* fix(nav): make the Navigate launcher panel actually scroll with the mouse

Live QA found that the panel only reached destinations below the fold
by keyboard; a real mouse wheel did nothing. The Radix ScrollArea
viewport is sized with h-full, and a percentage height only resolves
against a containing block with a definite height. The popper content
is height:auto clamped by max-height, which is not definite, so
nothing sized from it is either, so the viewport fell back to its full
content height with no internal overflow, no scrollbar, and no
response to wheel input, while an ancestor's overflow-hidden silently
clipped everything past the fold.

Moves the available-height cap onto the viewport itself and moves the
masthead inside the scroll region so the cap needs no masthead-height
arithmetic. Verified live: the previous structure measured
scrollHeight === clientHeight === 594 with zero wheel movement; the
fix measures 646/357 with the wheel reaching the true maximum.

Also hardens the panel's e2e coverage, which passed on the broken
structure because it asserted only computed overflow properties and
never drove a real scroll: adds a keyboard-reach assertion using End
rather than ArrowDown (ArrowDown landing on the first item is stock
roving focus and proves nothing about scrolling), a genuine
mouse-wheel assertion that scrolls to the true bottom rather than
assuming one gesture covers the range, and a companion test proving
the cap tracks the popper's available height rather than a hardcoded
pixel value. Confirmed the rewritten test fails on the previous
structure and passes on the fix.
This commit is contained in:
Anso
2026-09-05 20:03:48 -04:00
committed by GitHub
parent 607904ee63
commit b6766572df
30 changed files with 734 additions and 257 deletions
+4 -5
View File
@@ -96,11 +96,10 @@ The **Display** group holds layout and log-chip preferences for this browser:
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. Choosing Classic shows a callout that Classic bar will be removed soon; the preference is kept until then.
- **Compact launcher**: puts destinations in a left-side launcher menu and optionally pins up to seven **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 seven pins; recommended defaults start you with four.
- **Compact launcher** (recommended default): puts destinations in a left-side launcher menu and optionally pins up to eight **quick links** on the bar.
- **Smart bar**: keeps a short set of primary destinations visible and moves the rest into a grouped **More** menu.
- **Top navigation labels** (Smart bar only): 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 eight pins; recommended defaults start you with a reachable set of up to six.
Deploy-progress behavior and the diff-preview-before-save step are stack workflow preferences, so they live in **Settings → Infrastructure → Stacks**, not here.
+2 -2
View File
@@ -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 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.
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 top navigation 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.
<Frame>
<img src="/images/global-search/palette-pages.png" alt="Sencho global search palette open with no query over the blurred dashboard, the Pages group listing Home, Fleet, Resources, Networking, Security, and App Store each with a leading icon, with a scrollbar indicating more entries below." />
@@ -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 |
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
@@ -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.
</Step>
<Step title="Create a schedule that targets the label">
Open **More → Schedules**, click **New Schedule**, and set **Action** to **Auto-update stacks by label** (in the **Updates** group). Fill in:
Open the navigation launcher and, under **Operations**, pick **Schedules** (Smart bar: **More → Schedules**). Click **New Schedule**, and set **Action** to **Auto-update stacks by label** (in the **Updates** group). Fill in:
- **Name**: `Nightly patch check`
- **Stack Label**: type `Auto-update` and pick the suggestion that appears (it shows the live match count: `1 stack · 1 node`)
@@ -76,7 +76,7 @@ Check from two places, since a status badge alone can't tell you *what* got upda
<img src="/images/tutorials/configure-auto-update-policies/run-history.png" alt="Execution history sheet for Nightly patch check showing one run: Source Manual, Status Success, Duration 24.1s, and details naming the stack-label selector and 'Stack jackett: updated (lscr.io/linuxserver/jackett:latest)'." />
</Frame>
**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`.
<Frame caption="The Update readiness board after the schedule cleared the only pending rebuild.">
<img src="/images/tutorials/configure-auto-update-policies/readiness-board-up-to-date.png" alt="Update readiness board showing the empty state: a shield icon, the headline 'All stacks on current builds', and the subtitle 'Sencho rechecks registries on the configured interval.' The sidebar's UPDATES chip reads 0." />
+1 -1
View File
@@ -83,7 +83,7 @@ Check from two places, since a single screen showing "it looks right" isn't proo
<img src="/images/tutorials/grant-scoped-stack-access/scope-added.png" alt="The Edit User panel's Scoped Permissions box showing one row: deployer on stack support-portal at Local, with a trash icon to remove it." />
</Frame>
**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.
<Frame>
<img src="/images/tutorials/grant-scoped-stack-access/audit-log.png" alt="Audit log showing entries for assigning a role (POST /api/users/3/roles) and creating a user (POST /api/users), both attributed to the admin account." />
+3 -3
View File
@@ -22,10 +22,10 @@ This tutorial covers one recurring **Restart Stack** task on the hub. It does no
<Steps>
<Step title="Open the Schedules page">
In the top navigation, click **More**, then under **Operations** pick **Schedules**.
Open the navigation launcher (the top-left menu icon), then under **Operations** pick **Schedules**. If you use Smart bar instead, click **More** and find Schedules there under the same **Operations** group.
<Frame>
<img src="/images/tutorials/schedule-an-operation/more-menu-schedules.png" alt="The More navigation menu with Audit under Security & review, Logs, Update, and Schedules under Operations, and Console under Tools." />
<img src="/images/tutorials/schedule-an-operation/more-menu-schedules.png" alt="The navigation launcher's Navigate panel with Audit under Security & review, Logs, Update, and Schedules under Operations, and Console under Tools." />
</Frame>
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.
+1 -1
View File
@@ -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.
<Frame>
<img src="/images/tutorials/set-up-deploy-enforcement/audit-log-bypass-entry.png" alt="Audit log showing three prod-web entries in order: a 200 deploy, a policy.bypass entry naming policy Production block on critical with violations=1 images=[nginx:1.14], and a 409 blocked deploy." />
+1 -1
View File
@@ -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.
<Frame>
<img src="/images/tutorials/set-up-fleet-secrets/audit-log-push.png" alt="Audit log showing two recent entries: 'admin pushed secret: 1' and 'admin previewed secret push: 1', each with a timestamp, node, and 200 status." />
+164 -10
View File
@@ -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);
});
});
+7 -1
View File
@@ -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}
/>
</div>
);
@@ -1204,6 +1209,7 @@ export default function EditorLayout() {
selectedSection={mobileSettingsSection}
onSelectedSectionChange={setMobileSettingsSection}
quickLinkCandidates={navModel.quickLinkCandidates}
defaultQuickLinkEligibility={defaultQuickLinkEligibility}
/>
);
case 'security':
@@ -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}
/>
);
}
@@ -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', () => {
@@ -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;
}
+50 -45
View File
@@ -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 (
<span className="relative flex h-4 w-4 shrink-0 items-center justify-center" aria-hidden>
<span className={cn(bar, '-translate-y-[3px] group-data-[state=open]:rotate-45')} />
<span className={cn(bar, 'translate-y-[3px] group-data-[state=open]:-rotate-45')} />
</span>
);
}
function TopBarMenuMasthead({ title }: { title: string }) {
return (
<div className="relative overflow-hidden">
@@ -86,6 +109,24 @@ function TopBarMenuMasthead({ title }: { title: string }) {
);
}
// One scroll owner. The popper content and the ScrollArea viewport are both
// capped at the available height, but the content clips (overflow-hidden,
// overriding the base DropdownMenuContent's overflow-y-auto) and only the
// viewport scrolls.
//
// The viewport's cap has to be a max-height on the viewport itself. The viewport
// is sized by `h-full` (see ui/scroll-area), and a percentage height only
// resolves against a containing block whose height is definite. The popper
// content is `height: auto` clamped by `max-height`, which is not definite, so
// neither is anything sized from it, a flex item included, so `h-full` falls
// back to auto and the viewport grows to its full content height with nothing
// to scroll. A max-height clamps the viewport whatever its height resolves to.
// (`<ScrollArea className="flex-1">` stays correct wherever the flex
// container's own height is definite, as in the sheets and sidebars.)
//
// The masthead scrolls with the list rather than being pinned outside the
// scroll region, so the cap needs no masthead-height arithmetic and stays
// correct at every density setting.
function PanelMenuContent({
title,
children,
@@ -95,8 +136,10 @@ function PanelMenuContent({
}) {
return (
<DropdownMenuContent align="start" sideOffset={8} className="w-56 overflow-hidden rounded-md p-0">
{title ? <TopBarMenuMasthead title={title} /> : null}
<div className={cn(title && 'border-t border-card-border/60', 'p-1')}>{children}</div>
<ScrollArea className="[&>[data-radix-scroll-area-viewport]]:max-h-[var(--radix-dropdown-menu-content-available-height)]">
{title ? <TopBarMenuMasthead title={title} /> : null}
<div className={cn(title && 'border-t border-card-border/60', 'p-1')}>{children}</div>
</ScrollArea>
</DropdownMenuContent>
);
}
@@ -204,32 +247,6 @@ function GroupedMenuItems({
);
}
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,
@@ -386,9 +403,9 @@ function CompactStrip({
aria-label="Open navigation launcher"
aria-current={launcherActive ? 'page' : undefined}
data-sn-launcher-active={launcherActive ? 'true' : 'false'}
className={navButtonClass(launcherActive)}
className={cn(navButtonClass(launcherActive), 'group')}
>
<Menu className="w-4 h-4 shrink-0" strokeWidth={1.5} />
<LauncherHamburgerIcon />
<ActiveUnderline active={launcherActive} />
</button>
</DropdownMenuTrigger>
@@ -453,11 +470,7 @@ function CompactStrip({
) : (
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={0}
className="inline-flex h-full shrink-0 items-stretch"
title={addDisabledReason}
>
<span tabIndex={0} className="inline-flex h-full shrink-0 items-stretch">
<button
type="button"
aria-label="Add quick link"
@@ -489,7 +502,7 @@ export function TopBar({
userMenu,
showLabels = true,
navAlign = 'left',
navMode = 'smart',
navMode = 'compact',
navModel,
quickLinks = [],
persistedQuickLinkIds = [],
@@ -527,14 +540,6 @@ export function TopBar({
!stripLabels && centered && 'shrink-0',
)}
>
{navMode === 'classic' && (
<ClassicStrip
navItems={navItems}
activeView={activeView}
showLabels={stripLabels}
onNavigate={onNavigate}
/>
)}
{navMode === 'smart' && (
<SmartStrip
primaryItems={primaryItems}
@@ -27,6 +27,12 @@ function renderTopBar(overrides: Partial<Parameters<typeof TopBar>[0]> = {}) {
onMobileNavOpenChange={vi.fn()}
notifications={null}
userMenu={null}
// This file exercises Smart's labeled-strip contract (showLabels), which is
// meaningless in Compact mode (destinations live behind the launcher, not as
// visible top-bar buttons). Pin Smart here so the app's own default (Compact)
// doesn't silently break every test below; the "TopBar default navigation
// mode" describe block covers the real default.
navMode="smart"
{...overrides}
/>,
);
@@ -209,7 +215,7 @@ describe('TopBar smart and compact modes', () => {
expect(onOpenSettings).toHaveBeenCalled();
});
it('disables Add when persisted capacity is full even if fewer pins are visible', () => {
it('disables Add when persisted capacity is full even if fewer pins are visible', async () => {
renderTopBar({
navMode: 'compact',
// Capacity is a count check, so the IDs only need to be distinct and unpinned-candidate free.
@@ -223,7 +229,15 @@ describe('TopBar smart and compact modes', () => {
});
const add = screen.getByRole('button', { name: 'Add quick link' });
expect(add).toBeDisabled();
expect(add.closest('[title]')).toHaveAttribute('title', 'Remove a quick link to free a slot');
// No native title anywhere in the disabled control's ancestry: the Radix
// tooltip is the single source, fixing the former duplicate-tooltip bug.
expect(add.closest('[title]')).toBeNull();
// The wrapping span (not the disabled button) is the actual Radix TooltipTrigger,
// so it stays keyboard-discoverable even though the button itself is inert.
const trigger = add.closest('span[tabindex="0"]');
expect(trigger).not.toBeNull();
fireEvent.focus(trigger!);
expect(await screen.findByText('Remove a quick link to free a slot')).toBeInTheDocument();
});
it('offers Compact launcher context Add for unpinned destinations', async () => {
@@ -305,6 +319,85 @@ describe('TopBar smart and compact modes', () => {
});
});
describe('TopBar default navigation mode', () => {
it('renders Compact when navMode is omitted (the app default)', () => {
renderTopBar({ navMode: undefined });
expect(screen.getByRole('button', { name: 'Open navigation launcher' })).toBeInTheDocument();
// Compact hides destinations behind the launcher; they are not visible top-bar text.
expect(screen.queryByText('Home')).not.toBeInTheDocument();
expect(screen.queryByText('Fleet')).not.toBeInTheDocument();
});
});
describe('TopBar Compact launcher hamburger', () => {
const launcherGroups = [
{
group: 'overview' as const,
label: 'Overview',
items: [{ value: 'dashboard' as const, label: 'Home', icon: Home }],
},
];
const emptyModel = {
allPageItems: [],
primaryItems: [],
overflowGroups: [],
launcherGroups,
quickLinkCandidates: [],
};
it('carries a data-state attribute that toggles open/closed and morph-wired bars', async () => {
const user = userEvent.setup();
renderTopBar({ navMode: 'compact', navModel: emptyModel });
const trigger = screen.getByRole('button', { name: 'Open navigation launcher' });
expect(trigger).toHaveAttribute('data-state', 'closed');
// Without `group` on the trigger, the bars' group-data-[state=open]: selectors
// have no ancestor to key off and the morph silently becomes inert.
expect(trigger.className).toContain('group');
const bars = trigger.querySelectorAll('span > span');
expect(bars.length).toBeGreaterThanOrEqual(2);
for (const bar of bars) {
expect(bar.className).toContain('group-data-[state=open]:');
}
await user.click(trigger);
expect(trigger).toHaveAttribute('data-state', 'open');
});
it('the Navigate panel caps the ScrollArea viewport, not the menu content', async () => {
const user = userEvent.setup();
renderTopBar({ navMode: 'compact', navModel: emptyModel });
await user.click(screen.getByRole('button', { name: 'Open navigation launcher' }));
const masthead = await screen.findByText('Navigate', { selector: '.font-heading' });
const panel = masthead.closest('[role="menu"]') as HTMLElement;
expect(panel).not.toBeNull();
// jsdom has no layout, so the real check is the e2e scroll test. This pins the
// exact utility carrying the cap, because renaming or dropping it silently
// restores the broken layout. It cannot verify where the cap lands at runtime.
const viewport = panel.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
expect(viewport).not.toBeNull();
// Matched with one regex spanning both the target and the variable, not two
// independent toContain calls: the utility carrying the cap has to actually
// select the viewport, so a cap that drifts back onto an unrelated ancestor
// class still fails this even though each substring is present somewhere in
// the root's className. Tolerant of the equivalent Tailwind v4 shorthand
// (`max-h-(--x)` instead of `max-h-[var(--x)]`).
const scrollAreaRoot = viewport.parentElement as HTMLElement;
expect(scrollAreaRoot.className).toMatch(
/\[&>\[data-radix-scroll-area-viewport\]\][^\s]*--radix-dropdown-menu-content-available-height/,
);
// The outer content clips so it can never become a second scroll owner, and
// the masthead sits inside the scroll region so the cap needs no arithmetic.
expect(panel.className).toContain('overflow-hidden');
expect(viewport.contains(masthead)).toBe(true);
// No horizontal scrollbar is introduced by the default ScrollArea usage.
expect(panel.querySelector('[data-orientation="horizontal"]')).toBeNull();
});
});
describe('TopBar whatsNew slot', () => {
it('renders the whatsNew slot before the search slot', () => {
renderTopBar({
@@ -14,12 +14,14 @@ import { SettingsSectionContent } from '@/components/settings/SettingsSectionCon
import { useSettingsVisibility } from '@/components/settings/useSettingsVisibility';
import { BackChip, Kicker, Masthead } from './mobile-ui';
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
import type { ActiveView } from '@/lib/router/routeTypes';
interface MobileSettingsProps {
headerActions: ReactNode;
selectedSection: SectionId | null;
onSelectedSectionChange: (section: SectionId | null) => void;
quickLinkCandidates?: NavDestination[];
defaultQuickLinkEligibility?: ActiveView[] | null;
}
const NOOP = () => {};
@@ -29,6 +31,7 @@ export function MobileSettings({
selectedSection,
onSelectedSectionChange,
quickLinkCandidates,
defaultQuickLinkEligibility,
}: MobileSettingsProps) {
const { activeNode } = useNodes();
const nodeName = activeNode?.name ?? 'local';
@@ -64,6 +67,7 @@ export function MobileSettings({
onDirtyChange={NOOP}
showDescription
quickLinkCandidates={quickLinkCandidates}
defaultQuickLinkEligibility={defaultQuickLinkEligibility}
/>
</div>
</div>
@@ -43,9 +43,8 @@ const TOP_NAV_ALIGN_OPTIONS: { value: TopNavAlign; label: string }[] = [
];
const TOP_NAV_MODE_OPTIONS: { value: TopNavMode; label: string }[] = [
{ value: 'classic', label: 'Classic bar' },
{ value: 'smart', label: 'Smart bar' },
{ value: 'compact', label: 'Compact launcher' },
{ value: 'smart', label: 'Smart bar' },
];
const CHART_STYLE_OPTIONS: { value: ChartStyle; label: string }[] = [
@@ -147,8 +146,10 @@ function VisualCard({
export function AppearanceSection({
quickLinkCandidates = [],
defaultQuickLinkEligibility,
}: {
quickLinkCandidates?: NavDestination[];
defaultQuickLinkEligibility?: ActiveView[] | null;
}) {
const [density, setDensity] = useDensity();
const [chipColorMode, setChipColorMode] = useLogChipColorMode();
@@ -157,10 +158,11 @@ export function AppearanceSection({
const [topNavMode, setTopNavMode] = useTopNavMode();
const {
persistedIds: quickLinkIds,
canReset,
addQuickLink,
removeQuickLink,
resetQuickLinks,
} = useTopNavQuickLinks();
} = useTopNavQuickLinks(defaultQuickLinkEligibility);
const persistedSet = new Set(quickLinkIds);
const unpinnedCandidates = quickLinkCandidates.filter((item) => !persistedSet.has(item.value));
const atCapacity = quickLinkIds.length >= MAX_QUICK_LINKS;
@@ -459,17 +461,9 @@ export function AppearanceSection({
</SettingsSection>
<SettingsSection title="Navigation" kicker="this browser">
{topNavMode === 'classic' ? (
<SettingsCallout
tone="warn"
icon={<Info className="h-4 w-4" strokeWidth={1.5} />}
title="Classic bar retiring"
subtitle="Classic bar will be removed soon. Your preference is kept until then."
/>
) : null}
<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."
helper="Compact launcher is the recommended default: destinations live in a menu with optional quick links. Smart bar keeps primary destinations visible with the rest under More."
>
<SegmentedControl
value={topNavMode}
@@ -479,7 +473,7 @@ export function AppearanceSection({
/>
</SettingsField>
{(topNavMode === 'classic' || topNavMode === 'smart') && (
{topNavMode === 'smart' && (
<SettingsField
label="Top navigation labels"
helper="Show text labels beside top navigation icons. Turn off for a more compact navigation bar."
@@ -488,7 +482,7 @@ export function AppearanceSection({
</SettingsField>
)}
{(topNavMode === 'classic' || topNavMode === 'smart') && !topNavLabels && (
{topNavMode === 'smart' && !topNavLabels && (
<SettingsField
label="Top navigation alignment"
helper="Place the icon-only navigation against the left edge or centered in the bar."
@@ -505,7 +499,7 @@ export function AppearanceSection({
{topNavMode === 'compact' && (
<SettingsField
label="Quick links"
helper="Up to seven pinned destinations on the top bar. Defaults are a starting set; add reachable destinations here or with the trailing + on the Compact bar."
helper="Up to eight 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 ? (
@@ -553,7 +547,7 @@ export function AppearanceSection({
)}
</div>
<SettingsActions>
<SettingsSecondaryButton type="button" onClick={resetQuickLinks}>
<SettingsSecondaryButton type="button" onClick={resetQuickLinks} disabled={!canReset}>
Reset to defaults
</SettingsSecondaryButton>
</SettingsActions>
@@ -29,6 +29,7 @@ import { SettingsSectionContent } from './SettingsSectionContent';
import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsContext';
import { useSettingsVisibility } from './useSettingsVisibility';
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
import type { ActiveView } from '@/lib/router/routeTypes';
interface SettingsPageProps {
currentSection: SectionId;
@@ -37,6 +38,7 @@ interface SettingsPageProps {
onMutePrefillConsumed?: () => void;
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
quickLinkCandidates?: NavDestination[];
defaultQuickLinkEligibility?: ActiveView[] | null;
}
export function SettingsPage(props: SettingsPageProps) {
@@ -54,6 +56,7 @@ function SettingsPageInner({
onMutePrefillConsumed,
onOpenMuteRulesWithPrefill,
quickLinkCandidates,
defaultQuickLinkEligibility,
}: SettingsPageProps) {
const { activeNode } = useNodes();
const visibility = useSettingsVisibility();
@@ -212,6 +215,7 @@ function SettingsPageInner({
onMutePrefillConsumed={onMutePrefillConsumed}
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
quickLinkCandidates={quickLinkCandidates}
defaultQuickLinkEligibility={defaultQuickLinkEligibility}
/>
</div>
</ScrollArea>
@@ -26,6 +26,7 @@ import type { MuteRuleDraft } from '@/lib/muteRules';
import LazyBoundary from '../LazyBoundary';
import { SectionGate } from './SectionGate';
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
import type { ActiveView } from '@/lib/router/routeTypes';
// Paid-tier sections are loaded on demand. SectionGate returns null for
// Community / unentitled operators before reaching the JSX that would mount
@@ -74,17 +75,23 @@ function SectionSkeleton() {
);
}
function renderSection(
sectionId: SectionId,
onDirtyChange: (section: SectionId, dirty: boolean) => void,
muteRulePrefill: MuteRuleDraft | null | undefined,
onMutePrefillConsumed: (() => void) | undefined,
onOpenMuteRulesWithPrefill: ((draft: MuteRuleDraft) => void) | undefined,
quickLinkCandidates: NavDestination[] | undefined,
) {
function renderSection({
sectionId,
onDirtyChange,
muteRulePrefill,
onMutePrefillConsumed,
onOpenMuteRulesWithPrefill,
quickLinkCandidates,
defaultQuickLinkEligibility,
}: Omit<SettingsSectionContentProps, 'showDescription'>) {
switch (sectionId) {
case 'account': return <AccountSection />;
case 'appearance': return <AppearanceSection quickLinkCandidates={quickLinkCandidates} />;
case 'appearance': return (
<AppearanceSection
quickLinkCandidates={quickLinkCandidates}
defaultQuickLinkEligibility={defaultQuickLinkEligibility}
/>
);
case 'license': return <LicenseSection />;
case 'users': return <UsersSection />;
case 'sso': return <SSOSection />;
@@ -128,6 +135,7 @@ interface SettingsSectionContentProps {
onMutePrefillConsumed?: () => void;
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
quickLinkCandidates?: NavDestination[];
defaultQuickLinkEligibility?: ActiveView[] | null;
}
/**
@@ -144,18 +152,20 @@ export function SettingsSectionContent({
onMutePrefillConsumed,
onOpenMuteRulesWithPrefill,
quickLinkCandidates,
defaultQuickLinkEligibility,
}: SettingsSectionContentProps) {
const item = getSettingsItem(sectionId);
const element = useMemo(
() => renderSection(
() => renderSection({
sectionId,
onDirtyChange,
muteRulePrefill,
onMutePrefillConsumed,
onOpenMuteRulesWithPrefill,
quickLinkCandidates,
),
[sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill, quickLinkCandidates],
defaultQuickLinkEligibility,
}),
[sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill, quickLinkCandidates, defaultQuickLinkEligibility],
);
return (
<>
@@ -173,32 +173,39 @@ describe('AppearanceSection', () => {
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.
const navigationStyle = screen.getByRole('radiogroup', { name: 'Navigation style' });
expect(navigationStyle).toBeTruthy();
// Compact is the default: shows quick links, hides label/alignment controls.
expect(screen.getByText('Quick links')).toBeTruthy();
expect(screen.queryByText('Top navigation labels')).toBeNull();
fireEvent.click(screen.getByRole('radio', { name: 'Smart bar' }));
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();
});
it('shows the Classic bar retiring callout only while Classic is selected', () => {
it('offers only Compact launcher and Smart bar, with Compact first', () => {
localStorage.clear();
render(<AppearanceSection />);
expect(screen.queryByText('Classic bar retiring')).toBeNull();
fireEvent.click(screen.getByRole('radio', { name: 'Classic bar' }));
expect(screen.getByText('Classic bar retiring')).toBeTruthy();
expect(
screen.getByText('Classic bar will be removed soon. Your preference is kept until then.'),
).toBeTruthy();
fireEvent.click(screen.getByRole('radio', { name: 'Compact launcher' }));
const options = screen.getAllByRole('radio', { name: /bar|launcher/i }).map((el) => el.textContent);
expect(options).toEqual(['Compact launcher', 'Smart bar']);
expect(screen.queryByRole('radio', { name: 'Classic bar' })).toBeNull();
expect(screen.queryByText('Classic bar retiring')).toBeNull();
});
it('disables Reset to defaults while default eligibility has not settled', () => {
localStorage.clear();
render(<AppearanceSection quickLinkCandidates={[]} defaultQuickLinkEligibility={null} />);
expect((screen.getByRole('button', { name: 'Reset to defaults' }) as HTMLButtonElement).disabled).toBe(true);
});
it('enables Reset to defaults once default eligibility has settled', () => {
localStorage.clear();
render(<AppearanceSection quickLinkCandidates={[]} defaultQuickLinkEligibility={['dashboard']} />);
expect((screen.getByRole('button', { name: 'Reset to defaults' }) as HTMLButtonElement).disabled).toBe(false);
});
});
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useTopNavMode, TOP_NAV_MODE_KEY, parseTopNavMode } from '../use-top-nav-mode';
@@ -6,20 +6,27 @@ describe('useTopNavMode', () => {
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it('defaults to smart when no value is stored', () => {
it('defaults to compact when no value is stored', () => {
const { result } = renderHook(() => useTopNavMode());
expect(result.current[0]).toBe('smart');
expect(result.current[0]).toBe('compact');
});
it('falls back to smart for invalid storage', () => {
expect(parseTopNavMode('nope')).toBe('smart');
it('falls back to compact for invalid storage', () => {
expect(parseTopNavMode('nope')).toBe('compact');
localStorage.setItem(TOP_NAV_MODE_KEY, 'nope');
const { result } = renderHook(() => useTopNavMode());
expect(result.current[0]).toBe('smart');
expect(result.current[0]).toBe('compact');
});
it('migrates a legacy classic value to compact', () => {
expect(parseTopNavMode('classic')).toBe('compact');
localStorage.setItem(TOP_NAV_MODE_KEY, 'classic');
const { result } = renderHook(() => useTopNavMode());
expect(result.current[0]).toBe('compact');
});
it('reads each valid stored mode', () => {
for (const mode of ['classic', 'smart', 'compact'] as const) {
for (const mode of ['smart', 'compact'] as const) {
localStorage.setItem(TOP_NAV_MODE_KEY, mode);
const { result, unmount } = renderHook(() => useTopNavMode());
expect(result.current[0]).toBe(mode);
@@ -27,12 +34,24 @@ describe('useTopNavMode', () => {
}
});
it('resolves to compact when localStorage access throws', () => {
const spy = vi.spyOn(window.localStorage, 'getItem').mockImplementation(() => {
throw new Error('storage unavailable');
});
try {
const { result } = renderHook(() => useTopNavMode());
expect(result.current[0]).toBe('compact');
} finally {
spy.mockRestore();
}
});
it('persists mode changes and syncs same-tab listeners', () => {
const a = renderHook(() => useTopNavMode());
const b = renderHook(() => useTopNavMode());
act(() => a.result.current[1]('compact'));
expect(a.result.current[0]).toBe('compact');
expect(b.result.current[0]).toBe('compact');
expect(localStorage.getItem(TOP_NAV_MODE_KEY)).toBe('compact');
act(() => a.result.current[1]('smart'));
expect(a.result.current[0]).toBe('smart');
expect(b.result.current[0]).toBe('smart');
expect(localStorage.getItem(TOP_NAV_MODE_KEY)).toBe('smart');
});
});
@@ -1,11 +1,10 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { recommendedQuickLinkIds } from '@/lib/navigation/appNavRegistry';
import type { ActiveView } from '@/lib/router/routeTypes';
import {
useTopNavQuickLinks,
TOP_NAV_QUICK_LINKS_KEY,
parseStoredQuickLinks,
parseStoredState,
sanitizeQuickLinkIds,
MAX_QUICK_LINKS,
} from '../use-top-nav-quick-links';
@@ -19,31 +18,52 @@ const maxedPins: ActiveView[] = [
'networking',
'templates',
'global-observability',
'auto-updates',
];
describe('useTopNavQuickLinks', () => {
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it('uses registry recommended defaults when the key is missing', () => {
const { result } = renderHook(() => useTopNavQuickLinks());
expect(result.current.persistedIds).toEqual([...recommendedQuickLinkIds]);
});
it('uses registry recommended defaults for malformed JSON', () => {
localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, '{not-json');
expect(parseStoredQuickLinks(localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY))).toEqual([
...recommendedQuickLinkIds,
]);
});
it('keeps a valid empty array empty', () => {
localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, '[]');
it('starts empty when the key is missing and no defaultEligibleIds are given', () => {
const { result } = renderHook(() => useTopNavQuickLinks());
expect(result.current.persistedIds).toEqual([]);
});
it('sanitizes unknown, ineligible, and duplicate IDs and caps at seven', () => {
it('seeds settled default eligibility once, when the key is missing', () => {
const eligible: ActiveView[] = ['dashboard', 'fleet'];
const { result, rerender } = renderHook<
ReturnType<typeof useTopNavQuickLinks>,
{ ids: ActiveView[] | null }
>(
({ ids }) => useTopNavQuickLinks(ids),
{ initialProps: { ids: null } },
);
expect(result.current.persistedIds).toEqual([]);
rerender({ ids: eligible });
expect(result.current.persistedIds).toEqual(eligible);
expect(JSON.parse(localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY)!)).toEqual(eligible);
});
it('parseStoredState treats malformed JSON as unset, not raw defaults', () => {
expect(parseStoredState('{not-json')).toEqual({ status: 'unset' });
expect(parseStoredState(null)).toEqual({ status: 'unset' });
expect(parseStoredState('{"not":"an array"}')).toEqual({ status: 'unset' });
});
it('the hook also starts empty for malformed JSON, not raw unfiltered defaults', () => {
localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, '{not-json');
const { result } = renderHook(() => useTopNavQuickLinks());
expect(result.current.persistedIds).toEqual([]);
});
it('keeps a valid empty array empty', () => {
localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, '[]');
const { result } = renderHook(() => useTopNavQuickLinks(['dashboard', 'fleet']));
expect(result.current.persistedIds).toEqual([]);
});
it('sanitizes unknown, ineligible, and duplicate IDs and caps at eight', () => {
expect(
sanitizeQuickLinkIds([
'dashboard',
@@ -60,20 +80,37 @@ describe('useTopNavQuickLinks', () => {
'scheduled-ops',
]),
).toEqual(maxedPins);
expect(sanitizeQuickLinkIds([...maxedPins, 'auto-updates']).length).toBe(MAX_QUICK_LINKS);
expect(sanitizeQuickLinkIds([...maxedPins, 'scheduled-ops']).length).toBe(MAX_QUICK_LINKS);
});
it('reset writes recommendedQuickLinkIds', () => {
it('reset writes the given defaultEligibleIds, not the raw recommended list', () => {
localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, '[]');
const { result } = renderHook(() => useTopNavQuickLinks());
const eligible: ActiveView[] = ['dashboard', 'resources'];
const { result } = renderHook(() => useTopNavQuickLinks(eligible));
act(() => result.current.resetQuickLinks());
expect(result.current.persistedIds).toEqual([...recommendedQuickLinkIds]);
expect(JSON.parse(localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY)!)).toEqual([
...recommendedQuickLinkIds,
]);
expect(result.current.persistedIds).toEqual(eligible);
expect(JSON.parse(localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY)!)).toEqual(eligible);
});
it('reset persists a settled, confirmed-empty eligibility list as valid []', () => {
localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, JSON.stringify(['dashboard']));
const { result } = renderHook(() => useTopNavQuickLinks([]));
expect(result.current.canReset).toBe(true);
act(() => result.current.resetQuickLinks());
expect(result.current.persistedIds).toEqual([]);
expect(JSON.parse(localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY)!)).toEqual([]);
});
it('reset no-ops and canReset is false when defaultEligibleIds is not settled', () => {
localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, JSON.stringify(['dashboard']));
const { result } = renderHook(() => useTopNavQuickLinks(null));
expect(result.current.canReset).toBe(false);
act(() => result.current.resetQuickLinks());
expect(result.current.persistedIds).toEqual(['dashboard']);
});
it('remove can clear all pins without repopulating', () => {
localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, JSON.stringify(['dashboard', 'fleet']));
const { result } = renderHook(() => useTopNavQuickLinks());
act(() => {
for (const id of [...result.current.persistedIds]) {
@@ -84,10 +121,31 @@ describe('useTopNavQuickLinks', () => {
expect(JSON.parse(localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY)!)).toEqual([]);
});
it('add refuses beyond the persisted max of seven', () => {
it('remove applied across consecutive calls in one batch clears every pin, not just the last', () => {
localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, JSON.stringify(maxedPins));
const { result } = renderHook(() => useTopNavQuickLinks());
act(() => {
result.current.removeQuickLink(maxedPins[0]);
result.current.removeQuickLink(maxedPins[1]);
result.current.removeQuickLink(maxedPins[2]);
});
expect(result.current.persistedIds).toEqual(maxedPins.slice(3));
});
it('add applied across consecutive calls in one batch keeps every addition, in order', () => {
localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, JSON.stringify(['dashboard']));
const { result } = renderHook(() => useTopNavQuickLinks());
act(() => {
result.current.addQuickLink('fleet');
result.current.addQuickLink('resources');
});
expect(result.current.persistedIds).toEqual(['dashboard', 'fleet', 'resources']);
});
it('add refuses beyond the persisted max of eight', () => {
const { result } = renderHook(() => useTopNavQuickLinks());
act(() => result.current.setPersistedIds(maxedPins));
act(() => result.current.addQuickLink('auto-updates'));
act(() => result.current.addQuickLink('scheduled-ops'));
expect(result.current.persistedIds).toEqual(maxedPins);
});
@@ -97,4 +155,16 @@ describe('useTopNavQuickLinks', () => {
act(() => a.result.current.setPersistedIds(['networking']));
expect(b.result.current.persistedIds).toEqual(['networking']);
});
it('a malformed storage write synced from another tab lands as unset, not raw defaults', () => {
const { result } = renderHook(() => useTopNavQuickLinks());
act(() => result.current.setPersistedIds(['dashboard']));
act(() => {
window.dispatchEvent(new StorageEvent('storage', {
key: TOP_NAV_QUICK_LINKS_KEY,
newValue: '{not-json',
}));
});
expect(result.current.persistedIds).toEqual([]);
});
});
+8 -6
View File
@@ -3,22 +3,24 @@ import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
export const TOP_NAV_MODE_KEY = 'sencho.appearance.topNavMode';
export type TopNavMode = 'classic' | 'smart' | 'compact';
export type TopNavMode = 'smart' | 'compact';
const VALID: ReadonlySet<string> = new Set(['classic', 'smart', 'compact']);
const VALID: ReadonlySet<string> = new Set(['smart', 'compact']);
/** Recommended default, and the fallback for missing, invalid, or legacy ('classic') storage. */
const DEFAULT_MODE: TopNavMode = 'compact';
/** Missing or invalid storage resolves to Smart (recommended default). */
export function parseTopNavMode(raw: string | null): TopNavMode {
if (raw && VALID.has(raw)) return raw as TopNavMode;
return 'smart';
return DEFAULT_MODE;
}
function readStored(): TopNavMode {
if (typeof window === 'undefined') return 'smart';
if (typeof window === 'undefined') return DEFAULT_MODE;
try {
return parseTopNavMode(window.localStorage.getItem(TOP_NAV_MODE_KEY));
} catch {
return 'smart';
return DEFAULT_MODE;
}
}
+111 -69
View File
@@ -1,20 +1,18 @@
import { useCallback, useEffect, useState } from 'react';
import {
isQuickLinkEligibleId,
recommendedQuickLinkIds,
} from '@/lib/navigation/appNavRegistry';
import { useCallback, useEffect, useRef, useState } from 'react';
import { isQuickLinkEligibleId } from '@/lib/navigation/appNavRegistry';
import type { ActiveView } from '@/lib/router/routeTypes';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
export const TOP_NAV_QUICK_LINKS_KEY = 'sencho.appearance.topNavQuickLinks';
export const MAX_QUICK_LINKS = 7;
export const MAX_QUICK_LINKS = 8;
/**
* Sanitize a candidate ID list: keep registry-known eligible IDs, dedupe,
* and cap at MAX_QUICK_LINKS. Does not expand empty arrays to defaults.
* and cap at MAX_QUICK_LINKS. Never expands invalid input to a default set;
* the caller decides what to seed once eligibility is known.
*/
export function sanitizeQuickLinkIds(ids: unknown): ActiveView[] {
if (!Array.isArray(ids)) return [...recommendedQuickLinkIds];
if (!Array.isArray(ids)) return [];
const seen = new Set<string>();
const out: ActiveView[] = [];
for (const raw of ids) {
@@ -27,104 +25,148 @@ export function sanitizeQuickLinkIds(ids: unknown): ActiveView[] {
return out;
}
/**
* Parse stored JSON. Missing key or malformed JSON → recommended defaults.
* Valid JSON array (including []) is sanitized and returned as-is (empty stays empty).
*/
export function parseStoredQuickLinks(raw: string | null): ActiveView[] {
if (raw === null) return [...recommendedQuickLinkIds];
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [...recommendedQuickLinkIds];
return sanitizeQuickLinkIds(parsed);
} catch {
return [...recommendedQuickLinkIds];
}
}
type StoredQuickLinksState =
| { status: 'valid'; ids: ActiveView[] }
| { status: 'unset' }; // covers missing key, malformed JSON, and non-array JSON alike
function readStored(): ActiveView[] {
if (typeof window === 'undefined') return [...recommendedQuickLinkIds];
try {
return parseStoredQuickLinks(window.localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY));
} catch {
return [...recommendedQuickLinkIds];
}
}
function writeStored(ids: ActiveView[]): void {
function writeStored(ids: ActiveView[]): boolean {
try {
window.localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, JSON.stringify(ids));
} catch {
// ignore
return true;
} catch (err) {
console.warn('[useTopNavQuickLinks] failed to persist quick links', err);
return false;
}
}
/**
* Parse stored JSON into provenance-aware state. Missing key, malformed JSON, and non-array JSON
* all become 'unset' (not a raw-defaults guess): the caller decides what to do once eligibility
* is known. A valid JSON array (including []) is sanitized and returned as-is.
*/
export function parseStoredState(raw: string | null): StoredQuickLinksState {
if (raw === null) return { status: 'unset' };
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return { status: 'unset' };
return { status: 'valid', ids: sanitizeQuickLinkIds(parsed) };
} catch {
return { status: 'unset' };
}
}
function readStoredState(): StoredQuickLinksState {
if (typeof window === 'undefined') return { status: 'unset' };
try {
return parseStoredState(window.localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY));
} catch {
return { status: 'unset' };
}
}
/** An 'unset' state has no pins yet, so it reads as an empty list. */
function idsOf(state: StoredQuickLinksState): ActiveView[] {
return state.status === 'valid' ? state.ids : [];
}
export interface TopNavQuickLinksApi {
persistedIds: ActiveView[];
/** True once default eligibility has settled, so Reset has something authoritative to restore. */
canReset: boolean;
setPersistedIds: (next: ActiveView[]) => void;
addQuickLink: (value: ActiveView) => void;
removeQuickLink: (value: ActiveView) => void;
resetQuickLinks: () => void;
}
export function useTopNavQuickLinks(): TopNavQuickLinksApi {
const [persistedIds, setPersistedState] = useState<ActiveView[]>(readStored);
/**
* @param defaultEligibleIds Settled, reachability-aware recommended defaults from
* `useViewNavigationState`'s `defaultQuickLinkEligibility` (`null` while not yet settled). Used to
* seed a first-run/never-persisted state and to drive Reset, never `navModel.quickLinkCandidates`,
* which is current-context display filtering, not a settled default source.
*/
export function useTopNavQuickLinks(defaultEligibleIds?: readonly ActiveView[] | null): TopNavQuickLinksApi {
const [state, setState] = useState<StoredQuickLinksState>(readStoredState);
// Latest-value ref, updated synchronously via applyState below. addQuickLink/removeQuickLink
// read this instead of the `state` closure: within one synchronous batch (several calls before
// React re-renders), a closure read is stale for every call after the first. Only a ref updated
// inline sees each call's own effect on the one before it. Same latest-value-ref pattern as
// `activeNodeRef` in NodeContext.
const stateRef = useRef<StoredQuickLinksState>(state);
// The single writer for both, so the ref can never drift from the rendered state.
const applyState = useCallback((next: StoredQuickLinksState) => {
stateRef.current = next;
setState(next);
}, []);
// Same-tab settings-changed and cross-tab storage sync both route through the
// same status-discriminated parse: a malformed write synced from another tab
// must land as 'unset' here too, not fall back to raw unfiltered defaults.
useEffect(() => {
function onSettingsChanged() {
setPersistedState(readStored());
applyState(readStoredState());
}
window.addEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
}, []);
}, [applyState]);
useEffect(() => {
function onStorage(event: StorageEvent) {
if (event.key !== TOP_NAV_QUICK_LINKS_KEY) return;
setPersistedState(parseStoredQuickLinks(event.newValue));
applyState(parseStoredState(event.newValue));
}
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
}, [applyState]);
// commit always updates in-memory state (the operator's action, or the eligibility seed,
// still takes effect for this tab this session even if persistence failed), but only
// dispatches SENCHO_SETTINGS_CHANGED when the write actually succeeded. Every other mounted
// hook instance re-reads storage on that event; dispatching after a failed write would make
// them revert to the old (or absent) stored value even though this instance's in-memory state
// is correct.
const commit = useCallback((next: ActiveView[]) => {
const sanitized = sanitizeQuickLinkIds(next);
writeStored(sanitized);
setPersistedState(sanitized);
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
}, []);
const setPersistedIds = useCallback((next: ActiveView[]) => {
commit(next);
}, [commit]);
const addQuickLink = useCallback((value: ActiveView) => {
setPersistedState((prev) => {
if (prev.includes(value) || prev.length >= MAX_QUICK_LINKS) return prev;
if (!isQuickLinkEligibleId(value)) return prev;
const next = [...prev, value];
writeStored(next);
const persisted = writeStored(sanitized);
applyState({ status: 'valid', ids: sanitized });
if (persisted) {
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
return next;
});
}, []);
}
}, [applyState]);
const removeQuickLink = useCallback((value: ActiveView) => {
setPersistedState((prev) => {
const next = prev.filter((id) => id !== value);
writeStored(next);
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
return next;
});
}, []);
// Seed defaults once eligibility is settled (even a confirmed-empty list: defaultEligibleIds
// is only ever non-null once proven, never merely "not yet failed") and no valid preference has
// ever been saved. Fires at most once in practice: the moment it commits, storage holds a valid
// array and this becomes a no-op on every later render. Checks `== null`, not falsiness or
// `.length === 0`: a settled, confirmed-empty eligibility list is a real answer ("nothing
// recommended is reachable") and must be persisted as `[]`, not treated as "not yet known."
useEffect(() => {
if (state.status !== 'unset') return;
if (defaultEligibleIds == null) return;
commit([...defaultEligibleIds]);
}, [defaultEligibleIds, state.status, commit]);
const resetQuickLinks = useCallback(() => {
commit([...recommendedQuickLinkIds]);
if (defaultEligibleIds == null) return; // guarded in the UI too; defense in depth
commit([...defaultEligibleIds]);
}, [commit, defaultEligibleIds]);
const addQuickLink = useCallback((value: ActiveView) => {
if (!isQuickLinkEligibleId(value)) return;
const prevIds = idsOf(stateRef.current);
if (prevIds.includes(value) || prevIds.length >= MAX_QUICK_LINKS) return;
commit([...prevIds, value]);
}, [commit]);
const removeQuickLink = useCallback((value: ActiveView) => {
commit(idsOf(stateRef.current).filter((id) => id !== value));
}, [commit]);
return {
persistedIds,
setPersistedIds,
persistedIds: idsOf(state),
canReset: defaultEligibleIds != null,
setPersistedIds: commit,
addQuickLink,
removeQuickLink,
resetQuickLinks,
@@ -26,8 +26,10 @@ describe('appNavRegistry', () => {
expect([...recommendedQuickLinkIds]).toEqual([
'dashboard',
'fleet',
'security',
'resources',
'security',
'auto-updates',
'scheduled-ops',
]);
for (const id of recommendedQuickLinkIds) {
const item = APP_NAV_REGISTRY.find((entry) => entry.value === id);
@@ -36,11 +38,11 @@ describe('appNavRegistry', () => {
}
});
it('keeps Networking after Resources in Classic order metadata', () => {
it('keeps Networking after Resources in navOrder 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);
expect(networking!.navOrder).toBeGreaterThan(resources!.navOrder);
});
});
+20 -18
View File
@@ -25,8 +25,8 @@ export interface NavDestination {
export interface AppNavItem extends NavDestination {
group: NavGroup;
/** Classic strip order (ascending). Settings uses a high value and is excluded from Classic. */
classicOrder: number;
/** Flat page-list order (ascending), used by the command palette and mobile sheet. Settings uses a high value and is excluded (launcher-only). */
navOrder: number;
smart: SmartPlacement;
quickLinkEligible: boolean;
defaultQuickLink: boolean;
@@ -48,7 +48,7 @@ export const APP_NAV_REGISTRY: readonly AppNavItem[] = [
label: 'Home',
icon: Home,
group: 'overview',
classicOrder: 10,
navOrder: 10,
smart: 'primary',
quickLinkEligible: true,
defaultQuickLink: true,
@@ -58,7 +58,7 @@ export const APP_NAV_REGISTRY: readonly AppNavItem[] = [
label: 'Fleet',
icon: Radar,
group: 'fleet',
classicOrder: 20,
navOrder: 20,
smart: 'primary',
quickLinkEligible: true,
defaultQuickLink: true,
@@ -68,7 +68,7 @@ export const APP_NAV_REGISTRY: readonly AppNavItem[] = [
label: 'Resources',
icon: HardDrive,
group: 'fleet',
classicOrder: 30,
navOrder: 30,
smart: 'primary',
quickLinkEligible: true,
defaultQuickLink: true,
@@ -78,7 +78,7 @@ export const APP_NAV_REGISTRY: readonly AppNavItem[] = [
label: 'Networking',
icon: Network,
group: 'fleet',
classicOrder: 40,
navOrder: 40,
smart: 'primary',
quickLinkEligible: true,
defaultQuickLink: false,
@@ -88,7 +88,7 @@ export const APP_NAV_REGISTRY: readonly AppNavItem[] = [
label: 'Security',
icon: ShieldCheck,
group: 'security-review',
classicOrder: 50,
navOrder: 50,
smart: 'primary',
quickLinkEligible: true,
defaultQuickLink: true,
@@ -98,7 +98,7 @@ export const APP_NAV_REGISTRY: readonly AppNavItem[] = [
label: 'App Store',
icon: CloudDownload,
group: 'stack-workspace',
classicOrder: 60,
navOrder: 60,
smart: 'primary',
quickLinkEligible: true,
defaultQuickLink: false,
@@ -108,7 +108,7 @@ export const APP_NAV_REGISTRY: readonly AppNavItem[] = [
label: 'Logs',
icon: Activity,
group: 'operations',
classicOrder: 70,
navOrder: 70,
smart: 'overflow',
quickLinkEligible: true,
defaultQuickLink: false,
@@ -118,27 +118,27 @@ export const APP_NAV_REGISTRY: readonly AppNavItem[] = [
label: 'Update',
icon: RefreshCw,
group: 'operations',
classicOrder: 80,
navOrder: 80,
smart: 'overflow',
quickLinkEligible: true,
defaultQuickLink: false,
defaultQuickLink: true,
},
{
value: 'scheduled-ops',
label: 'Schedules',
icon: Clock,
group: 'operations',
classicOrder: 90,
navOrder: 90,
smart: 'overflow',
quickLinkEligible: true,
defaultQuickLink: false,
defaultQuickLink: true,
},
{
value: 'host-console',
label: 'Console',
icon: Terminal,
group: 'tools',
classicOrder: 100,
navOrder: 100,
smart: 'overflow',
quickLinkEligible: true,
defaultQuickLink: false,
@@ -148,7 +148,7 @@ export const APP_NAV_REGISTRY: readonly AppNavItem[] = [
label: 'Audit',
icon: ScrollText,
group: 'security-review',
classicOrder: 110,
navOrder: 110,
smart: 'overflow',
quickLinkEligible: true,
defaultQuickLink: false,
@@ -158,7 +158,7 @@ export const APP_NAV_REGISTRY: readonly AppNavItem[] = [
label: 'Settings',
icon: Settings,
group: 'settings',
classicOrder: 999,
navOrder: 999,
smart: 'launcher-only',
quickLinkEligible: false,
defaultQuickLink: false,
@@ -167,13 +167,15 @@ export const APP_NAV_REGISTRY: readonly AppNavItem[] = [
/**
* Recommended quick-link pins for missing/malformed storage and Reset.
* Order is intentional (Home, Fleet, Security, Resources), not Classic strip order.
* Order is intentional (Home, Fleet, Resources, Security, Update, Schedules), not page order.
*/
export const recommendedQuickLinkIds: readonly ActiveView[] = [
'dashboard',
'fleet',
'security',
'resources',
'security',
'auto-updates',
'scheduled-ops',
] as const;
const BY_VALUE = new Map(APP_NAV_REGISTRY.map((item) => [item.value, item]));
@@ -20,7 +20,7 @@ function makeCtx(overrides: Partial<ReachabilityContext> = {}): ReachabilityCont
}
describe('buildNavigationModel', () => {
it('returns exact Classic page order including Networking after Resources', () => {
it('returns exact page order including Networking after Resources', () => {
const model = buildNavigationModel(makeCtx());
expect(model.allPageItems.map((item) => item.value)).toEqual([
'dashboard',
@@ -15,7 +15,7 @@ export interface NavGroupBucket {
}
export interface ReachableNavigationModel {
/** Classic / palette / mobile page list (excludes Settings). Exact Classic order. */
/** Palette / mobile page list (excludes Settings). */
allPageItems: NavDestination[];
primaryItems: NavDestination[];
overflowGroups: NavGroupBucket[];
@@ -54,7 +54,7 @@ export function buildNavigationModel(reachCtx: ReachabilityContext): ReachableNa
const pages = reachable
.filter((item) => item.smart !== 'launcher-only')
.slice()
.sort((a, b) => a.classicOrder - b.classicOrder);
.sort((a, b) => a.navOrder - b.navOrder);
const allPageItems = pages.map(toNavDestination);
const primaryItems = pages