FN-7825: add resizable Settings navigation rail with persisted width

Removes the hard divider between the Settings navigation rail and content, keeps section rows single-line with ellipsis overflow, and adds a draggable/keyboard-resizable handle that persists the rail's width in localStorage across the standalone modal and embedded Settings page.

- Add a resize handle (.settings-nav-resize-handle) between .settings-navigation and .settings-content, draggable via pointer events and resizable with ArrowLeft/ArrowRight when focused
- Persist chosen width to localStorage (fusion:settings-nav-width), clamped between 200px and 420px, defaulting to 248px; restore on mount
- Make .settings-navigation the sole owner of rail width via a --settings-nav-width CSS custom property instead of a fixed width, and drop the border-right divider
- Keep nav section labels on one line with white-space: nowrap + text-overflow: ellipsis in both the modal (SettingsModal.css) and embedded (styles.css) nav item styles
- Hide the resize handle on mobile; mobile keeps the stacked section picker unaffected
- Update docs/dashboard-guide.md to describe the new divider-less rail and resize behavior
- Add SettingsModal.navResize.test.tsx covering drag-resize, keyboard-resize, and width persistence
- Add changeset .changeset/fn-7825-settings-nav-resizable.md (minor)

Files changed:
 .changeset/fn-7825-settings-nav-resizable.md                                    |   7 +
 docs/dashboard-guide.md                                                         |   3 +
 packages/dashboard/app/components/SettingsModal.css                             |  56 ++++-
 packages/dashboard/app/components/SettingsModal.tsx                             | 124 +++++++++-
 packages/dashboard/app/components/__tests__/SettingsModal.navResize.test.tsx     | 268 +++++++++++++++++++++
 packages/dashboard/app/styles.css                                               |  27 ++-
 6 files changed, 467 insertions(+), 18 deletions(-)

Fusion-Task-Id: FN-7825
Fusion-Task-Lineage: 34b940b4-2698-46a4-b47c-cda0b0cac564
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-11 18:48:59 -07:00
parent 41998a6c75
commit 26f0c5ae8a
6 changed files with 467 additions and 18 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a resizable Settings navigation rail that remembers its width.
category: feature
dev: Removes the Settings rail divider and keeps nav labels single-line across modal and embedded Settings.

View File

@@ -16,6 +16,9 @@ Use **Search settings** at the top of Settings to find the section that contains
<!-- FNXC:Settings 2026-07-09-12:00: FN-7751 keeps FN-7713's collapsed mobile search row but moves the toggle inline beside the section dropdown so mobile Settings exposes one compact section/search control row; desktop/tablet keep the row always visible with no toggle. -->
On mobile, the search row starts collapsed behind a compact toggle icon beside the **Settings Section** dropdown to save vertical space; tap it to reveal the search input and tap again to hide it. An in-progress search query is preserved across collapse/expand. Desktop and tablet always show the search row with no toggle.
<!-- FNXC:SettingsDocs 2026-07-11-18:58: FN-7825 makes the Settings navigation rail read as one clean surface: no vertical content divider, single-line section rows, and a desktop/tablet resize handle with browser-local width persistence. Mobile remains the stacked section picker. -->
On desktop and tablet, the Settings navigation rail has no hard divider between navigation and content; section rows stay on one line and use ellipsis for unusually long labels. Drag the thin handle between the navigation rail and content pane to widen or narrow the rail. Fusion remembers that width in browser storage and restores it for both the standalone Settings modal and embedded Settings page. Mobile keeps the stacked **Settings Section** picker and does not show the resize handle.
<!-- FNXC:SettingsDefaults 2026-07-04-00:00: FN-7505 requires every user-editable setting's help text to state its own default value, so operators reading a field's description know what it defaults to without checking the reference doc. -->
Every user-editable setting's help text (the `.settings-description`/`<small>` hint under a field) states its own default value — for example “Default: 3.”, “Default: enabled.”, or “No default — unset (inherits the global setting).” for values that fall back to another scope. Canonical default values come from `DEFAULT_GLOBAL_SETTINGS` / `DEFAULT_PROJECT_SETTINGS` in `packages/core/src/settings-schema.ts`; the dashboard copy never invents a number. A guard test (`settings-default-descriptions.test.tsx`) enforces that every surfaced setting states its default and that every `DEFAULT_SETTINGS` key is either documented or explicitly allowlisted as not surfaced in the Settings UI.

View File

@@ -641,24 +641,58 @@ Fix the invariant for BOTH presentations (standalone modal + embedded SettingsVi
overflow: hidden;
}
/*
FNXC:SettingsSimplification 2026-07-11-00:42:
The Settings rail is one navigation surface. The legacy translucent background on both the parent rail and nested section list compounded into a visibly lighter second tone; keep the parent opaque and the nested scroller transparent in every theme.
FNXC:Settings 2026-07-11-18:45:
FN-7825 removes the hard divider between the Settings rail and content, keeps section rows single-line at the rail's full width, and makes this parent rail the authoritative width owner so the upcoming desktop resize handle can update one CSS custom property without fighting nested sidebar rules.
*/
.settings-navigation {
width: calc(var(--space-xl) * 10 + var(--space-sm));
min-width: calc(var(--space-xl) * 10 + var(--space-sm));
border-right: var(--btn-border-width) solid var(--border);
--settings-nav-width: calc(var(--space-xl) * 10 + var(--space-sm));
width: var(--settings-nav-width);
min-width: var(--settings-nav-width);
display: flex;
flex-direction: column;
min-height: 0;
background: var(--surface);
}
/*
FNXC:SettingsSimplification 2026-07-11-00:42:
The Settings rail is one navigation surface. The legacy translucent background on both the parent rail and nested section list compounded into a visibly lighter second tone; keep the parent opaque and the nested scroller transparent in every theme.
*/
.settings-navigation .settings-sidebar {
background: transparent;
}
.settings-nav-resize-handle {
flex: 0 0 var(--space-xs);
margin-inline: calc(var(--space-xs) * -0.5);
cursor: col-resize;
background: transparent;
border: 0;
outline: none;
position: relative;
z-index: 1;
}
.settings-nav-resize-handle::before {
content: "";
position: absolute;
inset-block: var(--space-sm);
inset-inline: calc(50% - (var(--btn-border-width) / 2));
width: var(--btn-border-width);
border-radius: var(--radius-pill);
background: transparent;
transition: background var(--transition-fast);
}
.settings-nav-resize-handle:hover::before,
.settings-nav-resize-handle:focus-visible::before {
background: var(--border);
}
.settings-nav-resize-handle:focus-visible {
box-shadow: var(--focus-ring-strong);
}
.settings-search {
display: flex;
flex-direction: column;
@@ -843,6 +877,9 @@ The Advanced settings preference is a navigation-level disclosure, so keep it vi
border-radius: 0 var(--radius-md) var(--radius-md) 0;
cursor: pointer;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition:
background var(--transition-fast),
color var(--transition-fast),
@@ -861,6 +898,7 @@ The simplified Settings surface needs one consistent reading rhythm across legac
min-height: 36px;
display: flex;
align-items: center;
gap: var(--space-xs);
line-height: 1.25;
}
@@ -2471,6 +2509,10 @@ The header row wraps so the badge drops below the heading on narrow widths inste
background: var(--surface);
}
.settings-nav-resize-handle {
display: none;
}
.settings-search {
padding: var(--space-sm) var(--space-md) var(--space-sm);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties, type MouseEvent } from "react";
import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent } from "react";
import { Globe, Folder, RefreshCw, Star, HelpCircle, Settings as SettingsIcon, Search, X as SearchToggleCloseIcon } from "lucide-react";
import {
getErrorMessage,
@@ -239,6 +239,10 @@ type SettingsSection = {
const MOBILE_SETTINGS_MEDIA_QUERY = "(max-width: 768px)";
const DEFAULT_MEMORY_EDITOR_PATH = ".fusion/memory/DREAMS.md";
const ADVANCED_SETTINGS_STORAGE_KEY = "fusion:settings:show-advanced";
const SETTINGS_NAV_WIDTH_STORAGE_KEY = "fusion:settings-nav-width";
const SETTINGS_NAV_DEFAULT_WIDTH = 248;
const SETTINGS_NAV_MIN_WIDTH = 200;
const SETTINGS_NAV_MAX_WIDTH = 420;
/*
FNXC:SettingsSimplification 2026-07-10-23:24:
@@ -274,6 +278,20 @@ function readAdvancedSettingsPreference(): boolean {
}
}
function clampSettingsNavWidth(width: number): number {
if (!Number.isFinite(width)) return SETTINGS_NAV_DEFAULT_WIDTH;
return Math.min(SETTINGS_NAV_MAX_WIDTH, Math.max(SETTINGS_NAV_MIN_WIDTH, Math.round(width)));
}
function readSettingsNavWidthPreference(): number {
try {
const stored = Number.parseFloat(localStorage.getItem(SETTINGS_NAV_WIDTH_STORAGE_KEY) ?? "");
return clampSettingsNavWidth(stored);
} catch {
return SETTINGS_NAV_DEFAULT_WIDTH;
}
}
function removeEmptySettingsGroups(sections: SettingsSection[]): SettingsSection[] {
return sections.filter((section, index) => {
if (!section.isGroupHeader) return true;
@@ -1024,6 +1042,12 @@ export function SettingsModal({
? window.matchMedia(MOBILE_SETTINGS_MEDIA_QUERY)?.matches === true
: false),
);
/**
* FNXC:Settings 2026-07-11-18:52:
* FN-7825 makes the desktop/tablet Settings rail resizable and persists the chosen width locally. Mobile remains stacked and ignores this inline CSS variable so a desktop-saved width cannot leak into the top-bar layout.
*/
const [settingsNavWidth, setSettingsNavWidth] = useState(() => readSettingsNavWidthPreference());
const settingsNavDragRef = useRef<{ startX: number; startWidth: number; previousUserSelect: string } | null>(null);
const [settingsSearchQuery, setSettingsSearchQuery] = useState("");
const [showAdvancedSettings, setShowAdvancedSettings] = useState(() => {
const requestedSection = initialSection === "pi-extensions" ? "plugins" : initialSection;
@@ -1041,6 +1065,16 @@ export function SettingsModal({
// Storage can be unavailable in private/locked-down browser contexts; the in-session preference still works.
}
}, []);
const persistSettingsNavWidth = useCallback((width: number) => {
const nextWidth = clampSettingsNavWidth(width);
setSettingsNavWidth(nextWidth);
try {
localStorage.setItem(SETTINGS_NAV_WIDTH_STORAGE_KEY, String(nextWidth));
} catch {
// Storage can be unavailable in private/locked-down browser contexts; the in-session width still works.
}
return nextWidth;
}, []);
/*
* FNXC:Settings 2026-07-09-00:00:
* Mobile Settings previously always rendered the `.settings-search` row (label + input + result
@@ -1272,6 +1306,74 @@ export function SettingsModal({
enabled: activeSection === "memory",
});
const settingsNavResizeEnabled = !showMobileSectionPicker;
const settingsNavigationStyle = settingsNavResizeEnabled
? ({ "--settings-nav-width": `${settingsNavWidth}px` } as CSSProperties)
: undefined;
const endSettingsNavResize = useCallback((pointerId?: number, target?: EventTarget | null) => {
const dragState = settingsNavDragRef.current;
if (!dragState) return;
document.body.style.userSelect = dragState.previousUserSelect;
settingsNavDragRef.current = null;
if (typeof pointerId === "number" && target instanceof HTMLElement && typeof target.releasePointerCapture === "function") {
try {
target.releasePointerCapture(pointerId);
} catch {
// Pointer capture may already be released by the browser; cleanup is still complete.
}
}
}, []);
const handleSettingsNavResizePointerMove = useCallback((event: PointerEvent) => {
const dragState = settingsNavDragRef.current;
if (!dragState) return;
event.preventDefault();
persistSettingsNavWidth(dragState.startWidth + event.clientX - dragState.startX);
}, [persistSettingsNavWidth]);
const handleSettingsNavResizePointerUp = useCallback((event: PointerEvent) => {
endSettingsNavResize(event.pointerId, event.target);
}, [endSettingsNavResize]);
const handleSettingsNavResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (!settingsNavResizeEnabled) return;
event.preventDefault();
event.stopPropagation();
if (typeof event.currentTarget.setPointerCapture === "function") {
event.currentTarget.setPointerCapture(event.pointerId);
}
settingsNavDragRef.current = {
startX: event.clientX,
startWidth: settingsNavWidth,
previousUserSelect: document.body.style.userSelect,
};
document.body.style.userSelect = "none";
}, [settingsNavResizeEnabled, settingsNavWidth]);
const handleSettingsNavResizeKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
if (!settingsNavResizeEnabled) return;
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
persistSettingsNavWidth(settingsNavWidth + (event.key === "ArrowRight" ? 16 : -16));
}, [persistSettingsNavWidth, settingsNavResizeEnabled, settingsNavWidth]);
useEffect(() => {
if (!settingsNavResizeEnabled) {
endSettingsNavResize();
return;
}
document.addEventListener("pointermove", handleSettingsNavResizePointerMove);
document.addEventListener("pointerup", handleSettingsNavResizePointerUp);
document.addEventListener("pointercancel", handleSettingsNavResizePointerUp);
return () => {
document.removeEventListener("pointermove", handleSettingsNavResizePointerMove);
document.removeEventListener("pointerup", handleSettingsNavResizePointerUp);
document.removeEventListener("pointercancel", handleSettingsNavResizePointerUp);
endSettingsNavResize();
};
}, [endSettingsNavResize, handleSettingsNavResizePointerMove, handleSettingsNavResizePointerUp, settingsNavResizeEnabled]);
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return;
@@ -3764,7 +3866,11 @@ export function SettingsModal({
<div className="settings-empty-state settings-loading"><LoadingSpinner label={t("settings.loading", "Loading…")} /></div>
) : (
<div className="settings-layout">
<aside className="settings-navigation" aria-label={t("settings.search.navigationLabel", "Settings navigation")}>
<aside
className="settings-navigation"
aria-label={t("settings.search.navigationLabel", "Settings navigation")}
style={settingsNavigationStyle}
>
{showMobileSectionPicker && (
<div className="settings-mobile-section-picker">
{/**
@@ -3901,6 +4007,20 @@ export function SettingsModal({
)}
</nav>
</aside>
{settingsNavResizeEnabled && (
<div
className="settings-nav-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label={t("settings.nav.resize", "Resize settings navigation")}
aria-valuemin={SETTINGS_NAV_MIN_WIDTH}
aria-valuemax={SETTINGS_NAV_MAX_WIDTH}
aria-valuenow={settingsNavWidth}
tabIndex={0}
onPointerDown={handleSettingsNavResizePointerDown}
onKeyDown={handleSettingsNavResizeKeyDown}
/>
)}
<div
className="settings-content"
ref={settingsContentRef}

View File

@@ -0,0 +1,268 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import {
mockFetchSettings,
mockFetchSettingsByScope,
mockExportSettings,
mockUpdateSettings,
mockUpdateGlobalSettings,
mockFetchAuthStatus,
mockLoginProvider,
mockLogoutProvider,
mockCancelProviderLogin,
mockSaveApiKey,
mockSubmitProviderManualCode,
mockFetchModels,
mockFetchWorkflow,
mockFetchWorkflowSettingValues,
mockUpdateWorkflowSettingValues,
mockFetchCustomProviders,
mockCreateCustomProvider,
mockUpdateCustomProvider,
mockDeleteCustomProvider,
mockTestNtfyNotification,
mockTestNotification,
mockFetchBackups,
mockCreateBackup,
mockImportSettings,
mockFetchMemoryFiles,
mockFetchMemoryFile,
mockSaveMemoryFile,
mockCompactMemory,
mockFetchGlobalConcurrency,
mockUpdateGlobalConcurrency,
mockFetchMemoryBackendStatus,
mockTestMemoryRetrieval,
mockInstallQmd,
mockFetchGitRemotes,
mockFetchGitRemotesDetailed,
mockFetchProjects,
mockFetchDashboardHealth,
mockCheckForUpdates,
mockInstallUpdate,
mockFetchRemoteSettings,
mockUpdateRemoteSettings,
mockFetchRemoteStatus,
mockInstallCloudflared,
mockStartRemoteTunnel,
mockStopRemoteTunnel,
mockKillExternalTunnel,
mockRegenerateRemotePersistentToken,
mockGenerateShortLivedRemoteToken,
mockFetchRemoteQr,
mockFetchRemoteUrl,
mockTriggerMemoryDreams,
mockFetchPluginUiSlots,
mockFetchDroidCliStatus,
mockSetDroidCliEnabled,
mockFetchCursorCliStatus,
mockSetCursorCliEnabled,
mockSetCursorCliBinaryPath,
mockUseWorkspaceFileBrowser,
mockConfirm,
mockUseWorktrunkInstallStatus,
mockUseMemoryBackendStatus,
mockUseMobileKeyboard,
settingsModalCss,
renderModal,
waitForSettingsModalReady,
installSettingsModalEnv,
} from "./SettingsModal.test-harness";
const viewportMock = vi.hoisted(() => ({ mode: "desktop" as "desktop" | "mobile" }));
vi.mock("../../api", async (importOriginal) => {
const { createDashboardApiMock } = await import("../../test/mockApi");
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
fetchSettingsByScope: (...args: unknown[]) => mockFetchSettingsByScope(...args),
updateSettings: (...args: unknown[]) => mockUpdateSettings(...args),
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
exportSettings: (...args: unknown[]) => mockExportSettings(...args),
importSettings: (...args: unknown[]) => mockImportSettings(...args),
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
loginProvider: (...args: unknown[]) => mockLoginProvider(...args),
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
cancelProviderLogin: (...args: unknown[]) => mockCancelProviderLogin(...args),
saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args),
submitProviderManualCode: (...args: unknown[]) => mockSubmitProviderManualCode(...args),
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
fetchWorkflow: (...args: unknown[]) => mockFetchWorkflow(...args),
fetchWorkflowSettingValues: (...args: unknown[]) => mockFetchWorkflowSettingValues(...args),
updateWorkflowSettingValues: (...args: unknown[]) => mockUpdateWorkflowSettingValues(...args),
fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args),
createCustomProvider: (...args: unknown[]) => mockCreateCustomProvider(...args),
updateCustomProvider: (...args: unknown[]) => mockUpdateCustomProvider(...args),
deleteCustomProvider: (...args: unknown[]) => mockDeleteCustomProvider(...args),
testNtfyNotification: (...args: unknown[]) => mockTestNtfyNotification(...args),
testNotification: (...args: unknown[]) => mockTestNotification(...args),
fetchBackups: (...args: unknown[]) => mockFetchBackups(...args),
createBackup: (...args: unknown[]) => mockCreateBackup(...args),
fetchMemoryFiles: (...args: unknown[]) => mockFetchMemoryFiles(...args),
fetchMemoryFile: (...args: unknown[]) => mockFetchMemoryFile(...args),
saveMemoryFile: (...args: unknown[]) => mockSaveMemoryFile(...args),
compactMemory: (...args: unknown[]) => mockCompactMemory(...args),
fetchGlobalConcurrency: (...args: unknown[]) => mockFetchGlobalConcurrency(...args),
updateGlobalConcurrency: (...args: unknown[]) => mockUpdateGlobalConcurrency(...args),
fetchMemoryBackendStatus: (...args: unknown[]) => mockFetchMemoryBackendStatus(...args),
testMemoryRetrieval: (...args: unknown[]) => mockTestMemoryRetrieval(...args),
installQmd: (...args: unknown[]) => mockInstallQmd(...args),
fetchGitRemotes: (...args: unknown[]) => mockFetchGitRemotes(...args),
fetchGitRemotesDetailed: (...args: unknown[]) => mockFetchGitRemotesDetailed(...args),
fetchProjects: (...args: unknown[]) => mockFetchProjects(...args),
fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args),
checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args),
installUpdate: (...args: unknown[]) => mockInstallUpdate(...args),
fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args),
updateRemoteSettings: (...args: unknown[]) => mockUpdateRemoteSettings(...args),
fetchRemoteStatus: (...args: unknown[]) => mockFetchRemoteStatus(...args),
installCloudflared: (...args: unknown[]) => mockInstallCloudflared(...args),
startRemoteTunnel: (...args: unknown[]) => mockStartRemoteTunnel(...args),
stopRemoteTunnel: (...args: unknown[]) => mockStopRemoteTunnel(...args),
killExternalTunnel: (...args: unknown[]) => mockKillExternalTunnel(...args),
regenerateRemotePersistentToken: (...args: unknown[]) => mockRegenerateRemotePersistentToken(...args),
generateShortLivedRemoteToken: (...args: unknown[]) => mockGenerateShortLivedRemoteToken(...args),
fetchRemoteQr: (...args: unknown[]) => mockFetchRemoteQr(...args),
fetchRemoteUrl: (...args: unknown[]) => mockFetchRemoteUrl(...args),
triggerMemoryDreams: (...args: unknown[]) => mockTriggerMemoryDreams(...args),
fetchPluginUiSlots: (...args: unknown[]) => mockFetchPluginUiSlots(...args),
fetchDroidCliStatus: (...args: unknown[]) => mockFetchDroidCliStatus(...args),
setDroidCliEnabled: (...args: unknown[]) => mockSetDroidCliEnabled(...args),
fetchCursorCliStatus: (...args: unknown[]) => mockFetchCursorCliStatus(...args),
setCursorCliEnabled: (...args: unknown[]) => mockSetCursorCliEnabled(...args),
setCursorCliBinaryPath: (...args: unknown[]) => mockSetCursorCliBinaryPath(...args),
});
});
vi.mock("../../hooks/useMemoryBackendStatus", () => ({
useMemoryBackendStatus: (...args: unknown[]) => mockUseMemoryBackendStatus(...args),
}));
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args),
}));
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: (...args: unknown[]) => mockConfirm(...args) }),
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
getViewportMode: () => viewportMock.mode,
isMobileViewport: () => viewportMock.mode === "mobile",
useViewportMode: () => viewportMock.mode,
}));
vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({
useWorkspaceFileBrowser: (...args: unknown[]) => mockUseWorkspaceFileBrowser(...args),
}));
vi.mock("../../hooks/useWorktrunkInstallStatus", () => ({
useWorktrunkInstallStatus: (...args: unknown[]) => mockUseWorktrunkInstallStatus(...args),
}));
vi.mock("../FileBrowser", () => ({
FileBrowser: ({ onSelectFile }: { onSelectFile: (path: string) => void }) => (
<div data-testid="mock-overlap-file-browser">
<button type="button" onClick={() => onSelectFile("README.md")}>Select README.md</button>
</div>
),
}));
vi.mock("../PluginManager", () => ({
PluginManager: () => <div data-testid="plugin-manager">Plugin manager content</div>,
}));
vi.mock("../PiExtensionsManager", () => ({
PiExtensionsManager: () => <div data-testid="pi-extensions-manager">Pi extensions content</div>,
}));
function setMatchMediaMatches(matches: boolean) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
function getCssBlock(css: string, selector: string) {
const match = css.match(new RegExp(`${selector.replace(/\./g, "\\.")}\\s*\\{([^}]*)\\}`));
expect(match?.[1]).toBeDefined();
return match![1];
}
function getSettingsNavigation() {
const navigation = document.querySelector<HTMLElement>(".settings-navigation");
expect(navigation).not.toBeNull();
return navigation!;
}
describe("SettingsModal navigation rail resize", () => {
installSettingsModalEnv();
beforeEach(() => {
viewportMock.mode = "desktop";
});
it("asserts the non-wrapping nav contract and persists desktop resize drags", async () => {
// Symptom verification: jsdom has no layout engine, so pixel-accurate visual wrapping and divider absence remain manual-visual checks. This automated test asserts the shipped mechanism: nowrap CSS contract, desktop separator affordance, drag persistence, and restore on remount.
const navItemBlock = getCssBlock(settingsModalCss, ".settings-nav-item");
expect(navItemBlock).toContain("white-space: nowrap;");
expect(navItemBlock).toContain("overflow: hidden;");
expect(navItemBlock).toContain("text-overflow: ellipsis;");
expect(getCssBlock(settingsModalCss, ".settings-navigation")).not.toContain("border-right:");
expect(getCssBlock(settingsModalCss, ".settings-sidebar")).not.toContain("border-right:");
const firstRender = renderModal({ initialSection: "keyboard-shortcuts" });
await waitForSettingsModalReady();
const separator = screen.getByRole("separator", { name: "Resize settings navigation" });
expect(separator).toHaveAttribute("aria-orientation", "vertical");
expect(separator).toHaveAttribute("aria-valuemin", "200");
expect(separator).toHaveAttribute("aria-valuemax", "420");
expect(separator).toHaveAttribute("aria-valuenow", "248");
expect(getSettingsNavigation().style.getPropertyValue("--settings-nav-width")).toBe("248px");
fireEvent.pointerDown(separator, { pointerId: 1, clientX: 100 });
fireEvent.pointerMove(document, { pointerId: 1, clientX: 160 });
fireEvent.pointerUp(document, { pointerId: 1, clientX: 160 });
await waitFor(() => expect(localStorage.getItem("fusion:settings-nav-width")).toBe("308"));
expect(getSettingsNavigation().style.getPropertyValue("--settings-nav-width")).toBe("308px");
firstRender.unmount();
renderModal({ initialSection: "keyboard-shortcuts" });
await waitForSettingsModalReady();
expect(getSettingsNavigation().style.getPropertyValue("--settings-nav-width")).toBe("308px");
expect(screen.getByRole("separator", { name: "Resize settings navigation" })).toHaveAttribute("aria-valuenow", "308");
});
it("does not render the resize handle when the viewport hook reports mobile", async () => {
viewportMock.mode = "mobile";
renderModal();
await waitForSettingsModalReady();
expect(screen.queryByRole("separator", { name: "Resize settings navigation" })).not.toBeInTheDocument();
expect(getSettingsNavigation().style.getPropertyValue("--settings-nav-width")).toBe("");
});
it("does not render the resize handle when the Settings media query matches mobile", async () => {
setMatchMediaMatches(true);
renderModal();
await waitForSettingsModalReady();
expect(screen.queryByRole("separator", { name: "Resize settings navigation" })).not.toBeInTheDocument();
expect(getSettingsNavigation().style.getPropertyValue("--settings-nav-width")).toBe("");
});
});

View File

@@ -652,8 +652,8 @@ html .column.drag-over * {
background: color-mix(in srgb, var(--surface) 60%, transparent);
}
[data-theme="light"] .settings-sidebar {
background: color-mix(in srgb, var(--surface) 60%, transparent);
[data-theme="light"] .settings-navigation .settings-sidebar {
background: transparent;
}
[data-theme="light"] .toast-success {
@@ -1616,16 +1616,18 @@ input[type="range"]:focus-visible {
display: none;
}
/*
FNXC:Settings 2026-07-11-18:45:
FN-7825 makes .settings-navigation the sole owner of Settings rail width and removes the old nested sidebar divider. Keep this global fallback structural only so embedded and modal Settings do not fight competing width/border/background rules.
*/
.settings-sidebar {
width: 170px;
min-width: 170px;
border-right: 1px solid var(--border);
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow-y: auto;
padding: 10px 8px;
gap: 2px;
background: color-mix(in srgb, var(--text) 10%, transparent);
padding: var(--space-md) var(--space-sm);
gap: calc(var(--space-xs) / 2);
scrollbar-color: var(--border) transparent;
scrollbar-width: thin;
}
@@ -1648,11 +1650,15 @@ input[type="range"]:focus-visible {
}
.settings-nav-item {
display: block;
display: flex;
align-items: center;
gap: var(--space-xs);
width: 100%;
min-height: 36px;
padding: var(--space-sm) var(--space-md);
font-size: 13px;
font-weight: 500;
line-height: 1.25;
color: var(--text-muted);
background: none;
border: none;
@@ -1660,6 +1666,9 @@ input[type="range"]:focus-visible {
border-radius: 0 var(--radius) var(--radius) 0;
cursor: pointer;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition:
background var(--transition-fast),
color var(--transition-fast),