Skip to main content

Unified sidebar collapse control

One control, one state model, one position, across the client portal, the expert workspace and the super-admin surface โ€” and nav rows that sit flush instead of spaced. Ships as a single PR.

Problemโ€‹

The three surfaces disagree twice over.

ControlWhereState
/clientCollapseRailIcon, rotated 180ยฐ when collapsedtop baruseState in ClientShell
/workspaceNavToggle, a Menu hamburgertop barSidebarProvider
/opsNavToggle, a Menu hamburgertop barSidebarProvider

So the client portal has the better affordance โ€” an arrow that answers the state it controls โ€” and the worse state: no cookie persistence, no Cmd/Ctrl+B, no auto-collapse in the 768โ€“1279px band.

Both mechanisms put the control in the top bar, where it reads as a page action rather than as the sidebar's own.

components/ui/sidebar.tsx already exports SidebarTrigger, which renders exactly the stateful ChevronLeft/ChevronRight pair this needs. It has been dead code since NavToggle replaced it.

Decisionsโ€‹

  1. Edge tab on the border. The control sits on the sidebar's right border, half outside it โ€” one geometry for both rail states, so the 88px collapsed rail never has to fit a control beside its 32px brand mark.
  2. Full migration. The client rail moves onto the shared Sidebar primitives and SidebarProvider, rather than keeping a parallel implementation behind a shared-looking control.
  3. Structure and state converge; appearance does not. Every surface keeps the pixels it has today.
  4. Desktop only. Below 768px all three rails are off-canvas, and a control on the sidebar's border is unreachable once the sidebar is off-screen. Mobile keeps its existing triggers unchanged.
  5. Nav rows go flush. The space between rows is deleted; rows keep their current height, so the list gets shorter.
  6. Adjacent fills merge. Two touching filled rows square the corners at their join and read as one block.

The controlโ€‹

SidebarRail, added to components/ui/sidebar.tsx, replacing SidebarTrigger. No props: it reads useSidebar() and returns null when isMobile.

28px round button, centred on the sidebar's right border
right-0, translate-x-1/2 14px inside the rail, 14px over the content
top SHELL_TOP_BAR_HEIGHT / 2, -translate-y-1/2
z-20
ChevronLeft expanded ยท ChevronRight collapsed
fill var(--bg-canvas), 1px var(--divider), var(--text-muted)
hover/focus โ†’ var(--text-primary)
aria-label "Collapse sidebar" / "Expand sidebar", aria-expanded on state

It anchors to the top bar's centre-line, not the rail header's. The rail header is 80px on /workspace and /ops and 64px on /client; anchoring to the header gives two positions, anchoring to the bar gives one constant, and the chevron then lines up with the breadcrumb as the bar's left cap.

It is persistently visible, not hover-revealed. Discoverability is the reason for moving it out of the top bar; shadcn's hover-only 4px strip gives up more than the move gains. The 28px circle is also a full 28px target rather than a border-width sliver.

SidebarInset is relative and later in DOM order, so it paints over the rail. z-20 on the button is enough โ€” the inset creates no stacking context.

Known drift. /workspace and /ops render MfaGraceBanner and CrisisModeBanner / DlqAlertBanner inside SidebarInset, above AppTopBar. A visible banner pushes the bar down while the sidebar stays at y=0, so the chevron then sits above the bar's centre-line rather than on it. Accepted: the control belongs to the sidebar and should be anchored to it, and the alternative โ€” anchoring into the inset โ€” makes a sidebar control depend on the content column's banner state. /client is unaffected; ReconsentBanner sits above the whole shell.

What converges, and what does notโ€‹

Converges: the DOM (Sidebar / SidebarHeader / SidebarContent / SidebarMenu / SidebarMenuItem / SidebarMenuButton), the state (SidebarProvider โ€” cookie, Cmd/Ctrl+B, tablet auto-collapse), the control, and the collapsed width.

Stays put: the client's bordered 24px count pill, its .hw-rail-item claret-inset selection, its New thread CTA and its traced navIcons; the expert and super-admin NavCount, collapsible groups, NavAttentionDot and collapsed flyout.

ClientNavRail's shell dies โ€” the bespoke <aside>, the inline 214/88 width switch, the isCollapsed prop. Its rows live, as a composition inside the shared primitives. Nothing the client portal renders moves by a pixel.

Row densityโ€‹

The client and the expert workspace agree on 40px rows and 8px between them. /ops does not, and never did โ€” it uses SidebarMenuButton's bare defaults:

RowGapRhythm
/clientNAV_ROW_HEIGHT = 40NAV_ROW_GAP = 8 on the flex column48
/workspaceNAV_ROW = "h-10 my-1"my-1 (4+4), gap-0 on SidebarMenu48
/opsprimitive h-8 (32)SidebarMenu default gap-1 (4)36

The gap goes on all three. Rows keep the height they have, so /client and /workspace land on a 40px rhythm and /ops on 32px.

The 40 vs 32 row height is pre-existing and stays. /ops also selects with bg-sidebar-accent + border-l-2 rather than the claret inset. Both are real divergences and neither is in scope here โ€” "appearance stays put" covers them. Worth a follow-up issue; not worth widening this one.

In the collapsed rail the RAIL_COLLAPSED_ROW chips lose their my-1, and the collapsed-state gap-2 on SidebarMenu goes with the expanded gap-1.

Removing my-1 also tightens group boundaries by 8px on /workspace, since it was padding the first and last row of every group. That is wanted; SidebarGroupLabel still separates the groups.

Squaring the joinโ€‹

Two rounded rows that touch leave a lens-shaped notch showing the rail ground.

The fill is not on the anchor. SidebarMenuButton with asChild renders a <span data-slot="sidebar-menu-button"> wrapping the <Link>, and the background classes land on that span. So the DOM is ul > li > span[data-slot="sidebar-menu-button"] > a, and the span is what rounds.

Two one-line changes in the primitive, then two rules in globals.css:

  • SidebarMenuButton sets data-active when isActive โ€” the shadcn convention, and all three surfaces already pass isActive.
  • SidebarMenuItem carries hw-nav-row on its <li>.
.hw-nav-row:where(:has(> [data-active]), :hover)
+ .hw-nav-row:where(:has(> [data-active]), :hover)
> [data-slot="sidebar-menu-button"]
โ†’ 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))
> [data-slot="sidebar-menu-button"]
โ†’ border-bottom-left-radius: 0; border-bottom-right-radius: 0

Only the lower rule needs :has() for adjacency; both use it to read the active child. Baseline since December 2023.

Scope: rows inside a SidebarMenu. The workspace group triggers (Collapsible.Trigger) and the collapsed flyout triggers (DropdownMenuTrigger) are one per SidebarGroup, so no two are ever siblings and + cannot reach them. Two collapsed group pills can both be filled โ€” active group beside a hovered one โ€” and that join stays notched. Accepted: collapsed pills are 56px chips centred in an 88px rail with ground on both sides, where a corner notch does not read the way it does on a full-width row.

It is a no-op on /client today. Only aria-current="page" fills there โ€” hover tints the label and paints no background โ€” and one route is current at a time, so two client rows are never filled at once. The rule is written once and covers the surfaces where hover does fill, which is /workspace and /ops.

Changes by fileโ€‹

components/ui/sidebar.tsx

  • Add SidebarRail; delete SidebarTrigger.
  • SIDEBAR_WIDTH_ICON becomes shellRem(SHELL_RAIL_COLLAPSED_WIDTH), derived rather than restated as 5.5rem; the file already imports both. Neither shell-geometry.test.ts scan catches this constant โ€” the literal scan wants a width:/height: on its left and the shadow scan wants one of four specific names โ€” so deriving it is a convention, not a gate.
  • One internal consumer (the --sidebar-width-icon custom property on the provider wrapper) and the two layout overrides. Nothing else reads 56px.
  • Retires the "one open divergence" comment at line 33.

app/workspace/layout.tsx, app/ops/layout.tsx

  • Mount <SidebarRail /> inside <Sidebar>.
  • Remove <NavToggle /> from the top bar's start region.
  • Delete the local RAIL_COLLAPSED_WIDTH const and the --sidebar-width-icon override on SidebarProvider.
  • Drop my-1 from NAV_ROW (workspace) and from RAIL_COLLAPSED_ROW (both).
  • The comment at app/workspace/layout.tsx:547 states the 48px rhythm the gap-0 was protecting, and the one at :85 restates it for the icon rail. Both are now wrong and must be rewritten, not left.
  • /ops needs no row edits โ€” it holds no NAV_ROW, and its gap comes from the primitive.

components/layout/NavToggle.tsx โ€” deleted.

components/client/ClientShell.tsx

  • Desktop branch wraps in <SidebarProvider>.
  • Delete isRailCollapsed and its setter.
  • Stop passing isRailCollapsed / onToggleRail to ClientTopBar.
  • The isMobile early return stays above the provider, so no second Sheet mounts behind MobileClientNav.

components/client/ClientTopBar.tsx

  • Delete the toggle button, its separator, the CollapseRailIcon import and the two props. TOGGLE_SEPARATOR_HEIGHT goes with them.

components/client/ClientNavRail.tsx

  • Recomposed onto Sidebar / SidebarHeader / SidebarContent / SidebarMenu; reads useSidebar() instead of taking isCollapsed.
  • Mount <SidebarRail />.
  • SidebarMenuButton hides only span:last-child when collapsed, so the label and the count pill must be wrapped in one span or the count survives the collapse. This is the most likely visual bug in the change.
  • Delete NAV_ROW_GAP โ€” the flex column's gap goes with the migration to SidebarMenu.

app/globals.css

  • The two join rules, next to the existing .hw-rail-item block.

components/ui/sidebar.tsx (row density and the join)

  • SidebarMenu loses its default gap-1 and its group-data-[collapsible=icon]/sidebar:gap-2.
  • SidebarMenuItem gains hw-nav-row.
  • SidebarMenuButton gains data-active={isActive || undefined}.

components/client/navIcons.tsx โ€” drop CollapseRailIcon and its path constants once nothing imports them.

Order within the runโ€‹

Sequenced so each step compiles and the suite stays green:

  1. SidebarRail + its test.
  2. Swap it into /workspace and /ops; delete NavToggle and SidebarTrigger.
  3. SIDEBAR_WIDTH_ICON default; delete both layout overrides.
  4. Client migration: ClientNavRail, then ClientShell, then ClientTopBar.
  5. Row density: strip the gaps, then add the join rules. Last, because the join rules depend on all three rails being ul > li > a.
  6. Tests, then the browser pass.

Testsโ€‹

  • components/layout/__tests__/NavToggle.test.tsx becomes components/ui/__tests__/SidebarRail.test.tsx: the same behaviours, plus chevron direction per state and null on mobile.
  • ClientTopBar.test.tsx โ€” the rail toggle block inverts to assert the bar renders no sidebar toggle.
  • ClientNavRail.test.tsx โ€” every case needs a SidebarProvider wrapper, and the collapsed cases drive it through the provider rather than an isCollapsed prop. toHaveStyle({ width: "214px" }) breaks, because the primitive sets width through w-(--sidebar-width); assert data-state and data-collapsible instead. queryByRole("button") must be re-specified as "the nav lists only links": it was written to prove no "My specialist" entry crept back, and the rail control is a button, so deleting the assertion drops a guard that is still wanted.
  • New, one per surface: the control renders inside the sidebar and is absent from the top bar.
  • portal-ui-rules.test.ts gates the new button's colours as tokens.
  • Nothing currently asserts the 48px rhythm, so nothing breaks โ€” and nothing would have caught the change either. Add one: every nav row carries .hw-nav-row, and data-active marks exactly the current row. The join rules are unobservable in jsdom, so that attribute contract is the only part of the density change a unit test can hold.

Recorded deviationsโ€‹

Two, both into docs/design/UI_FOUNDATION.md ยง1 with their reasons. Without that, the next person reads the frames and reverts either one as a bug.

The rail carries a collapse control. Figma/expert/UI-SPEC.md ยง2 gives the left nav "no icons on any item, no collapse control, no footer", and SHELL_RAIL_HEADER_HEIGHT = 80 was derived from that absence โ€” shellGeometry.ts says so in its own comment.

Nav rows sit flush, on a 40px rhythm rather than 48px. The expert frames put nav baselines at 105 / 153 / 201 / 249 โ€ฆ, 48 apart, and the super-admin rail's 921px content height is measured on that grid. Nothing in the suite asserts it; the only record is the pair of comments at app/workspace/layout.tsx:85 and :547, which this change rewrites. (/ops was already off that grid at 36px โ€” that is pre-existing, not this change.)

SHELL_RAIL_HEADER_HEIGHT itself is unaffected โ€” it positions the first row's top, which no inter-row gap touches.

Verificationโ€‹

Jest observes nothing about a translate-x-1/2 on a border. Browser pass on all three surfaces at 1440, 1280 and 1024, in both themes, before and after:

  • 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 the rail's --bg-sidebar and the content's --bg-canvas;
  • Cmd/Ctrl+B works on the client portal, and the state survives a reload;
  • the tablet band still auto-collapses, and an explicit toggle still overrides it for the session;
  • rows touch with no rail ground between them, expanded and collapsed;
  • hovering the row above and below the current one squares the join in both directions, with no notch and no doubled edge;
  • group labels still separate groups on /workspace and /ops now that the 8px either side of a group's first and last row is gone;
  • the super-admin tree, which is the longest, still fits without the rail scrolling at 900px;
  • no client portal row, pill or label has changed shape.

Out of scopeโ€‹

  • SidebarCollapseToggle โ€” the PanelLeftClose / PanelLeftOpen pair in PortalChat hides the "My chats" thread column, a second-level list inside the content region. Different control, different thing controlled.
  • Mobile. MobileClientNav and the Sheet on /workspace and /ops are untouched โ€” but the Sheet still needs something to open it. This bullet read as if retiring NavToggle were desktop-only work. It is not: NavToggle was the only control that opened those two drawers below 768px, so it is replaced by MobileNavTrigger rather than deleted outright. SidebarRail above 768px, MobileNavTrigger below it โ€” one control at every width.
  • Normalising counts, selection or icons across surfaces.