Skip to main content

Unified Sidebar Collapse Control Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the two different sidebar collapse controls with one stateful chevron mounted on the sidebar's own border, and make nav rows sit flush.

Architecture: All three surfaces converge on SidebarProvider and the components/ui/sidebar.tsx primitives. A new SidebarRail reads useSidebar() and renders a 28px chevron centred on the sidebar's right border. The client portal's bespoke <aside> and its local useState are replaced by the shared primitive and the shared provider; its row styling is carried across unchanged.

Tech Stack: Next.js 16 App Router, React 19, TypeScript, Tailwind v4, shadcn-derived sidebar primitive, Jest + Testing Library, lucide-react.

Spec: docs/superpowers/specs/2026-08-13-unified-sidebar-collapse-design.md

Global Constraints​

  • Work in the worktree /Users/procurares/Developer/Humanity/humanwork-sidebar on branch feat/5984-unify-sidebar-collapse. frontend/node_modules is already symlinked from the main checkout.
  • All commands run from frontend/.
  • Never add Co-Authored-By: or any AI-attribution trailer to a commit.
  • Conventional commit subjects. No ticket numbers in the body prose.
  • No colour literals. Every colour is a var(--token). src/__tests__/portal-ui-rules.test.ts gates this.
  • No magic numbers. Extract named constants grouped by concern.
  • No AI-tell comments. No audit tags, no "This ensures that…", no "behavior-neutral". Keep only the non-obvious why, in one or two lines.
  • Shell dimensions come from src/components/layout/shellGeometry.ts. Never restate 214, 88 or 55 as a literal β€” src/__tests__/shell-geometry.test.ts scans for them.
  • Verification commands:
    • npx jest <path> β€” one file
    • npm run test β€” full suite
    • npm run type-check
    • npm run lint
  • Prettier formatting differences and pre-existing lint warnings are expected noise; do not chase them.

Task 1: The SidebarRail control​

Files:

  • Modify: frontend/src/components/ui/sidebar.tsx β€” replace SidebarTrigger (lines 433–454) with SidebarRail
  • Test: frontend/src/components/ui/__tests__/SidebarRail.test.tsx (create)

Interfaces:

  • Consumes: useSidebar() from the same module β€” { toggle, state, isMobile }. SHELL_TOP_BAR_HEIGHT from @/components/layout/shellGeometry (already imported in this file alongside SHELL_RAIL_WIDTH and shellRem).
  • Produces: export function SidebarRail(): JSX.Element | null β€” no props. Tasks 2 and 4 mount it inside <Sidebar>.

The component stays in sidebar.tsx rather than a file of its own, so the test drives it through a real SidebarProvider instead of mocking useSidebar. Mocking a hook that a component in the same module calls does not work β€” the component holds the module-local binding, not the mocked export.

  • Step 1: Write the failing test

Create frontend/src/components/ui/__tests__/SidebarRail.test.tsx:

/**
* The rail control replaces NavToggle. It lives on the sidebar's border rather
* than in the top bar, and it answers the state it controls: chevron left to
* close, chevron right to reopen.
*/
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import { Sidebar, SidebarProvider, SidebarRail } from "../sidebar";

let isMobile = false;
jest.mock("@/hooks/useIsMobile", () => ({
useIsMobile: () => isMobile,
}));

function renderRail() {
return render(
<SidebarProvider>
<Sidebar>
<SidebarRail />
</Sidebar>
</SidebarProvider>,
);
}

beforeEach(() => {
isMobile = false;
document.cookie = "sidebar_state=; path=/; max-age=0";
});

describe("SidebarRail", () => {
it("collapses the rail and renames itself for the way back", async () => {
renderRail();

const collapse = screen.getByRole("button", { name: "Collapse sidebar" });
expect(collapse).toHaveAttribute("aria-expanded", "true");

await userEvent.click(collapse);

const expand = screen.getByRole("button", { name: "Expand sidebar" });
expect(expand).toHaveAttribute("aria-expanded", "false");
});

it("points the chevron at the rail, and flips it once closed", async () => {
renderRail();

const svgOf = (name: string) =>
screen.getByRole("button", { name }).querySelector("svg");

const closing = svgOf("Collapse sidebar")?.getAttribute("class");
await userEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));
const opening = svgOf("Expand sidebar")?.getAttribute("class");

expect(closing).not.toBe(opening);
});

it("persists the choice, so a reload does not reopen what you closed", async () => {
renderRail();
await userEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));

expect(document.cookie).toContain("sidebar_state=collapsed");
});

it("renders nothing on a phone, where the rail is off-canvas", () => {
isMobile = true;
renderRail();

expect(screen.queryByRole("button", { name: /sidebar/i })).not.toBeInTheDocument();
});

it("tracks a collapse driven from elsewhere, not only its own click", async () => {
renderRail();

await userEvent.keyboard("{Meta>}b{/Meta}");

expect(
screen.getByRole("button", { name: "Expand sidebar" }),
).toHaveAttribute("aria-expanded", "false");
});
});
  • Step 2: Run the test to verify it fails

Run: npx jest src/components/ui/__tests__/SidebarRail.test.tsx Expected: FAIL β€” SidebarRail is not exported from ../sidebar.

  • Step 3: Replace SidebarTrigger with SidebarRail

In frontend/src/components/ui/sidebar.tsx, add SHELL_TOP_BAR_HEIGHT to the existing shellGeometry import, and add these constants beside the other module constants near the top:

/** Diameter of the collapse control, centred on the sidebar's right border. */
const RAIL_TOGGLE_SIZE = 28;
const RAIL_TOGGLE_ICON_SIZE = 16;

Then delete the whole SidebarTrigger function and put this in its place:

/**
* The collapse control, on the sidebar's own border rather than in the top bar.
*
* Straddling the border is what lets one geometry serve both rail states: the
* collapsed rail is 88px with a centred brand mark and has no corner to spare.
* It anchors to the top bar's centre-line, not the rail header's, because the
* header is 80px on /ops and /workspace and 64px on /client β€” the bar is the
* one measurement all three share.
*
* `SidebarInset` is `relative` and later in DOM order, so this needs `z-20` to
* stay on top of the content region.
*/
export function SidebarRail() {
const { toggle, state, isMobile } = useSidebar();
if (isMobile) return null;

const collapsed = state === "collapsed";
const label = collapsed ? "Expand sidebar" : "Collapse sidebar";
const Icon = collapsed ? ChevronRight : ChevronLeft;

return (
<button
type="button"
data-slot="sidebar-rail"
onClick={toggle}
aria-label={label}
aria-expanded={!collapsed}
title={label}
className="absolute right-0 z-20 flex -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full border border-[var(--divider)] bg-[var(--bg-canvas)] text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
style={{
width: RAIL_TOGGLE_SIZE,
height: RAIL_TOGGLE_SIZE,
top: SHELL_TOP_BAR_HEIGHT / 2,
}}
>
<Icon size={RAIL_TOGGLE_ICON_SIZE} aria-hidden="true" />
</button>
);
}

ChevronLeft and ChevronRight are already imported at line 17 for the control being replaced β€” leave the import as it is.

  • Step 4: Run the test to verify it passes

Run: npx jest src/components/ui/__tests__/SidebarRail.test.tsx Expected: PASS, 5 tests.

  • Step 5: Commit
git add frontend/src/components/ui/sidebar.tsx \
frontend/src/components/ui/__tests__/SidebarRail.test.tsx
git commit -m "feat(ds): the sidebar collapse control onto the rail's own border"

Task 2: Mount it on /workspace and /ops, retire NavToggle​

Files:

  • Modify: frontend/src/app/workspace/layout.tsx β€” import at line 43, usage at line 415, <Sidebar> at line 536
  • Modify: frontend/src/app/ops/layout.tsx β€” import at line 72, usage at line 747, <Sidebar> at line 682
  • Delete: frontend/src/components/layout/NavToggle.tsx
  • Delete: frontend/src/components/layout/__tests__/NavToggle.test.tsx

Interfaces:

  • Consumes: SidebarRail from Task 1.

  • Produces: nothing new. After this task no surface mounts a nav toggle in its top bar.

  • Step 1: Mount the control in the workspace rail

In frontend/src/app/workspace/layout.tsx, add SidebarRail to the existing import block from @/components/ui/sidebar (line 32 region), then place it as the first child of <Sidebar> at line 536:

<Sidebar>
<SidebarRail />
{/* Logo and nothing else, at one height in both states. Fixing the
height is what lands the first nav row at y=80 either way. */}
<SidebarHeader

Note the existing comment on that header says "the frames draw no control here, and the toggle lives in the top bar". Both halves are now false β€” replace it with the text above.

  • Step 2: Remove the hamburger from the workspace top bar

Delete the NavToggle import (line 43) and collapse WorkspaceTopBar's start region (lines 413–419) to:

start={<Breadcrumbs />}

The wrapping <div className="flex min-w-0 flex-1 items-center gap-1.5"> and the inner <div className="min-w-0 flex-1"> existed only to sit the toggle beside the breadcrumb. AppTopBar already lays its two ends out.

  • Step 3: Do the same on /ops

In frontend/src/app/ops/layout.tsx: add SidebarRail to the @/components/ui/sidebar import (line 53 region), mount it as the first child of <Sidebar> at line 682 with the same replacement comment, delete the NavToggle import (line 72), and reduce the AppTopBar start region (lines 745–752) to start={<Breadcrumbs />}.

  • Step 4: Delete NavToggle and its test
git rm frontend/src/components/layout/NavToggle.tsx \
frontend/src/components/layout/__tests__/NavToggle.test.tsx
  • Step 5: Verify nothing still imports it

Run: grep -rn "NavToggle" frontend/src Expected: no output.

Run: npm run type-check Expected: clean.

There is no render test for either layout β€” app/workspace/layout.tsx and app/ops/layout.tsx are 593 and 782 lines of provider, socket and auth wiring with no test harness, and standing one up is a larger job than this change. The grep plus the browser pass in Task 8 is the coverage for these two surfaces. /client gets a real assertion, in Task 5.

  • Step 6: Commit
git add frontend/src/app/workspace/layout.tsx frontend/src/app/ops/layout.tsx
git commit -m "refactor(ds): the expert and ops toggles out of the top bar and onto the rail"

Task 3: Take the collapsed width from the shell module​

Files:

  • Modify: frontend/src/components/ui/sidebar.tsx:36
  • Modify: frontend/src/app/workspace/layout.tsx:90,535
  • Modify: frontend/src/app/ops/layout.tsx:102,681

Interfaces:

  • Consumes: SHELL_RAIL_COLLAPSED_WIDTH and shellRem from @/components/layout/shellGeometry.

  • Produces: SIDEBAR_WIDTH_ICON now equals 5.5rem (88px). Nothing else reads it besides the --sidebar-width-icon custom property on the provider wrapper, verified by grep before this change.

  • Step 1: Derive the default

In frontend/src/components/ui/sidebar.tsx, add SHELL_RAIL_COLLAPSED_WIDTH to the shellGeometry import at line 21 and replace lines 33–36:

export const SIDEBAR_WIDTH_ICON = shellRem(SHELL_RAIL_COLLAPSED_WIDTH);

The three-line comment above it recorded 56px as "the one open divergence" between the primitive and the frames. Delete it β€” the divergence is gone.

  • Step 2: Delete both overrides

In frontend/src/app/workspace/layout.tsx, delete the RAIL_COLLAPSED_WIDTH const (line 90) and reduce line 535 to:

<SidebarProvider>

The two-line comment above it explaining the override goes too. Then remove SHELL_RAIL_COLLAPSED_WIDTH from the shellGeometry import if the file no longer uses it β€” check with grep -n "SHELL_RAIL_COLLAPSED_WIDTH" frontend/src/app/workspace/layout.tsx.

Repeat identically in frontend/src/app/ops/layout.tsx (line 102, line 681).

  • Step 3: Verify the shell-geometry gate still passes

Run: npx jest src/__tests__/shell-geometry.test.ts Expected: PASS. SIDEBAR_WIDTH_ICON is not caught by either scan β€” the literal scan needs width:/height: immediately left of the value, and the shadow scan matches only RAIL_WIDTH, RAIL_COLLAPSED_WIDTH, RAIL_HEADER_HEIGHT and TOP_BAR_HEIGHT. Deriving it is convention here, not a gate.

  • Step 4: Commit
git add frontend/src/components/ui/sidebar.tsx \
frontend/src/app/workspace/layout.tsx frontend/src/app/ops/layout.tsx
git commit -m "refactor(ds): the collapsed rail width off the shell module, not three copies"

Task 4: The client rail onto the primitives​

Files:

  • Modify: frontend/src/components/client/ClientNavRail.tsx (whole file)
  • Modify: frontend/src/components/client/__tests__/ClientNavRail.test.tsx

Interfaces:

  • Consumes: Sidebar, SidebarContent, SidebarHeader, SidebarMenu, SidebarMenuItem, SidebarRail, useSidebar from @/components/ui/sidebar.

  • Produces: ClientNavRail({ active, approvalsCount }) β€” the isCollapsed prop is gone; collapse state comes from context. Task 5 stops passing it.

  • Rows are <li> > <a class="hw-rail-item"> with no SidebarMenuButton. The primitive's button classes (h-8, rounded-md, hover:bg-sidebar-accent) would have to be overridden one by one, and its asChild mode wraps the anchor in a <span> that would take the fill away from .hw-rail-item.

  • Step 1: Update the test to drive collapse through the provider

In frontend/src/components/client/__tests__/ClientNavRail.test.tsx, add a render helper below the next/link mock and use it everywhere the file currently calls render(<ClientNavRail … />):

import { SidebarProvider } from "@/components/ui/sidebar";

jest.mock("@/hooks/useIsMobile", () => ({ useIsMobile: () => false }));

function renderRail(
props: React.ComponentProps<typeof ClientNavRail>,
{ collapsed = false } = {},
) {
return render(
<SidebarProvider defaultOpen={!collapsed}>
<ClientNavRail {...props} />
</SidebarProvider>,
);
}

Replace every isCollapsed prop with the collapsed render option, e.g. render(<ClientNavRail active="home" isCollapsed />) becomes renderRail({ active: "home" }, { collapsed: true }).

Then change the two assertions that read inline width. In ClientNavRail geometry:

expect(screen.getByRole("complementary")).toHaveAttribute("data-state", "expanded");

and in ClientNavRail collapsed:

expect(screen.getByRole("complementary")).toHaveAttribute("data-collapsible", "icon");

The width now comes from w-(--sidebar-width), which jsdom does not resolve.

Finally, re-specify the button assertion in ClientNavRail entries β€” it was written to prove no "My specialist" entry crept back, and the rail control is a button, so queryByRole("button") no longer says that:

const nav = screen.getByRole("navigation");
expect(nav.querySelector("button")).toBeNull();
expect(screen.queryByText(/specialist/i)).not.toBeInTheDocument();
  • Step 2: Run the test to verify it fails

Run: npx jest src/components/client/__tests__/ClientNavRail.test.tsx Expected: FAIL β€” ClientNavRail still renders a bare <aside> with no data-state, and still expects an isCollapsed prop.

  • Step 3: Rewrite the rail onto the primitives

Replace the component body in frontend/src/components/client/ClientNavRail.tsx. Keep every geometry constant already at the top of the file β€” RAIL_INSET, NEW_THREAD_*, NAV_LIST_OFFSET, RAIL_HEADER_HEIGHT, NAV_ROW_*, NAV_ICON_*, NAV_LABEL_SIZE, NAV_COUNT_MAX, COUNT_* β€” and delete NAV_ROW_GAP, which the flex column owned.

export function ClientNavRail({
active,
approvalsCount,
}: {
active: ClientNavTab;
approvalsCount?: number;
}) {
const { state } = useSidebar();
const isCollapsed = state === "collapsed";

const items: NavItem[] = [
{ tab: "home", label: "Home", href: "/client/chat", icon: HomeIcon },
{ tab: "approvals", label: "Approvals", href: "/client/approvals", icon: ApprovalsIcon, count: approvalsCount },
{ tab: "knowledge", label: "Knowledge", href: "/client/knowledge", icon: KnowledgeIcon },
];

// Collapsed rows centre their icon and drop the label, so the accessible name
// has to come from the attribute instead of the removed text.
const rowLabelling = (label: string) =>
isCollapsed ? { "aria-label": label, title: label } : {};

const renderItem = (item: NavItem) => {
const isActive = item.tab === active;
const Icon = item.icon;
return (
<SidebarMenuItem key={item.href}>
<Link
href={item.href}
aria-current={isActive ? "page" : undefined}
data-active={isActive || undefined}
className="hw-rail-item"
{...rowLabelling(item.label)}
style={{
display: "flex",
alignItems: "center",
justifyContent: isCollapsed ? "center" : undefined,
gap: NAV_ICON_GAP,
height: NAV_ROW_HEIGHT,
padding: isCollapsed ? 0 : `0 ${NAV_ROW_PADDING_X}px`,
borderRadius: NAV_ROW_RADIUS,
fontSize: NAV_LABEL_SIZE,
textDecoration: "none",
}}
>
<Icon size={NAV_ICON_SIZE} style={{ flexShrink: 0 }} />
{!isCollapsed && (
<>
<span style={{ flex: 1 }}>{item.label}</span>
{typeof item.count === "number" && item.count > 0 && (
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
height: COUNT_HEIGHT,
minWidth: COUNT_MIN_WIDTH,
padding: `0 ${COUNT_PADDING_X}px`,
borderRadius: COUNT_HEIGHT / 2,
background: "var(--bg-surface)",
border: "1px solid var(--border)",
color: "var(--text-primary)",
fontSize: COUNT_FONT_SIZE,
fontWeight: 400,
fontVariantNumeric: "tabular-nums",
}}
>
{item.count > NAV_COUNT_MAX ? `${NAV_COUNT_MAX}+` : item.count}
</span>
)}
</>
)}
</Link>
</SidebarMenuItem>
);
};

return (
<Sidebar className="hw-client-portal border-r-0 bg-[var(--bg-sidebar)]">
<SidebarRail />

<SidebarHeader
className="p-0"
style={{
display: "flex",
alignItems: "center",
justifyContent: isCollapsed ? "center" : undefined,
height: RAIL_HEADER_HEIGHT,
padding: `0 ${RAIL_INSET}px`,
flexShrink: 0,
}}
>
{isCollapsed ? <ClientBrandMark /> : <ClientBrandLockup />}
</SidebarHeader>

<Link
href="/client/chat?new=1"
{...(isCollapsed ? { "aria-label": "New thread", title: "New thread" } : {})}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: NEW_THREAD_ICON_GAP,
height: NEW_THREAD_HEIGHT,
flexShrink: 0,
margin: `${NEW_THREAD_OFFSET}px ${RAIL_INSET}px ${NAV_LIST_OFFSET}px`,
background: "var(--brand)",
color: "var(--brand-foreground)",
borderRadius: NEW_THREAD_RADIUS,
fontSize: NAV_LABEL_SIZE,
fontWeight: 600,
textDecoration: "none",
}}
>
{!isCollapsed && "New thread"}
<Plus size={NEW_THREAD_ICON_SIZE} strokeWidth={NEW_THREAD_ICON_STROKE} />
</Link>

<SidebarContent>
<SidebarMenu style={{ padding: `0 ${RAIL_INSET}px` }}>
{items.map(renderItem)}
</SidebarMenu>
</SidebarContent>

SidebarMenu does not accept a style prop today β€” only className and children. Add one, matching SidebarHeader, which already has it:

export function SidebarMenu({
className,
children,
style,
}: {
className?: string;
children: React.ReactNode;
style?: React.CSSProperties;
}) {
return (
<ul
data-slot="sidebar-menu"
className={cn("flex w-full min-w-0 flex-col", className)}
style={style}
>
{children}
</ul>
);
}
</Sidebar>
);
}

Update the imports at the top of the file: drop SHELL_RAIL_COLLAPSED_WIDTH and SHELL_RAIL_WIDTH from the shellGeometry import (the primitive owns both widths now; keep SHELL_RAIL_HEADER_HEIGHT, which RAIL_HEADER_HEIGHT derives from), and add:

import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarMenu,
SidebarMenuItem,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar";

Also update the file's docblock: the sentence "Collapsing narrows the rail to the DS's 88px variant" is still true, but "callers gate the column" and the isCollapsed prop reference are not.

  • Step 4: Run the test to verify it passes

Run: npx jest src/components/client/__tests__/ClientNavRail.test.tsx Expected: PASS.

  • Step 5: Commit
git add frontend/src/components/client/ClientNavRail.tsx \
frontend/src/components/client/__tests__/ClientNavRail.test.tsx
git commit -m "refactor(client): the portal rail onto the shared sidebar primitive"

Task 5: ClientShell onto SidebarProvider, and the top bar loses its toggle​

Files:

  • Modify: frontend/src/components/client/ClientShell.tsx:60,212-244
  • Modify: frontend/src/components/client/ClientTopBar.tsx
  • Modify: frontend/src/components/client/__tests__/ClientTopBar.test.tsx:90-113
  • Modify: frontend/src/components/client/navIcons.tsx

Interfaces:

  • Consumes: ClientNavRail({ active, approvalsCount }) from Task 4.

  • Produces: ClientTopBar no longer accepts isRailCollapsed or onToggleRail. Nothing else passes them β€” ClientShell is its only caller.

  • Step 1: Rewrite the top-bar test to assert the toggle is gone

In frontend/src/components/client/__tests__/ClientTopBar.test.tsx, replace the whole ClientTopBar rail toggle describe block (lines 90–113) with:

describe("ClientTopBar carries no rail toggle", () => {
// The control moved onto the rail's own border (SidebarRail). A second one
// here would be two controls for one job, which is what this change undid.
it("leaves the collapse control to the sidebar", async () => {
render(<ClientTopBar active="home" />);
await flushProfileFetch();

expect(
screen.queryByRole("button", { name: /sidebar/i }),
).not.toBeInTheDocument();
});
});

Remove the now-unused userEvent import if no other block in the file uses it β€” check with grep -n "userEvent" frontend/src/components/client/__tests__/ClientTopBar.test.tsx.

  • Step 2: Run the test β€” it passes, and that is expected

Run: npx jest src/components/client/__tests__/ClientTopBar.test.tsx Expected: PASS.

Fail-first does not apply to a deletion. The toggle only ever rendered when onToggleRail was passed, and this case passes none, so the assertion holds both before and after. It becomes load-bearing in Step 3, when the prop stops existing and no caller can bring the button back.

  • Step 3: Strip the toggle from ClientTopBar

In frontend/src/components/client/ClientTopBar.tsx:

  • Delete TOGGLE_SEPARATOR_HEIGHT (line 21).

  • Delete isRailCollapsed and onToggleRail from the props type and the destructuring (lines 55–56, 61–64).

  • Delete the whole {onToggleRail && (…)} block (lines 84–119) β€” the button and the separator span.

  • Remove CollapseRailIcon from the ./navIcons import (line 19).

  • The TITLE_OFFSET comment says the toggle box and separator put the title at x282. With them gone the gap is the crumb's own leading inset β€” rewrite the comment to say that, or drop the constant into the gap unexplained if the measurement no longer holds. Do not leave the stale justification.

  • Step 4: Delete the icon

In frontend/src/components/client/navIcons.tsx, delete CollapseRailIcon (line 186) and its COLLAPSE_RAIL_SIZE, COLLAPSE_RAIL_VIEW_BOX and COLLAPSE_RAIL_PATH constants.

Verify nothing else imports it: grep -rn "CollapseRailIcon" frontend/src Expected: no output.

  • Step 5: Wrap the client shell in the provider

In frontend/src/components/client/ClientShell.tsx:

  • Delete the isRailCollapsed state (line 60).
  • Add SidebarProvider to the imports from @/components/ui/sidebar.
  • Replace the desktop return (lines 212–244) with:
return (
<SidebarProvider>
<ClientNavRail active={active} approvalsCount={approvalsCount} />
{/* Fixed-height flex column: the bar is static and only the region under
it swaps. The routed page owns its own scrolling, so the chat surface
can scroll each pane separately instead of moving the whole region;
document pages scroll their own <main>. */}
<div
style={{
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<ClientTopBar
active={active}
orgName={ctx?.orgName ?? null}
onOpenInbox={() => setInboxOpen(true)}
trail={trail}
/>
<ClientTrailProvider value={setTrail}>{children}</ClientTrailProvider>
</div>
{inbox}
</SidebarProvider>
);

SidebarProvider renders its own flex h-svh w-full overflow-hidden wrapper, which is what the deleted <div style={{ display: "flex", height: "100vh", overflow: "hidden" }}> was doing by hand.

The isMobile early return at line 188 stays exactly where it is, above the provider. MobileClientNav already owns the portal's mobile drawer; mounting a provider on mobile would render a second, always-closed Sheet behind it and would put h-svh on a branch that wants minHeight: 100vh with a 60px inset.

Update the component docblock: "Desktop-only rail: on mobile the rail is hidden and MobileClientNav's hamburger takes over" is still true and should stay.

  • Step 6: Run both tests

Run: npx jest src/components/client/__tests__/ClientTopBar.test.tsx src/components/client/__tests__/ClientNavRail.test.tsx Expected: PASS.

Run: npm run type-check Expected: clean. A ClientTopBar caller still passing the removed props would surface here.

  • Step 7: Commit
git add frontend/src/components/client/ClientShell.tsx \
frontend/src/components/client/ClientTopBar.tsx \
frontend/src/components/client/navIcons.tsx \
frontend/src/components/client/__tests__/ClientTopBar.test.tsx
git commit -m "refactor(client): the portal shell onto SidebarProvider, and the bar's toggle out"

Task 6: Flush nav rows​

Files:

  • Modify: frontend/src/components/ui/sidebar.tsx:359 (SidebarMenu)
  • Modify: frontend/src/app/workspace/layout.tsx:66,85-92,178,367,547

Interfaces:

  • Consumes: nothing new.

  • Produces: nav rows touch. /client and /workspace land on a 40px rhythm, /ops on 32px β€” its rows are h-8, which is pre-existing and out of scope.

  • Step 1: Drop the gap from the shared menu

In frontend/src/components/ui/sidebar.tsx, SidebarMenu (line 349) currently reads:

className={cn("flex w-full min-w-0 flex-col gap-1 group-data-[collapsible=icon]/sidebar:gap-2", className)}

Change it to:

className={cn("flex w-full min-w-0 flex-col", className)}
  • Step 2: Drop my-1 from the workspace rows

In frontend/src/app/workspace/layout.tsx:

const NAV_ROW = "h-10 rounded-lg";

and:

const RAIL_COLLAPSED_ROW =
"relative flex h-10 w-14 items-center gap-2 rounded-lg px-2 transition-colors";

Do the same to RAIL_COLLAPSED_ROW in frontend/src/app/ops/layout.tsx:103-104.

/ops needs no other row edit β€” it holds no NAV_ROW, and its expanded rows took their spacing from the gap-1 deleted in Step 1.

  • Step 3: Fix the two comments that record the old rhythm

frontend/src/app/workspace/layout.tsx:85-89 says the collapsed rows "keep the expanded 40px box on the same 48px pitch". :547 says "gap-0 keeps the 48px rhythm across a group boundary too". Both describe geometry this task removes. Rewrite them to state the 40px pitch, and drop the now-redundant className="gap-0" on the two SidebarMenu instances (lines 178 and 367) β€” the primitive's default is 0 now.

  • Step 4: Run the suites that touch the rails

Run: npx jest src/app/ops/__tests__/ops-nav-groups.test.tsx src/components/client/__tests__/ClientNavRail.test.tsx src/components/ui/__tests__/SidebarRail.test.tsx Expected: PASS. Nothing in the suite asserts the 48px rhythm β€” that is why Task 7 adds an attribute contract to hold the part a unit test can hold.

  • Step 5: Commit
git add frontend/src/components/ui/sidebar.tsx \
frontend/src/app/workspace/layout.tsx frontend/src/app/ops/layout.tsx
git commit -m "refactor(ds): nav rows flush, so two highlighted rows meet with no gap"

Task 7: Square the join between adjacent fills​

Files:

  • Modify: frontend/src/components/ui/sidebar.tsx β€” SidebarMenuItem (line 366) and SidebarMenuButton (line 390)
  • Modify: frontend/src/app/globals.css β€” after the .hw-rail-item block (line 909–935)
  • Test: frontend/src/components/ui/__tests__/SidebarRail.test.tsx (extend)

Interfaces:

  • Consumes: SidebarMenuItem / SidebarMenuButton from the primitive; data-active on the client's <a> from Task 4.

  • Produces: every nav list item carries .hw-nav-row; every filled row carries data-active. The CSS is unobservable in jsdom, so the attribute contract is what the test holds.

  • Step 1: Write the failing test

Extend the existing ../sidebar import at the top of frontend/src/components/ui/__tests__/SidebarRail.test.tsx to:

import {
Sidebar,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarRail,
} from "../sidebar";

Then append this describe block at the end of the file:

// jsdom loads no stylesheet, so the squared join itself cannot be observed
// here. What a unit test can hold is the contract the CSS keys on.
describe("nav row join contract", () => {
it("marks every row for the join rules and flags only the active one", () => {
render(
<SidebarProvider>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton isActive>Queue</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton>Approvals</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarProvider>,
);

const rows = screen.getAllByRole("listitem");
expect(rows).toHaveLength(2);
for (const row of rows) expect(row).toHaveClass("hw-nav-row");

expect(rows[0].querySelector("[data-active]")).not.toBeNull();
expect(rows[1].querySelector("[data-active]")).toBeNull();
});
});
  • Step 2: Run the test to verify it fails

Run: npx jest src/components/ui/__tests__/SidebarRail.test.tsx -t "join contract" Expected: FAIL β€” hw-nav-row is not on the <li> and data-active is not set.

  • Step 3: Add the two attributes to the primitive

In frontend/src/components/ui/sidebar.tsx, SidebarMenuItem:

className={cn("hw-nav-row group/menu-item relative", className)}

and in SidebarMenuButton, on the rendered Comp:

<Comp
data-slot="sidebar-menu-button"
data-active={isActive || undefined}
className={baseClasses}
{...props}
>

|| undefined rather than {isActive}: a data-active="false" attribute is still present in the DOM, and [data-active] would match it.

  • Step 4: Add the join rules

In frontend/src/app/globals.css, immediately after the .hw-rail-item[aria-current="page"] block (ends line 935):

/* Adjacent filled rows read as one block rather than two pills with a notch
between them. "Filled" is the active row or the hovered one; two can only
ever meet as active-beside-hovered, since one route is current at a time.
The list item holds one child, which is the element carrying the fill:
a[.hw-rail-item] on the client, span[data-slot=sidebar-menu-button]
elsewhere. */
.hw-nav-row:where(:has(> [data-active]), :hover)
+ .hw-nav-row:where(:has(> [data-active]), :hover)
> * {
border-top-left-radius: 0;
border-top-right-radius: 0;
}

.hw-nav-row:where(:has(> [data-active]), :hover):has(
+ .hw-nav-row:where(:has(> [data-active]), :hover)
)
> * {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
  • Step 5: Run the tests

Run: npx jest src/components/ui/__tests__/SidebarRail.test.tsx Expected: PASS, 6 tests.

Run: npx jest src/__tests__/portal-ui-rules.test.ts Expected: PASS β€” the rules name no colour.

  • Step 6: Commit
git add frontend/src/components/ui/sidebar.tsx frontend/src/app/globals.css \
frontend/src/components/ui/__tests__/SidebarRail.test.tsx
git commit -m "feat(ds): adjacent filled nav rows square their join"

Task 8: Record the deviations, then verify the whole thing​

Files:

  • Modify: docs/design/UI_FOUNDATION.md Β§1

Interfaces:

  • Consumes: everything above.

  • Produces: the shipped change.

  • Step 1: Record both deviations

Add to docs/design/UI_FOUNDATION.md Β§1, which currently records that the client, Expert and Superadmin frames agree on every shell value with no exceptions found. There are now two:

### Deviations from the frames

**The rail carries a collapse control.** `Figma/expert/UI-SPEC.md` Β§2 draws the
left nav with no collapse control, and `SHELL_RAIL_HEADER_HEIGHT = 80` was
derived from that absence. The control now sits on the rail's right border,
centred on the top bar's line, on all three surfaces β€” one control for a job
that had two, in the place that says what it acts on.

**Nav rows sit flush.** The frames put expert nav baselines 48px apart
(105 / 153 / 201 / 249 …). Rows keep their height and lose the space between
them, so `/client` and `/workspace` run on a 40px pitch. `/ops` was already off
that grid at 36px, which predates this change.
  • Step 2: Run the full suite

Run: npm run test Expected: PASS. Compare the failure list against origin/dev if anything red appears β€” this repo carries pre-existing failures unrelated to the rail.

  • Step 3: Type-check and lint

Run: npm run type-check Run: npm run lint Expected: clean of new errors. Pre-existing warnings are noise.

  • Step 4: Check for orphans

Run: npx knip 2>&1 | grep -iE "sidebar|navtoggle|collapserail" Expected: no new unused exports. SidebarTrigger, NavToggle and CollapseRailIcon are gone; SidebarRail has three consumers.

  • Step 5: Browser verification

Start the frontend against the dev API on port 3001 and check all three surfaces at 1440, 1280 and 1024, in both themes. Jest observes nothing about a translate-x-1/2 on a border, so this step is the actual gate:

  • the control lands on the border at the bar's centre-line, both rail states;

  • it clears the breadcrumb at the 24px content gutter;

  • it reads against both --bg-sidebar and --bg-canvas;

  • with a banner up on /workspace or /ops the control stays at the sidebar's top and the bar moves β€” expected, recorded in the spec;

  • rows touch with no rail ground between them, expanded and collapsed;

  • hovering the row above and below the current one squares the join both ways, with no notch and no doubled edge;

  • Cmd/Ctrl+B works on the client portal, and the state survives a reload;

  • the tablet band still auto-collapses, and an explicit toggle overrides it;

  • the super-admin tree still fits without the rail scrolling at 900px;

  • no client portal row, pill or label has changed shape.

  • Step 6: Commit and open the PR

git add docs/design/UI_FOUNDATION.md
git commit -m "docs(design): the two frame deviations the sidebar change introduces"

Then follow the standing pre-push gate: all local checks green, ask before pushing, run the local Greptile review to 5/5, and only then push.