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.
| Control | Where | State | |
|---|---|---|---|
/client | CollapseRailIcon, rotated 180ยฐ when collapsed | top bar | useState in ClientShell |
/workspace | NavToggle, a Menu hamburger | top bar | SidebarProvider |
/ops | NavToggle, a Menu hamburger | top bar | SidebarProvider |
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โ
- 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.
- Full migration. The client rail moves onto the shared
Sidebarprimitives andSidebarProvider, rather than keeping a parallel implementation behind a shared-looking control. - Structure and state converge; appearance does not. Every surface keeps the pixels it has today.
- 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.
- Nav rows go flush. The space between rows is deleted; rows keep their current height, so the list gets shorter.
- 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:
| Row | Gap | Rhythm | |
|---|---|---|---|
/client | NAV_ROW_HEIGHT = 40 | NAV_ROW_GAP = 8 on the flex column | 48 |
/workspace | NAV_ROW = "h-10 my-1" | my-1 (4+4), gap-0 on SidebarMenu | 48 |
/ops | primitive 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:
SidebarMenuButtonsetsdata-activewhenisActiveโ the shadcn convention, and all three surfaces already passisActive.SidebarMenuItemcarrieshw-nav-rowon 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; deleteSidebarTrigger. SIDEBAR_WIDTH_ICONbecomesshellRem(SHELL_RAIL_COLLAPSED_WIDTH), derived rather than restated as5.5rem; the file already imports both. Neithershell-geometry.test.tsscan catches this constant โ the literal scan wants awidth:/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-iconcustom 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_WIDTHconst and the--sidebar-width-iconoverride onSidebarProvider. - Drop
my-1fromNAV_ROW(workspace) and fromRAIL_COLLAPSED_ROW(both). - The comment at
app/workspace/layout.tsx:547states the 48px rhythm thegap-0was protecting, and the one at:85restates it for the icon rail. Both are now wrong and must be rewritten, not left. /opsneeds no row edits โ it holds noNAV_ROW, and its gap comes from the primitive.
components/layout/NavToggle.tsx โ deleted.
components/client/ClientShell.tsx
- Desktop branch wraps in
<SidebarProvider>. - Delete
isRailCollapsedand its setter. - Stop passing
isRailCollapsed/onToggleRailtoClientTopBar. - The
isMobileearly return stays above the provider, so no secondSheetmounts behindMobileClientNav.
components/client/ClientTopBar.tsx
- Delete the toggle button, its separator, the
CollapseRailIconimport and the two props.TOGGLE_SEPARATOR_HEIGHTgoes with them.
components/client/ClientNavRail.tsx
- Recomposed onto
Sidebar/SidebarHeader/SidebarContent/SidebarMenu; readsuseSidebar()instead of takingisCollapsed. - Mount
<SidebarRail />. SidebarMenuButtonhides onlyspan:last-childwhen 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'sgapgoes with the migration toSidebarMenu.
app/globals.css
- The two join rules, next to the existing
.hw-rail-itemblock.
components/ui/sidebar.tsx (row density and the join)
SidebarMenuloses its defaultgap-1and itsgroup-data-[collapsible=icon]/sidebar:gap-2.SidebarMenuItemgainshw-nav-row.SidebarMenuButtongainsdata-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:
SidebarRail+ its test.- Swap it into
/workspaceand/ops; deleteNavToggleandSidebarTrigger. SIDEBAR_WIDTH_ICONdefault; delete both layout overrides.- Client migration:
ClientNavRail, thenClientShell, thenClientTopBar. - Row density: strip the gaps, then add the join rules. Last, because the
join rules depend on all three rails being
ul > li > a. - Tests, then the browser pass.
Testsโ
components/layout/__tests__/NavToggle.test.tsxbecomescomponents/ui/__tests__/SidebarRail.test.tsx: the same behaviours, plus chevron direction per state andnullon mobile.ClientTopBar.test.tsxโ therail toggleblock inverts to assert the bar renders no sidebar toggle.ClientNavRail.test.tsxโ every case needs aSidebarProviderwrapper, and the collapsed cases drive it through the provider rather than anisCollapsedprop.toHaveStyle({ width: "214px" })breaks, because the primitive sets width throughw-(--sidebar-width); assertdata-stateanddata-collapsibleinstead.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.tsgates 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, anddata-activemarks 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-sidebarand 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
/workspaceand/opsnow 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โ thePanelLeftClose/PanelLeftOpenpair inPortalChathides the "My chats" thread column, a second-level list inside the content region. Different control, different thing controlled.- Mobile.
MobileClientNavand theSheeton/workspaceand/opsare untouched โ but theSheetstill needs something to open it. This bullet read as if retiringNavTogglewere desktop-only work. It is not:NavTogglewas the only control that opened those two drawers below 768px, so it is replaced byMobileNavTriggerrather than deleted outright.SidebarRailabove 768px,MobileNavTriggerbelow it โ one control at every width. - Normalising counts, selection or icons across surfaces.