From 1b96f3b9803f37ae6f120e1bb0181ae67f88751f Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 12 Jun 2026 10:49:36 -0400 Subject: [PATCH] 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. --- docs/features/appearance.mdx | 1 + docs/features/auto-update-policies.mdx | 2 +- docs/features/global-search.mdx | 2 +- docs/getting-started/introduction.mdx | 2 +- docs/getting-started/quickstart.mdx | 2 +- frontend/src/components/EditorLayout.tsx | 6 ++ .../__tests__/useViewNavigationState.test.tsx | 4 +- .../hooks/useViewNavigationState.ts | 2 +- frontend/src/components/TopBar.tsx | 86 +++++++++++------- .../src/components/__tests__/TopBar.test.tsx | 89 +++++++++++++++++++ .../components/settings/AppearanceSection.tsx | 31 +++++++ .../hooks/__tests__/use-top-nav-align.test.ts | 50 +++++++++++ .../__tests__/use-top-nav-labels.test.ts | 49 ++++++++++ frontend/src/hooks/use-top-nav-align.ts | 52 +++++++++++ frontend/src/hooks/use-top-nav-labels.ts | 51 +++++++++++ 15 files changed, 391 insertions(+), 38 deletions(-) create mode 100644 frontend/src/components/__tests__/TopBar.test.tsx create mode 100644 frontend/src/hooks/__tests__/use-top-nav-align.test.ts create mode 100644 frontend/src/hooks/__tests__/use-top-nav-labels.test.ts create mode 100644 frontend/src/hooks/use-top-nav-align.ts create mode 100644 frontend/src/hooks/use-top-nav-labels.ts diff --git a/docs/features/appearance.mdx b/docs/features/appearance.mdx index d7c71b6a..c8a56fd0 100644 --- a/docs/features/appearance.mdx +++ b/docs/features/appearance.mdx @@ -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: - **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. - **Diff preview before save** toggles a side-by-side diff of compose and env edits before they are written to disk. diff --git a/docs/features/auto-update-policies.mdx b/docs/features/auto-update-policies.mdx index 83eee22f..06576486 100644 --- a/docs/features/auto-update-policies.mdx +++ b/docs/features/auto-update-policies.mdx @@ -33,7 +33,7 @@ When nothing is pending, the board renders a single Shield-icon panel with the h ## 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. 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). diff --git a/docs/features/global-search.mdx b/docs/features/global-search.mdx index 75f4463c..0cb1b4db 100644 --- a/docs/features/global-search.mdx +++ b/docs/features/global-search.mdx @@ -25,7 +25,7 @@ The palette groups results into three sections. | Group | What it contains | What happens when you pick one | |-------|------------------|--------------------------------| -| **Pages** | The same set of destinations the top bar shows you. Home, Fleet, Resources, 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 | | **Stacks** | Every compose stack on every online node, matched on the compose filename (extension included). | Switches to the stack's node and opens it in the editor | diff --git a/docs/getting-started/introduction.mdx b/docs/getting-started/introduction.mdx index b3c79acb..4366499d 100644 --- a/docs/getting-started/introduction.mdx +++ b/docs/getting-started/introduction.mdx @@ -34,7 +34,7 @@ The **Home** view is the default landing page. It is designed for a fast operati - The activity panel shows **Fleet Heartbeat** when remote nodes exist, or **Stack Restarts (7d)** on a local-only install. - **Recent Alerts** shows the latest notification feed and includes **Clear All Notifications** when there is anything to clear. -The top navigation 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 diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx index 3fa21e79..038cbaae 100644 --- a/docs/getting-started/quickstart.mdx +++ b/docs/getting-started/quickstart.mdx @@ -107,7 +107,7 @@ You land on **Home**, the default operational view. The health masthead reports Below the stack table, **Configuration Status** summarizes 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`. diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 14aba8e5..3b960682 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -38,6 +38,8 @@ import { useNextAutoUpdateRun } from '@/components/sidebar/useNextAutoUpdateRun' import { usePanelSessionStartedAt } from '@/components/sidebar/usePanelSessionStartedAt'; import type { SidebarActivityAction } from '@/components/sidebar/SidebarActivityTicker'; 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 { useIsMobile } from '@/hooks/use-is-mobile'; import { MobileTabBar } from './MobileTabBar'; @@ -144,6 +146,8 @@ export default function EditorLayout() { }, [setCreateDialogOpen]); const [diffPreviewEnabled] = useComposeDiffPreviewEnabled(); + const [topNavLabels] = useTopNavLabels(); + const [topNavAlign] = useTopNavAlign(); // Use a ref to break the circular dependency: // useViewNavigationState needs onNavigateToDashboard -> resetEditorState @@ -665,6 +669,8 @@ export default function EditorLayout() { themeSwitch={themeSwitchEl} notifications={notificationsEl} userMenu={userMenuEl} + showLabels={topNavLabels} + navAlign={topNavAlign} /> ); diff --git a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx index f8362a00..0e1bffab 100644 --- a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx @@ -217,7 +217,7 @@ describe('useViewNavigationState', () => { 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(); const { result } = renderHook(() => useViewNavigationState()); const values = result.current.navItems.map(i => i.value); @@ -225,6 +225,8 @@ describe('useViewNavigationState', () => { expect(values).toContain('scheduled-ops'); expect(values).not.toContain('host-console'); 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', () => { diff --git a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts index f0721f0e..cc7927a8 100644 --- a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts +++ b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts @@ -123,7 +123,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) // 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: '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 }); } if (isPaid) { diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index ee7694a5..aeba5256 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -1,8 +1,10 @@ -import type { ReactNode } from 'react'; +import { Fragment, type ReactNode } from 'react'; import type { LucideIcon } from 'lucide-react'; import { Menu } from 'lucide-react'; import { Button } from './ui/button'; 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'; export interface TopBarNavItem { @@ -21,6 +23,10 @@ interface TopBarProps { themeSwitch?: ReactNode; notifications: ReactNode; userMenu: ReactNode; + /** Show text labels beside the desktop nav icons. When false, the bar is icon-only. */ + showLabels?: boolean; + /** Desktop nav placement in icon-only mode. Ignored while labels are shown (always left). */ + navAlign?: TopNavAlign; } export function TopBar({ @@ -33,7 +39,12 @@ export function TopBar({ themeSwitch, notifications, userMenu, + showLabels = true, + navAlign = 'left', }: 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 (
- {/* LEFT ZONE: reserved spacer (keeps nav visually centered) */} -
+ {/* LEFT SPACER: balances the right utilities so the nav centers. */} + {centered &&
} - {/* CENTER ZONE: Navigation (hidden on mobile) */} - + {/* NAV ZONE: Navigation (hidden on mobile) */} + + + {/* RIGHT ZONE: Utilities + identity pin */}
diff --git a/frontend/src/components/__tests__/TopBar.test.tsx b/frontend/src/components/__tests__/TopBar.test.tsx new file mode 100644 index 00000000..c8b010d4 --- /dev/null +++ b/frontend/src/components/__tests__/TopBar.test.tsx @@ -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[0]> = {}) { + return render( + , + ); +} + +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(); + }); +}); diff --git a/frontend/src/components/settings/AppearanceSection.tsx b/frontend/src/components/settings/AppearanceSection.tsx index 5756d120..6bf67c81 100644 --- a/frontend/src/components/settings/AppearanceSection.tsx +++ b/frontend/src/components/settings/AppearanceSection.tsx @@ -2,11 +2,14 @@ import { Combobox } from '@/components/ui/combobox'; import { Checkbox } from '@/components/ui/checkbox'; import { Slider } from '@/components/ui/slider'; import { SegmentedControl } from '@/components/ui/segmented-control'; +import { TogglePill } from '@/components/ui/toggle-pill'; import { useDensity } from '@/hooks/use-density'; import type { Density } from '@/hooks/use-density'; import { useDeployFeedbackEnabled } from '@/hooks/use-deploy-feedback-enabled'; import { useDeployFeedbackStyle, type DeployFeedbackStyle } from '@/hooks/use-deploy-feedback-style'; 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 { AccentPicker } from '@/components/theme/AccentPicker'; import { ThemePreview } from '@/components/theme/ThemePreview'; @@ -31,6 +34,11 @@ const DEPLOY_STYLE_OPTIONS: { value: DeployFeedbackStyle; label: string }[] = [ { 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)}`; export function AppearanceSection() { @@ -38,6 +46,8 @@ export function AppearanceSection() { const [isEnabled, setEnabled] = useDeployFeedbackEnabled(); const [feedbackStyle, setFeedbackStyle] = useDeployFeedbackStyle(); const [diffPreviewEnabled, setDiffPreviewEnabled] = useComposeDiffPreviewEnabled(); + const [topNavLabels, setTopNavLabels] = useTopNavLabels(); + const [topNavAlign, setTopNavAlign] = useTopNavAlign(); const { theme, accent, borderBoost, glow, contrast, uiFont, monoFont, typeScale, setTheme, setAccent, setBorderBoost, setGlow, setContrast, setUiFont, setMonoFont, setTypeScale, @@ -193,6 +203,27 @@ export function AppearanceSection() { /> + + + + + {!topNavLabels && ( + + + + )} + { + 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'); + }); +}); diff --git a/frontend/src/hooks/__tests__/use-top-nav-labels.test.ts b/frontend/src/hooks/__tests__/use-top-nav-labels.test.ts new file mode 100644 index 00000000..6fb0e85b --- /dev/null +++ b/frontend/src/hooks/__tests__/use-top-nav-labels.test.ts @@ -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); + }); +}); diff --git a/frontend/src/hooks/use-top-nav-align.ts b/frontend/src/hooks/use-top-nav-align.ts new file mode 100644 index 00000000..e4936d4c --- /dev/null +++ b/frontend/src/hooks/use-top-nav-align.ts @@ -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(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]; +} diff --git a/frontend/src/hooks/use-top-nav-labels.ts b/frontend/src/hooks/use-top-nav-labels.ts new file mode 100644 index 00000000..a3b1851c --- /dev/null +++ b/frontend/src/hooks/use-top-nav-labels.ts @@ -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(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]; +}