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-sidebaron branchfeat/5984-unify-sidebar-collapse.frontend/node_modulesis 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.tsgates 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.tsscans for them. - Verification commands:
npx jest <path>β one filenpm run testβ full suitenpm run type-checknpm 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β replaceSidebarTrigger(lines 433β454) withSidebarRail - Test:
frontend/src/components/ui/__tests__/SidebarRail.test.tsx(create)
Interfaces:
- Consumes:
useSidebar()from the same module β{ toggle, state, isMobile }.SHELL_TOP_BAR_HEIGHTfrom@/components/layout/shellGeometry(already imported in this file alongsideSHELL_RAIL_WIDTHandshellRem). - 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
SidebarTriggerwithSidebarRail
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:
SidebarRailfrom 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
NavToggleand 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_WIDTHandshellRemfrom@/components/layout/shellGeometry. -
Produces:
SIDEBAR_WIDTH_ICONnow equals5.5rem(88px). Nothing else reads it besides the--sidebar-width-iconcustom 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,useSidebarfrom@/components/ui/sidebar. -
Produces:
ClientNavRail({ active, approvalsCount })β theisCollapsedprop is gone; collapse state comes from context. Task 5 stops passing it. -
Rows are
<li> > <a class="hw-rail-item">with noSidebarMenuButton. The primitive's button classes (h-8,rounded-md,hover:bg-sidebar-accent) would have to be overridden one by one, and itsasChildmode 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:
ClientTopBarno longer acceptsisRailCollapsedoronToggleRail. Nothing else passes them βClientShellis 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
isRailCollapsedandonToggleRailfrom 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
CollapseRailIconfrom the./navIconsimport (line 19). -
The
TITLE_OFFSETcomment 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 thegapunexplained 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
isRailCollapsedstate (line 60). - Add
SidebarProviderto 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.
/clientand/workspaceland on a 40px rhythm,/opson 32px β its rows areh-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-1from 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) andSidebarMenuButton(line 390) - Modify:
frontend/src/app/globals.cssβ after the.hw-rail-itemblock (line 909β935) - Test:
frontend/src/components/ui/__tests__/SidebarRail.test.tsx(extend)
Interfaces:
-
Consumes:
SidebarMenuItem/SidebarMenuButtonfrom the primitive;data-activeon the client's<a>from Task 4. -
Produces: every nav list item carries
.hw-nav-row; every filled row carriesdata-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-sidebarand--bg-canvas; -
with a banner up on
/workspaceor/opsthe 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.