FN-6847: move sidebar collapse toggle into footer
Move the left sidebar collapse control into the footer so it sits above Settings with row-style behavior. - Replace the floating border collapse button with a footer row button that keeps accessible expanded/collapsed labels. - Retokenize collapse toggle CSS around shared sidebar item styling and remove the obsolete floating modifier. - Extend sidebar tests and dashboard guide coverage for the footer placement and collapsed rail behavior. Files changed: docs/dashboard-guide.md | 4 +- .../dashboard/app/components/LeftSidebarNav.css | 24 ++-------- .../dashboard/app/components/LeftSidebarNav.tsx | 32 ++++++------- .../components/__tests__/LeftSidebarNav.test.tsx | 54 ++++++++++++++++++++-- 4 files changed, 72 insertions(+), 42 deletions(-) Fusion-Task-Id: FN-6847 Fusion-Task-Lineage: 5d7781a1-afc9-4e5c-bf7b-0648bb874285
This commit is contained in:
@@ -23,11 +23,11 @@ Task Detail modal opens from onboarding, activity log, and task-to-task navigati
|
||||
|
||||
Enable **Left Sidebar Navigation** from **Settings → Experimental Features** to move the desktop/tablet project navigation out of the Header and into a persistent left sidebar.
|
||||
|
||||
When enabled on desktop or tablet project screens, the sidebar contains the primary destinations (Board, List, Agents, Command Center, Missions, Chat, Documents, Mailbox, and plugin primary views), Header overflow destinations as regular entries (Research, Insights, Skills, Memory, Secrets, Stash Recovery, Evals, Goals, Dev Server, Todos, and plugin overflow views when their flags/plugins are enabled), and a Settings button pinned to the bottom. The Header retains the Fusion brand and project selector, keeps its non-navigation controls, and hides the view-toggle row and **More views** trigger so there is only one canonical navigation surface.
|
||||
When enabled on desktop or tablet project screens, the sidebar contains the primary destinations (Board, List, Agents, Command Center, Missions, Chat, Documents, Mailbox, and plugin primary views), Header overflow destinations as regular entries (Research, Insights, Skills, Memory, Secrets, Stash Recovery, Evals, Goals, Dev Server, Todos, and plugin overflow views when their flags/plugins are enabled), and a footer with the collapse toggle directly above the Settings button. The Header retains the Fusion brand and project selector, keeps its non-navigation controls, and hides the view-toggle row and **More views** trigger so there is only one canonical navigation surface.
|
||||
|
||||
While the sidebar is active on desktop/tablet project screens, Board and List workflow controls move into the Header slot that replaces the hidden view toggle. Board and List share one workflow dropdown: each workflow row includes an inline edit action, and a persistent **New workflow** action remains at the bottom of the dropdown while the workflow list scrolls. The standalone workflow row above the board/list content is removed in this mode. When the flag is off, outside project screens, or on mobile, workflow controls remain inline with the same consolidated dropdown.
|
||||
|
||||
A small right-border toggle collapses or expands the sidebar without consuming a navigation row; collapsed rail mode keeps accessible labels/titles preserved, and the expanded width can be resized from the right-edge separator. Collapsed state and expanded width are saved in browser `localStorage` (`fusion:left-sidebar-collapsed` and `fusion:left-sidebar-width`) and restored on reload.
|
||||
The footer collapse toggle uses the same row styling as other sidebar items: expanded mode shows a **Collapse** label, while collapsed rail mode hides the label and keeps the icon-only button accessible through its label/title. The expanded width can still be resized from the right-edge separator. Collapsed state and expanded width are saved in browser `localStorage` (`fusion:left-sidebar-collapsed` and `fusion:left-sidebar-width`) and restored on reload.
|
||||
|
||||
On mobile viewports (`<=768px`), the sidebar is not rendered even when the experiment is enabled. The existing bottom `MobileNavBar` remains the navigation surface.
|
||||
|
||||
|
||||
@@ -25,27 +25,13 @@ The left sidebar is a sibling of project-content, so it does not inherit project
|
||||
padding-bottom: var(--executor-footer-height);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Navigation 2026-06-21-00:00:
|
||||
The collapse toggle lives in the footer above Settings instead of floating on the resize border. It reuses the tokenized row-item styling so expanded mode can show the Collapse label and rail mode can hide that label consistently with other sidebar items.
|
||||
*/
|
||||
.left-sidebar-nav__collapse-toggle {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:DashboardStyling 2026-06-20-20:56:
|
||||
The floating sidebar collapse toggle must stay fully round, but FN-6809 surfaced that --radius-full is not a defined dashboard token. Use 50% here to preserve the circular affordance without introducing another undefined custom property.
|
||||
*/
|
||||
.left-sidebar-nav__collapse-toggle--floating {
|
||||
position: absolute;
|
||||
top: var(--space-sm);
|
||||
right: calc((var(--space-lg) + var(--space-xs)) * -1);
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: calc(var(--space-lg) + var(--space-md));
|
||||
min-height: calc(var(--space-lg) + var(--space-md));
|
||||
padding: var(--space-xs);
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--shadow-sm);
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.left-sidebar-nav__list {
|
||||
|
||||
@@ -397,28 +397,28 @@ export function LeftSidebarNav({
|
||||
aria-label={t("nav.sidebarAriaLabel", "Sidebar navigation")}
|
||||
style={isCollapsed ? undefined : { width: sidebarWidth, minWidth: sidebarWidth }}
|
||||
>
|
||||
{/*
|
||||
FNXC:Navigation 2026-06-20-12:00:
|
||||
The sidebar collapse affordance must not consume a dedicated brand row now that logo, wordmark, and project controls live in Header. Float the single toggle on the resize border so expanded and rail states retain the same reachable click target without an empty header shell.
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon left-sidebar-nav__collapse-toggle left-sidebar-nav__collapse-toggle--floating"
|
||||
aria-label={isCollapsed ? t("nav.expandSidebar", "Expand sidebar") : t("nav.collapseSidebar", "Collapse sidebar")}
|
||||
title={isCollapsed ? t("nav.expandSidebar", "Expand sidebar") : t("nav.collapseSidebar", "Collapse sidebar")}
|
||||
aria-pressed={isCollapsed}
|
||||
data-testid="sidebar-nav-collapse-toggle"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
||||
</button>
|
||||
|
||||
<nav className="left-sidebar-nav__list" aria-label={t("nav.primaryNavAriaLabel", "Primary navigation")}>
|
||||
<div className="left-sidebar-nav__section">{primaryEntries.map(renderEntry)}</div>
|
||||
<div className="left-sidebar-nav__section left-sidebar-nav__section--secondary">{secondaryEntries.map(renderEntry)}</div>
|
||||
</nav>
|
||||
|
||||
<div className="left-sidebar-nav__footer">
|
||||
{/*
|
||||
FNXC:Navigation 2026-06-21-00:00:
|
||||
The sidebar collapse affordance belongs in the footer immediately above Settings, using the same row-item visual language. Expanded mode shows the Collapse label, while rail mode relies on the shared label-hiding rule so the button remains icon-only like Settings.
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
className="btn left-sidebar-nav__item left-sidebar-nav__collapse-toggle"
|
||||
aria-label={isCollapsed ? t("nav.expandSidebar", "Expand sidebar") : t("nav.collapseSidebar", "Collapse sidebar")}
|
||||
title={isCollapsed ? t("nav.expandSidebar", "Expand sidebar") : t("nav.collapseSidebar", "Collapse sidebar")}
|
||||
aria-pressed={isCollapsed}
|
||||
data-testid="sidebar-nav-collapse-toggle"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
||||
<span className="left-sidebar-nav__label">{t("nav.collapse", "Collapse")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn left-sidebar-nav__item left-sidebar-nav__settings"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
@@ -26,6 +28,15 @@ const projects: ProjectInfo[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const leftSidebarNavCss = readFileSync(resolve(__dirname, "../LeftSidebarNav.css"), "utf8");
|
||||
const obsoleteCollapseToggleFloatingClass = "left-sidebar-nav__collapse-toggle--" + "floating";
|
||||
|
||||
function getCssRuleBlock(css: string, selector: string) {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
|
||||
return match?.[1] ?? "";
|
||||
}
|
||||
|
||||
const pluginViews: PluginDashboardViewEntry[] = [
|
||||
{
|
||||
pluginId: "fusion-plugin-primary",
|
||||
@@ -57,10 +68,22 @@ function expectNoSidebarBrandOrProjectAffordances(container: HTMLElement) {
|
||||
expect(container.querySelector(".left-sidebar-nav__wordmark")).toBeNull();
|
||||
}
|
||||
|
||||
function expectSettingsLastInFooter() {
|
||||
function expectCollapseToggleImmediatelyBeforeSettings() {
|
||||
const footer = screen.getByTestId("sidebar-nav-settings").closest(".left-sidebar-nav__footer");
|
||||
const toggle = screen.getByTestId("sidebar-nav-collapse-toggle");
|
||||
const settings = screen.getByTestId("sidebar-nav-settings");
|
||||
expect(footer).not.toBeNull();
|
||||
expect(footer?.lastElementChild).toBe(screen.getByTestId("sidebar-nav-settings"));
|
||||
expect(toggle.closest(".left-sidebar-nav__footer")).toBe(footer);
|
||||
expect(toggle).toHaveClass("left-sidebar-nav__item");
|
||||
expect(toggle).toHaveClass("left-sidebar-nav__collapse-toggle");
|
||||
expect(toggle).not.toHaveClass(obsoleteCollapseToggleFloatingClass);
|
||||
expect(footer?.children[0]).toBe(toggle);
|
||||
expect(toggle.nextElementSibling).toBe(settings);
|
||||
expect(footer?.lastElementChild).toBe(settings);
|
||||
}
|
||||
|
||||
function expectSettingsLastInFooter() {
|
||||
expectCollapseToggleImmediatelyBeforeSettings();
|
||||
}
|
||||
|
||||
function renderSidebar(overrides: Partial<ComponentProps<typeof LeftSidebarNav>> = {}) {
|
||||
@@ -285,26 +308,47 @@ describe("LeftSidebarNav", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("removes the brand-row shell while keeping the floating toggle reachable in expanded and collapsed states", () => {
|
||||
it("renders the collapse toggle in the footer above Settings in expanded and collapsed states", () => {
|
||||
const { container } = renderSidebar();
|
||||
const sidebar = screen.getByTestId("left-sidebar-nav");
|
||||
const expandedToggle = screen.getByTestId("sidebar-nav-collapse-toggle");
|
||||
|
||||
expectNoSidebarBrandOrProjectAffordances(container);
|
||||
expect(expandedToggle).toHaveClass("left-sidebar-nav__collapse-toggle--floating");
|
||||
expectCollapseToggleImmediatelyBeforeSettings();
|
||||
expect(expandedToggle).toHaveAttribute("aria-pressed", "false");
|
||||
expect(expandedToggle).toHaveAccessibleName("Collapse sidebar");
|
||||
expect(expandedToggle).toHaveAttribute("title", "Collapse sidebar");
|
||||
expect(expandedToggle).toHaveTextContent("Collapse");
|
||||
expect(expandedToggle.querySelector("svg")).not.toBeNull();
|
||||
expect(within(sidebar).getAllByRole("button").at(-1)).toBe(screen.getByTestId("sidebar-nav-settings"));
|
||||
|
||||
fireEvent.click(expandedToggle);
|
||||
|
||||
const collapsedToggle = screen.getByTestId("sidebar-nav-collapse-toggle");
|
||||
expect(sidebar.className).toContain("left-sidebar-nav--collapsed");
|
||||
expectNoSidebarBrandOrProjectAffordances(container);
|
||||
expect(collapsedToggle).toHaveClass("left-sidebar-nav__collapse-toggle--floating");
|
||||
expectCollapseToggleImmediatelyBeforeSettings();
|
||||
expect(collapsedToggle).toHaveAttribute("aria-pressed", "true");
|
||||
expect(collapsedToggle).toHaveAccessibleName("Expand sidebar");
|
||||
expect(collapsedToggle).toHaveAttribute("title", "Expand sidebar");
|
||||
expect(collapsedToggle.querySelector("svg")).not.toBeNull();
|
||||
expect(within(sidebar).getAllByRole("button").at(-1)).toBe(screen.getByTestId("sidebar-nav-settings"));
|
||||
});
|
||||
|
||||
it("keeps collapse toggle styling tokenized and removes the floating modifier", () => {
|
||||
expect(leftSidebarNavCss).not.toContain(obsoleteCollapseToggleFloatingClass);
|
||||
|
||||
const toggleRule = getCssRuleBlock(leftSidebarNavCss, ".left-sidebar-nav__collapse-toggle");
|
||||
expect(toggleRule).toContain("flex-shrink: 0");
|
||||
expect(toggleRule).toContain("justify-content: flex-start");
|
||||
expect(toggleRule).not.toMatch(/#|rgb\(/i);
|
||||
expect(toggleRule).not.toMatch(/position:\s*absolute/);
|
||||
|
||||
const itemRule = getCssRuleBlock(leftSidebarNavCss, ".left-sidebar-nav__item");
|
||||
expect(itemRule).toContain("gap: var(--space-sm)");
|
||||
expect(itemRule).toContain("border-radius: var(--radius-md)");
|
||||
expect(itemRule).toContain("color: var(--text-muted)");
|
||||
expect(itemRule).not.toMatch(/#|rgb\(/i);
|
||||
});
|
||||
|
||||
it("toggles collapsed rail mode, keeps bottom settings reachable, and restores it on remount", () => {
|
||||
|
||||
Reference in New Issue
Block a user