mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 20:29:15 +00:00
feat: add a compact icon-only top navigation toggle (#1363)
* feat: add a compact icon-only top navigation toggle Add a browser-local "Top navigation labels" preference under Settings > Appearance. With it off, the desktop top navigation renders icon-only; each destination keeps an aria-label, gains a hover/focus tooltip, and stays reachable from the command palette. The setting defaults on, so current behavior is preserved, and the mobile navigation always keeps its labels. Also left-align the desktop nav (previously centered) and shorten the longest nav label from "Auto-Update" to "Update" so the bar scans faster. * feat: let the icon-only top nav be left or centered Add a "Top navigation alignment" preference under Settings > Appearance that appears only when top navigation labels are off. It places the icon-only bar against the left edge (the default) or centered. With labels on, the nav always stays left so the longer labels read from the edge. The choice is browser-local and persists per device.
This commit is contained in:
@@ -49,5 +49,6 @@ Hero headings and featured names always render in Instrument Serif so the editor
|
|||||||
The **Display** group holds the remaining per-browser preferences:
|
The **Display** group holds the remaining per-browser preferences:
|
||||||
|
|
||||||
- **Density** switches between **Comfortable** (roomy rows, the default) and **Compact** (tighter rows and tiles that fit more on screen for dense dashboards).
|
- **Density** switches between **Comfortable** (roomy rows, the default) and **Compact** (tighter rows and tiles that fit more on screen for dense dashboards).
|
||||||
|
- **Top navigation labels** shows text labels beside the top navigation icons. Turn it off for an icon-only bar; the destinations stay reachable by hover tooltip, accessible name, and the command palette. On the phone layout the navigation always keeps its labels. With labels off, **Top navigation alignment** chooses whether the icon-only bar sits to the left or centered.
|
||||||
- **Deploy progress** toggles live output streaming for deploy, restart, update, install, and Git operations, and **Progress style** chooses Modal or Inline presentation.
|
- **Deploy progress** toggles live output streaming for deploy, restart, update, install, and Git operations, and **Progress style** chooses Modal or Inline presentation.
|
||||||
- **Diff preview before save** toggles a side-by-side diff of compose and env edits before they are written to disk.
|
- **Diff preview before save** toggles a side-by-side diff of compose and env edits before they are written to disk.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ When nothing is pending, the board renders a single Shield-icon panel with the h
|
|||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. Open **Auto-Update** from the top nav strip.
|
1. Open **Update** from the top nav strip.
|
||||||
2. Skim the card grid. The badge tells you the risk at a glance: `Safe · patch` is green, `Review · minor` is amber, `Blocked · major` is red, and a digest-only rebuild on a non-semver tag shows the gray `Digest rebuild` badge.
|
2. Skim the card grid. The badge tells you the risk at a glance: `Safe · patch` is green, `Review · minor` is amber, `Blocked · major` is red, and a digest-only rebuild on a non-semver tag shows the gray `Digest rebuild` badge.
|
||||||
3. For a safe update, click **Apply now** on the card to pull and recreate the stack immediately.
|
3. For a safe update, click **Apply now** on the card to pull and recreate the stack immediately.
|
||||||
4. For a major bump, review the changelog preview and the upstream release notes. **Apply now** is disabled on the readiness board for blocked cards; to apply a major bump after review, use the stack's lifecycle **Update** action (right-click the stack in the sidebar, or open the kebab menu and choose **Update**, or click **Deploy** in the stack editor).
|
4. For a major bump, review the changelog preview and the upstream release notes. **Apply now** is disabled on the readiness board for blocked cards; to apply a major bump after review, use the stack's lifecycle **Update** action (right-click the stack in the sidebar, or open the kebab menu and choose **Update**, or click **Deploy** in the stack editor).
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ The palette groups results into three sections.
|
|||||||
|
|
||||||
| Group | What it contains | What happens when you pick one |
|
| Group | What it contains | What happens when you pick one |
|
||||||
|-------|------------------|--------------------------------|
|
|-------|------------------|--------------------------------|
|
||||||
| **Pages** | The same set of destinations the top bar shows you. Home, Fleet, Resources, App Store, and Logs always appear; Auto-Update and Schedules appear for admins; Console appears for admins on Admiral; Audit appears for any role on Admiral with the audit permission. | Navigates to that page |
|
| **Pages** | The same set of destinations the top bar shows you. Home, Fleet, Resources, App Store, and Logs always appear; Update and Schedules appear for admins; Console appears for admins on Admiral; Audit appears for any role on Admiral with the audit permission. | 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 |
|
| **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 |
|
| **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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- **Recent Alerts** shows the latest notification feed and includes **Clear All Notifications** when there is anything to clear.
|
||||||
|
|
||||||
The top navigation includes **Home**, **Fleet**, **Resources**, **App Store**, and **Logs** on the standard dashboard surface. Depending on license, role, and node context, it can also include **Auto-Update**, **Schedules**, **Console**, and **Audit**.
|
The top navigation includes **Home**, **Fleet**, **Resources**, **App Store**, and **Logs** on the standard dashboard surface. Depending on license, role, and node context, it can also include **Update**, **Schedules**, **Console**, and **Audit**.
|
||||||
|
|
||||||
## Stack workspace
|
## Stack workspace
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ You land on **Home**, the default operational view. The health masthead reports
|
|||||||
|
|
||||||
Below the stack table, **Configuration Status** summarizes notification delivery, alert rules, automation, account security, backups, host 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.
|
Below the stack table, **Configuration Status** summarizes notification delivery, alert rules, automation, account security, backups, host thresholds, and crash detection. The neighboring activity card shows **Fleet Heartbeat** when remote nodes exist, or **Stack Restarts (7d)** on a local-only install. **Recent Alerts** shows the latest notification feed and includes **Clear All Notifications** when there is anything to clear.
|
||||||
|
|
||||||
On the local node, the top navigation includes **Home**, **Fleet**, **Resources**, **App Store**, and **Logs**. Depending on license, role, and node context, it can also include **Auto-Update**, **Schedules**, **Console**, and **Audit**; hub-only views are hidden when a remote node is active. The right side of the top bar holds global search, notifications, and the profile menu entries **Settings**, **Documentation**, **Feedback**, **Appearance**, and **Log Out**.
|
On the local node, the top navigation includes **Home**, **Fleet**, **Resources**, **App Store**, and **Logs**. Depending on license, role, and node context, it can also include **Update**, **Schedules**, **Console**, and **Audit**; hub-only views are hidden when a remote node is active. The right side of the top bar holds global search, notifications, and the profile menu entries **Settings**, **Documentation**, **Feedback**, **Appearance**, and **Log Out**.
|
||||||
|
|
||||||
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 importing Compose projects added outside Sencho. Use **Search stacks...** with the **All**, **Up**, **Down**, and **Updates** chips to narrow the list. On a fresh install the stack list is empty until you create a stack or scan a populated `COMPOSE_DIR`.
|
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 importing Compose projects added outside Sencho. Use **Search stacks...** with the **All**, **Up**, **Down**, and **Updates** chips to narrow the list. On a fresh install the stack list is empty until you create a stack or scan a populated `COMPOSE_DIR`.
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ import { useNextAutoUpdateRun } from '@/components/sidebar/useNextAutoUpdateRun'
|
|||||||
import { usePanelSessionStartedAt } from '@/components/sidebar/usePanelSessionStartedAt';
|
import { usePanelSessionStartedAt } from '@/components/sidebar/usePanelSessionStartedAt';
|
||||||
import type { SidebarActivityAction } from '@/components/sidebar/SidebarActivityTicker';
|
import type { SidebarActivityAction } from '@/components/sidebar/SidebarActivityTicker';
|
||||||
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
||||||
|
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
|
||||||
|
import { useTopNavAlign } from '@/hooks/use-top-nav-align';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||||
import { MobileTabBar } from './MobileTabBar';
|
import { MobileTabBar } from './MobileTabBar';
|
||||||
@@ -144,6 +146,8 @@ export default function EditorLayout() {
|
|||||||
}, [setCreateDialogOpen]);
|
}, [setCreateDialogOpen]);
|
||||||
|
|
||||||
const [diffPreviewEnabled] = useComposeDiffPreviewEnabled();
|
const [diffPreviewEnabled] = useComposeDiffPreviewEnabled();
|
||||||
|
const [topNavLabels] = useTopNavLabels();
|
||||||
|
const [topNavAlign] = useTopNavAlign();
|
||||||
|
|
||||||
// Use a ref to break the circular dependency:
|
// Use a ref to break the circular dependency:
|
||||||
// useViewNavigationState needs onNavigateToDashboard -> resetEditorState
|
// useViewNavigationState needs onNavigateToDashboard -> resetEditorState
|
||||||
@@ -665,6 +669,8 @@ export default function EditorLayout() {
|
|||||||
themeSwitch={themeSwitchEl}
|
themeSwitch={themeSwitchEl}
|
||||||
notifications={notificationsEl}
|
notifications={notificationsEl}
|
||||||
userMenu={userMenuEl}
|
userMenu={userMenuEl}
|
||||||
|
showLabels={topNavLabels}
|
||||||
|
navAlign={topNavAlign}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ describe('useViewNavigationState', () => {
|
|||||||
expect(result.current.navItems.map(i => i.value)).toContain('global-observability');
|
expect(result.current.navItems.map(i => i.value)).toContain('global-observability');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows Auto-Update and Schedules for a community admin (now free) but hides paid Console and Audit', () => {
|
it('shows Update and Schedules for a community admin (now free) but hides paid Console and Audit', () => {
|
||||||
mockCommunityAdmin();
|
mockCommunityAdmin();
|
||||||
const { result } = renderHook(() => useViewNavigationState());
|
const { result } = renderHook(() => useViewNavigationState());
|
||||||
const values = result.current.navItems.map(i => i.value);
|
const values = result.current.navItems.map(i => i.value);
|
||||||
@@ -225,6 +225,8 @@ describe('useViewNavigationState', () => {
|
|||||||
expect(values).toContain('scheduled-ops');
|
expect(values).toContain('scheduled-ops');
|
||||||
expect(values).not.toContain('host-console');
|
expect(values).not.toContain('host-console');
|
||||||
expect(values).not.toContain('audit-log');
|
expect(values).not.toContain('audit-log');
|
||||||
|
// The auto-updates nav item surfaces under the short label "Update".
|
||||||
|
expect(result.current.navItems.find(i => i.value === 'auto-updates')?.label).toBe('Update');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects a non-admin off the Logs view when reached via a deep-link event', () => {
|
it('redirects a non-admin off the Logs view when reached via a deep-link event', () => {
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
|||||||
// admin-only operator view (the backend gates the same routes on admin).
|
// admin-only operator view (the backend gates the same routes on admin).
|
||||||
if (isAdmin) items.push({ value: 'global-observability', label: 'Logs', icon: Activity });
|
if (isAdmin) items.push({ value: 'global-observability', label: 'Logs', icon: Activity });
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
items.push({ value: 'auto-updates', label: 'Auto-Update', icon: RefreshCw });
|
items.push({ value: 'auto-updates', label: 'Update', icon: RefreshCw });
|
||||||
items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
|
items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
|
||||||
}
|
}
|
||||||
if (isPaid) {
|
if (isPaid) {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { ReactNode } from 'react';
|
import { Fragment, type ReactNode } from 'react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { Menu } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Sheet, SheetContent, SheetTrigger } from './ui/sheet';
|
import { Sheet, SheetContent, SheetTrigger } from './ui/sheet';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||||
|
import type { TopNavAlign } from '@/hooks/use-top-nav-align';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export interface TopBarNavItem {
|
export interface TopBarNavItem {
|
||||||
@@ -21,6 +23,10 @@ interface TopBarProps {
|
|||||||
themeSwitch?: ReactNode;
|
themeSwitch?: ReactNode;
|
||||||
notifications: ReactNode;
|
notifications: ReactNode;
|
||||||
userMenu: ReactNode;
|
userMenu: ReactNode;
|
||||||
|
/** Show text labels beside the desktop nav icons. When false, the bar is icon-only. */
|
||||||
|
showLabels?: boolean;
|
||||||
|
/** Desktop nav placement in icon-only mode. Ignored while labels are shown (always left). */
|
||||||
|
navAlign?: TopNavAlign;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopBar({
|
export function TopBar({
|
||||||
@@ -33,7 +39,12 @@ export function TopBar({
|
|||||||
themeSwitch,
|
themeSwitch,
|
||||||
notifications,
|
notifications,
|
||||||
userMenu,
|
userMenu,
|
||||||
|
showLabels = true,
|
||||||
|
navAlign = 'left',
|
||||||
}: TopBarProps) {
|
}: TopBarProps) {
|
||||||
|
// Centering applies only to the icon-only bar; with labels on the nav stays
|
||||||
|
// left so the long labels read from the edge.
|
||||||
|
const centered = !showLabels && navAlign === 'center';
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -42,38 +53,49 @@ export function TopBar({
|
|||||||
'shadow-chrome-top',
|
'shadow-chrome-top',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* LEFT ZONE: reserved spacer (keeps nav visually centered) */}
|
{/* LEFT SPACER: balances the right utilities so the nav centers. */}
|
||||||
<div className="flex-1 min-w-0" />
|
{centered && <div className="flex-1 min-w-0" />}
|
||||||
|
|
||||||
{/* CENTER ZONE: Navigation (hidden on mobile) */}
|
{/* NAV ZONE: Navigation (hidden on mobile) */}
|
||||||
<nav aria-label="Primary" className="hidden md:flex self-stretch items-stretch">
|
<TooltipProvider delayDuration={300} disableHoverableContent>
|
||||||
{navItems.map(({ value, label, icon: Icon }) => {
|
<nav aria-label="Primary" className="hidden md:flex self-stretch items-stretch">
|
||||||
const isActive = activeView === value;
|
{navItems.map(({ value, label, icon: Icon }) => {
|
||||||
return (
|
const isActive = activeView === value;
|
||||||
<button
|
const button = (
|
||||||
key={value}
|
<button
|
||||||
onClick={() => onNavigate(value)}
|
onClick={() => onNavigate(value)}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-current={isActive ? 'page' : undefined}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative inline-flex h-full items-center gap-2 px-4',
|
'relative inline-flex h-full items-center gap-2 px-4',
|
||||||
'font-mono text-[10px] uppercase tracking-[0.18em] transition-colors',
|
'font-mono text-[10px] uppercase tracking-[0.18em] transition-colors',
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
|
||||||
isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4 shrink-0" strokeWidth={1.5} />
|
<Icon className="w-4 h-4 shrink-0" strokeWidth={1.5} />
|
||||||
<span className="hidden xl:inline">{label}</span>
|
{showLabels && <span className="hidden xl:inline">{label}</span>}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="pointer-events-none absolute inset-x-0 -bottom-px h-[2px] bg-brand"
|
className="pointer-events-none absolute inset-x-0 -bottom-px h-[2px] bg-brand"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
// Icon-only mode: a tooltip names the destination on hover/focus. With
|
||||||
</nav>
|
// labels on, the visible text carries it, so the button renders bare.
|
||||||
|
return showLabels ? (
|
||||||
|
<Fragment key={value}>{button}</Fragment>
|
||||||
|
) : (
|
||||||
|
<Tooltip key={value}>
|
||||||
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
{/* RIGHT ZONE: Utilities + identity pin */}
|
{/* RIGHT ZONE: Utilities + identity pin */}
|
||||||
<div className="flex flex-1 min-w-0 items-center justify-end gap-2">
|
<div className="flex flex-1 min-w-0 items-center justify-end gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Coverage for the TopBar `showLabels` preference and accessibility contract.
|
||||||
|
*
|
||||||
|
* Locks the compact icon-only mode: when labels are hidden the desktop nav must
|
||||||
|
* drop the visible text yet keep an accessible name on every button, and the
|
||||||
|
* mobile navigation sheet must always show its labels regardless of the setting.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { Home, Radar } from 'lucide-react';
|
||||||
|
import { TopBar, type TopBarNavItem } from '../TopBar';
|
||||||
|
|
||||||
|
const navItems: TopBarNavItem[] = [
|
||||||
|
{ value: 'dashboard', label: 'Home', icon: Home },
|
||||||
|
{ value: 'fleet', label: 'Fleet', icon: Radar },
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderTopBar(overrides: Partial<Parameters<typeof TopBar>[0]> = {}) {
|
||||||
|
return render(
|
||||||
|
<TopBar
|
||||||
|
activeView="dashboard"
|
||||||
|
navItems={navItems}
|
||||||
|
onNavigate={vi.fn()}
|
||||||
|
mobileNavOpen={false}
|
||||||
|
onMobileNavOpenChange={vi.fn()}
|
||||||
|
notifications={null}
|
||||||
|
userMenu={null}
|
||||||
|
{...overrides}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TopBar showLabels', () => {
|
||||||
|
it('renders visible nav labels by default', () => {
|
||||||
|
renderTopBar();
|
||||||
|
expect(screen.getByText('Home')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Fleet')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the visible label text in icon-only mode but keeps the accessible name', () => {
|
||||||
|
renderTopBar({ showLabels: false });
|
||||||
|
expect(screen.queryByText('Home')).not.toBeInTheDocument();
|
||||||
|
// The button is still reachable by its accessible name (aria-label).
|
||||||
|
expect(screen.getByRole('button', { name: 'Home' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Fleet' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always shows labels in the mobile navigation sheet, even when desktop labels are off', () => {
|
||||||
|
renderTopBar({ showLabels: false, mobileNavOpen: true });
|
||||||
|
// Desktop label spans are not rendered in icon-only mode, so the only "Home"
|
||||||
|
// text comes from the open sheet.
|
||||||
|
expect(screen.getByText('Home')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Fleet')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps nav buttons in a tooltip trigger only in icon-only mode', () => {
|
||||||
|
// Radix TooltipTrigger (asChild) stamps a data-state attribute onto the
|
||||||
|
// button; the labels-on path renders the button bare without one.
|
||||||
|
const { unmount } = renderTopBar({ showLabels: false });
|
||||||
|
expect(screen.getByRole('button', { name: 'Home' })).toHaveAttribute('data-state');
|
||||||
|
unmount();
|
||||||
|
renderTopBar({ showLabels: true });
|
||||||
|
expect(screen.getByRole('button', { name: 'Home' })).not.toHaveAttribute('data-state');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards clicks to onNavigate through the icon-only tooltip trigger', () => {
|
||||||
|
const onNavigate = vi.fn();
|
||||||
|
renderTopBar({ showLabels: false, onNavigate });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Fleet' }));
|
||||||
|
expect(onNavigate).toHaveBeenCalledWith('fleet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a leading spacer to center the nav only in icon-only center mode', () => {
|
||||||
|
// Centered: a flex-1 spacer precedes the nav.
|
||||||
|
const { unmount } = renderTopBar({ showLabels: false, navAlign: 'center' });
|
||||||
|
expect(screen.getByRole('navigation', { name: 'Primary' }).previousElementSibling)
|
||||||
|
.toHaveClass('flex-1');
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
// Icon-only but left-aligned: no leading spacer.
|
||||||
|
const second = renderTopBar({ showLabels: false, navAlign: 'left' });
|
||||||
|
expect(screen.getByRole('navigation', { name: 'Primary' }).previousElementSibling).toBeNull();
|
||||||
|
second.unmount();
|
||||||
|
|
||||||
|
// Labels on always stays left, even if center is requested.
|
||||||
|
renderTopBar({ showLabels: true, navAlign: 'center' });
|
||||||
|
expect(screen.getByRole('navigation', { name: 'Primary' }).previousElementSibling).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,11 +2,14 @@ import { Combobox } from '@/components/ui/combobox';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||||
|
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||||
import { useDensity } from '@/hooks/use-density';
|
import { useDensity } from '@/hooks/use-density';
|
||||||
import type { Density } from '@/hooks/use-density';
|
import type { Density } from '@/hooks/use-density';
|
||||||
import { useDeployFeedbackEnabled } from '@/hooks/use-deploy-feedback-enabled';
|
import { useDeployFeedbackEnabled } from '@/hooks/use-deploy-feedback-enabled';
|
||||||
import { useDeployFeedbackStyle, type DeployFeedbackStyle } from '@/hooks/use-deploy-feedback-style';
|
import { useDeployFeedbackStyle, type DeployFeedbackStyle } from '@/hooks/use-deploy-feedback-style';
|
||||||
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
||||||
|
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
|
||||||
|
import { useTopNavAlign, type TopNavAlign } from '@/hooks/use-top-nav-align';
|
||||||
import { useTheme, THEME_MODE_OPTIONS, ACCENTS, CONTRAST, BORDER_BOOST, GLOW, TYPE_SCALE } from '@/hooks/use-theme';
|
import { useTheme, THEME_MODE_OPTIONS, ACCENTS, CONTRAST, BORDER_BOOST, GLOW, TYPE_SCALE } from '@/hooks/use-theme';
|
||||||
import { AccentPicker } from '@/components/theme/AccentPicker';
|
import { AccentPicker } from '@/components/theme/AccentPicker';
|
||||||
import { ThemePreview } from '@/components/theme/ThemePreview';
|
import { ThemePreview } from '@/components/theme/ThemePreview';
|
||||||
@@ -31,6 +34,11 @@ const DEPLOY_STYLE_OPTIONS: { value: DeployFeedbackStyle; label: string }[] = [
|
|||||||
{ value: 'inline', label: 'Inline' },
|
{ value: 'inline', label: 'Inline' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const TOP_NAV_ALIGN_OPTIONS: { value: TopNavAlign; label: string }[] = [
|
||||||
|
{ value: 'left', label: 'Left' },
|
||||||
|
{ value: 'center', label: 'Center' },
|
||||||
|
];
|
||||||
|
|
||||||
const fmtSigned = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
|
const fmtSigned = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
|
||||||
|
|
||||||
export function AppearanceSection() {
|
export function AppearanceSection() {
|
||||||
@@ -38,6 +46,8 @@ export function AppearanceSection() {
|
|||||||
const [isEnabled, setEnabled] = useDeployFeedbackEnabled();
|
const [isEnabled, setEnabled] = useDeployFeedbackEnabled();
|
||||||
const [feedbackStyle, setFeedbackStyle] = useDeployFeedbackStyle();
|
const [feedbackStyle, setFeedbackStyle] = useDeployFeedbackStyle();
|
||||||
const [diffPreviewEnabled, setDiffPreviewEnabled] = useComposeDiffPreviewEnabled();
|
const [diffPreviewEnabled, setDiffPreviewEnabled] = useComposeDiffPreviewEnabled();
|
||||||
|
const [topNavLabels, setTopNavLabels] = useTopNavLabels();
|
||||||
|
const [topNavAlign, setTopNavAlign] = useTopNavAlign();
|
||||||
const {
|
const {
|
||||||
theme, accent, borderBoost, glow, contrast, uiFont, monoFont, typeScale,
|
theme, accent, borderBoost, glow, contrast, uiFont, monoFont, typeScale,
|
||||||
setTheme, setAccent, setBorderBoost, setGlow, setContrast, setUiFont, setMonoFont, setTypeScale,
|
setTheme, setAccent, setBorderBoost, setGlow, setContrast, setUiFont, setMonoFont, setTypeScale,
|
||||||
@@ -193,6 +203,27 @@ export function AppearanceSection() {
|
|||||||
/>
|
/>
|
||||||
</SettingsField>
|
</SettingsField>
|
||||||
|
|
||||||
|
<SettingsField
|
||||||
|
label="Top navigation labels"
|
||||||
|
helper="Show text labels beside top navigation icons. Turn off for a more compact navigation bar."
|
||||||
|
>
|
||||||
|
<TogglePill checked={topNavLabels} onChange={setTopNavLabels} />
|
||||||
|
</SettingsField>
|
||||||
|
|
||||||
|
{!topNavLabels && (
|
||||||
|
<SettingsField
|
||||||
|
label="Top navigation alignment"
|
||||||
|
helper="Place the icon-only navigation against the left edge or centered in the bar."
|
||||||
|
>
|
||||||
|
<SegmentedControl
|
||||||
|
value={topNavAlign}
|
||||||
|
options={TOP_NAV_ALIGN_OPTIONS}
|
||||||
|
onChange={setTopNavAlign}
|
||||||
|
ariaLabel="Top navigation alignment"
|
||||||
|
/>
|
||||||
|
</SettingsField>
|
||||||
|
)}
|
||||||
|
|
||||||
<SettingsField
|
<SettingsField
|
||||||
label="Deploy progress"
|
label="Deploy progress"
|
||||||
helper="Stream live output for deploy, restart, update, install, and Git operations, with a warning when an operation goes quiet. On by default; turn it off to run operations without it."
|
helper="Stream live output for deploy, restart, update, install, and Git operations, with a warning when an operation goes quiet. On by default; turn it off to run operations without it."
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { useTopNavAlign, TOP_NAV_ALIGN_KEY } from '../use-top-nav-align';
|
||||||
|
|
||||||
|
describe('useTopNavAlign (left default)', () => {
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
afterEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it('defaults to left when no value is stored', () => {
|
||||||
|
const { result } = renderHook(() => useTopNavAlign());
|
||||||
|
expect(result.current[0]).toBe('left');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads center only when explicitly set to center', () => {
|
||||||
|
localStorage.setItem(TOP_NAV_ALIGN_KEY, 'center');
|
||||||
|
const { result } = renderHook(() => useTopNavAlign());
|
||||||
|
expect(result.current[0]).toBe('center');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats any unknown value as left', () => {
|
||||||
|
localStorage.setItem(TOP_NAV_ALIGN_KEY, 'something-else');
|
||||||
|
const { result } = renderHook(() => useTopNavAlign());
|
||||||
|
expect(result.current[0]).toBe('left');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setAlign persists and switches', () => {
|
||||||
|
const { result } = renderHook(() => useTopNavAlign());
|
||||||
|
act(() => result.current[1]('center'));
|
||||||
|
expect(result.current[0]).toBe('center');
|
||||||
|
expect(localStorage.getItem(TOP_NAV_ALIGN_KEY)).toBe('center');
|
||||||
|
act(() => result.current[1]('left'));
|
||||||
|
expect(result.current[0]).toBe('left');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reacts to a storage event from another tab', () => {
|
||||||
|
const { result } = renderHook(() => useTopNavAlign());
|
||||||
|
expect(result.current[0]).toBe('left');
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new StorageEvent('storage', { key: TOP_NAV_ALIGN_KEY, newValue: 'center' }));
|
||||||
|
});
|
||||||
|
expect(result.current[0]).toBe('center');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs a second hook in the same tab via the settings-changed event', () => {
|
||||||
|
const a = renderHook(() => useTopNavAlign());
|
||||||
|
const b = renderHook(() => useTopNavAlign());
|
||||||
|
act(() => a.result.current[1]('center'));
|
||||||
|
expect(b.result.current[0]).toBe('center');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { useTopNavLabels, TOP_NAV_LABELS_KEY } from '../use-top-nav-labels';
|
||||||
|
|
||||||
|
describe('useTopNavLabels (opt-out default)', () => {
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
afterEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it('defaults to showing labels when no value is stored', () => {
|
||||||
|
const { result } = renderHook(() => useTopNavLabels());
|
||||||
|
expect(result.current[0]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides labels only when explicitly set to false', () => {
|
||||||
|
localStorage.setItem(TOP_NAV_LABELS_KEY, 'false');
|
||||||
|
const { result } = renderHook(() => useTopNavLabels());
|
||||||
|
expect(result.current[0]).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats any non-false value as labels-on', () => {
|
||||||
|
localStorage.setItem(TOP_NAV_LABELS_KEY, 'true');
|
||||||
|
const { result } = renderHook(() => useTopNavLabels());
|
||||||
|
expect(result.current[0]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores labels when a storage event clears the key (newValue null)', () => {
|
||||||
|
localStorage.setItem(TOP_NAV_LABELS_KEY, 'false');
|
||||||
|
const { result } = renderHook(() => useTopNavLabels());
|
||||||
|
expect(result.current[0]).toBe(false);
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new StorageEvent('storage', { key: TOP_NAV_LABELS_KEY, newValue: null }));
|
||||||
|
});
|
||||||
|
expect(result.current[0]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setShowLabels(false) persists false and hides labels', () => {
|
||||||
|
const { result } = renderHook(() => useTopNavLabels());
|
||||||
|
act(() => result.current[1](false));
|
||||||
|
expect(result.current[0]).toBe(false);
|
||||||
|
expect(localStorage.getItem(TOP_NAV_LABELS_KEY)).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs a second hook in the same tab via the settings-changed event', () => {
|
||||||
|
const a = renderHook(() => useTopNavLabels());
|
||||||
|
const b = renderHook(() => useTopNavLabels());
|
||||||
|
act(() => a.result.current[1](false));
|
||||||
|
expect(b.result.current[0]).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||||
|
|
||||||
|
export const TOP_NAV_ALIGN_KEY = 'sencho.appearance.topNavAlign';
|
||||||
|
|
||||||
|
export type TopNavAlign = 'left' | 'center';
|
||||||
|
|
||||||
|
// Horizontal placement of the desktop top nav when it is icon-only (labels off).
|
||||||
|
// 'left' (the default) keeps the bar tucked against the left edge; 'center'
|
||||||
|
// centers the icon cluster. Only an explicit 'center' centers it, so a missing
|
||||||
|
// or unknown value stays left.
|
||||||
|
function readStored(): TopNavAlign {
|
||||||
|
if (typeof window === 'undefined') return 'left';
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(TOP_NAV_ALIGN_KEY) === 'center' ? 'center' : 'left';
|
||||||
|
} catch {
|
||||||
|
return 'left';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTopNavAlign(): [TopNavAlign, (next: TopNavAlign) => void] {
|
||||||
|
const [align, setAlignState] = useState<TopNavAlign>(readStored);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onSettingsChanged() {
|
||||||
|
setAlignState(readStored());
|
||||||
|
}
|
||||||
|
window.addEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
|
||||||
|
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onStorage(event: StorageEvent) {
|
||||||
|
if (event.key !== TOP_NAV_ALIGN_KEY) return;
|
||||||
|
setAlignState(event.newValue === 'center' ? 'center' : 'left');
|
||||||
|
}
|
||||||
|
window.addEventListener('storage', onStorage);
|
||||||
|
return () => window.removeEventListener('storage', onStorage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setAlign = useCallback((next: TopNavAlign) => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(TOP_NAV_ALIGN_KEY, next);
|
||||||
|
} catch {
|
||||||
|
// ignore; localStorage may be unavailable (private mode, quota)
|
||||||
|
}
|
||||||
|
setAlignState(next);
|
||||||
|
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [align, setAlign];
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||||
|
|
||||||
|
export const TOP_NAV_LABELS_KEY = 'sencho.appearance.topNavLabels';
|
||||||
|
|
||||||
|
// Default on (opt-out): the desktop top nav keeps its text labels unless the
|
||||||
|
// user has explicitly turned them off, so current behavior is preserved. Only a
|
||||||
|
// stored 'false' switches the bar to icon-only.
|
||||||
|
function readStored(): boolean {
|
||||||
|
if (typeof window === 'undefined') return true;
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(TOP_NAV_LABELS_KEY) !== 'false';
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTopNavLabels(): [boolean, (next: boolean) => void] {
|
||||||
|
const [showLabels, setShowLabelsState] = useState<boolean>(readStored);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onSettingsChanged() {
|
||||||
|
setShowLabelsState(readStored());
|
||||||
|
}
|
||||||
|
window.addEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
|
||||||
|
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onStorage(event: StorageEvent) {
|
||||||
|
if (event.key !== TOP_NAV_LABELS_KEY) return;
|
||||||
|
// Opt-out: anything other than an explicit 'false' (including a
|
||||||
|
// cleared key) means labels are shown.
|
||||||
|
setShowLabelsState(event.newValue !== 'false');
|
||||||
|
}
|
||||||
|
window.addEventListener('storage', onStorage);
|
||||||
|
return () => window.removeEventListener('storage', onStorage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setShowLabels = useCallback((next: boolean) => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(TOP_NAV_LABELS_KEY, next ? 'true' : 'false');
|
||||||
|
} catch {
|
||||||
|
// ignore; localStorage may be unavailable (private mode, quota)
|
||||||
|
}
|
||||||
|
setShowLabelsState(next);
|
||||||
|
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [showLabels, setShowLabels];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user