From 26f0c5ae8aed0eac50f6a277b7c22fef09109be1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 11 Jul 2026 18:48:59 -0700 Subject: [PATCH] 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) --- .changeset/fn-7825-settings-nav-resizable.md | 7 + docs/dashboard-guide.md | 3 + .../app/components/SettingsModal.css | 56 +++- .../app/components/SettingsModal.tsx | 124 +++++++- .../SettingsModal.navResize.test.tsx | 268 ++++++++++++++++++ packages/dashboard/app/styles.css | 27 +- 6 files changed, 467 insertions(+), 18 deletions(-) create mode 100644 .changeset/fn-7825-settings-nav-resizable.md create mode 100644 packages/dashboard/app/components/__tests__/SettingsModal.navResize.test.tsx diff --git a/.changeset/fn-7825-settings-nav-resizable.md b/.changeset/fn-7825-settings-nav-resizable.md new file mode 100644 index 0000000000..2462c6701f --- /dev/null +++ b/.changeset/fn-7825-settings-nav-resizable.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c1c2fdd05f..f4a9cfc7b5 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -16,6 +16,9 @@ Use **Search settings** at the top of Settings to find the section that contains 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. + +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. + Every user-editable setting's help text (the `.settings-description`/`` 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. diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index 9b1d511331..ac88ca2ce0 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -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); } diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index e2357c18b9..465294a5eb 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -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) => { + 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) => { + 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({
) : (
-