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({
) : (
-