feat(sidebar): remove the header, drag the pane edge, snap to the rail #124

Merged
lz merged 10 commits from feat/sidebar-resize into main 2026-09-05 12:41:38 +02:00
Owner

Stacked on #122 — both branches touch MainSplit.svelte, so this targets feat/new-session-affordance rather than main.

What

Reclaims the fixed chrome above the workspace list and replaces the collapse button with the pane edge itself.

holds height
.lp-top the collapse button, nothing else 30px + 1px border
.ws-section-title "Workspaces" + "N running · N total" 35px
above the first workspace 66px of an 839px pane

Measured in the running app at 1440×900. Verified after the change: the first .ws moved from y=126 to y=61, and .lp-body grew from 809px to 839px.

Why the header goes

Its label is redundant — the pane holds nothing else and every card names a workspace. Its one piece of unique data was the count, which appeared nowhere else in the app (Workers.svelte:116 was the only site) and was not sticky: it sits inside the scrolling body, so at a short viewport a 83px scroll puts it 83px above the scroller's top edge. It disappeared exactly when the list got long enough for a summary to be worth having. Per-workspace status is already on every card (dormant dimming, chips) and on every rail capsule (left border colour).

The global .section-title and .pill stay — the settings panels and Connections.svelte still use both.

The resize

rail 52px, unchanged
snap to rail below 120px
narrowest expanded 200px
default 360px, today's width
widest 560px, and never more than viewport − 240

MIN_WIDTH is set by measurement, not taste: the longest session name in routine use needs 189px of name column, which the pane reaches at 240px. At 200px a normal branch name still fits whole and the long one clips; at 160px two names sharing a prefix stop being distinguishable.

Drag the 9px strip over the pane border; double-click it to toggle; or use the keyboard — it is a role="separator" with tabindex, and Home End all work. That last part is not decoration: the button was the only non-pointer route to collapsing, so removing it without a focusable separator would be an accessibility regression. There is also a Collapse sidebar / Expand sidebar row in ⌘K, which costs no pixels at all and is offered only where the rail is reachable.

Two bugs found by driving it, both now pinned

Both were found in the interactive mock before any of this was written, and both have tests verified to fail when the guard is removed:

  1. Re-expansion must be gated at the floor, not at the snap point. A drag is a stream of samples and they are not monotonic — aim at 150px and the interpolated stream overshoots past 120 and comes back. With the two thresholds equal, the sample it settles on undoes the collapse and the pane lands at its minimum instead of in the rail.
  2. The keyboard must collapse at the floor, not at the snap point. Otherwise walks the width down into the hysteresis band and parks it there, and the next expand comes back narrower than any drag allows.

One of those two tests initially did not bind — its sample sequence carried on past the snap point and so re-collapsed, passing with the bug present. Caught by actually running the break rather than assuming the test was good; the sequence now ends inside the band.

Structure

  • sidebar-resize.ts — pure, DOM-free geometry: constants, clampWidth, fromPointer, fromKey. 19 tests.
  • sidebar.svelte.ts — a store, for the same reason add-session.svelte.ts is one: the palette and focusNewSession both need to expand the sidebar, and the alternative is one component reaching into another's markup by CSS selector, which has broken this exact handler twice.
  • sidebar-state.ts — gains a width pair beside the collapsed pair. Stored width is clamped, not rejected.

Pane width comes from a --pane-w custom property on .main-split, not an inline width on the pane: an inline width outranks the mobile media query and would show a 360px pane on a phone until hydration.

Verified

pnpm run typecheck 4905 files / 0 errors · pnpm run lint clean · pnpm test 124 files / 1254 tests.

The suite runs environment: 'node' with no DOM, so none of the rendering or dragging is covered by it. Browser pass on a real instance:

  • the strip, the button and the header are gone; the first workspace sits at the top of the pane
  • drag 500 → 500, 210 → 210, 119 → rail, then 150 from the rail → stays in the rail (the hysteresis, live), 400 → 400, 900 → clamps at 560
  • a drag that lands inside the band rests at 200
  • keyboard: End 360, 344, 360, Shift+→ 408, 20× collapses without ever parking below the floor, from the rail restores the remembered width, Home/End
  • double-click toggles both ways; aria-valuenow tracks throughout
  • grip is transparent at rest, accent on hover and on keyboard focus, col-resize cursor
  • width and collapsed state both survive a reload
  • ⌘K → "sidebar" → Expand sidebar → expands to the remembered width and closes
  • mobile 390×844: pane is full-width, grip is display: none and hit-tests to the card underneath, palette offers no sidebar row
  • the ⋯ menu's "New session…" from a collapsed sidebar expands it, renders the add row and focuses it, with no toast — the path that broke twice under previous restyles

Zero page errors throughout.

Review pass

/simplify plus an explicit bug hunt, five agents. 16 findings applied in 2126563, one skipped. Every claim was re-verified against the running app before it was acted on.

The bug hunter's closing observation is the finding that matters: every defect it found was outside sidebar-resize.ts — which is where all 19 tests are. The pure module was clean; the wiring around it held all sixteen.

Correctness

  • The drag could latch on. dragging was cleared only by a pointerup reaching the window, and three routes end a pointer sequence without one: a right-click (the context menu eats the release), a pointercancel, and a release over the dock's artifact iframe — sandbox="allow-scripts" with no allow-same-origin, so its events never surface. pointermove fires on hover regardless, and nothing checked the button state, so a latched drag made the pane follow a bare cursor and collapse if it swept past the snap point. Three independent guards now: e.button !== 0 at start, e.buttons === 0 self-heal in the move handler, and onpointercancel.
  • The dock reserve applied only while dragging. A 560px width stored on a wide window came back intact on a narrow one — wider than any drag could have produced there. Hydration now clamps against window.innerWidth like every other move.
  • 768 / 769 left a gap. min-width: 769px in JS against max-width: 768px in CSS: at a fractional viewport width neither matches, so the stylesheet kept the desktop layout while a collapsed sidebar rendered expanded with no grip to reach the rail. One query, negated — never two.
  • focusNewSession clobbered the desktop width preference from a phone. Verified on the base branch: 'true''false'. This PR fixes a bug that already shipped in #122.
  • Plus: a stored ' ' or '0' clamped to the floor instead of falling back to the default (Number(' ') is 0, and 0 is finite — Number.isFinite alone does not reject it); the grip discarded its grab offset, so the pane jumped by the click's offset within the 9px strip; and a sidebar collapsed at load animated shut on every page load.

Cost: a one-second drag performed ~59 synchronous localStorage writes, each broadcasting a storage event to every other tab. Now 0 during the drag, 2 at release — measured live, along with 2 per arrow press and 0 for a stray Escape or Tab on the focused grip.

Skipped one. The efficiency agent proposed attaching the window listeners only while dragging. By its own numbers that is single-digit microseconds, and the fix adds lifecycle machinery; it was also wrong that this retires dragging, which drives class:dragging for the transition, cursor, user-select and grip accent.

Not introduced here. The mobile add-input does not take focus after "New session…" — verified identical on the base branch (activeElement is the menu button both times). Out of scope for this PR.

Stacked on #122 — both branches touch `MainSplit.svelte`, so this targets `feat/new-session-affordance` rather than `main`. ## What Reclaims the fixed chrome above the workspace list and replaces the collapse button with the pane edge itself. | | holds | height | |---|---|---| | `.lp-top` | the collapse button, nothing else | 30px + 1px border | | `.ws-section-title` | "Workspaces" + "N running · N total" | 35px | | | **above the first workspace** | **66px** of an 839px pane | Measured in the running app at 1440×900. Verified after the change: the first `.ws` moved from `y=126` to `y=61`, and `.lp-body` grew from 809px to 839px. ## Why the header goes Its label is redundant — the pane holds nothing else and every card names a workspace. Its one piece of unique data was the count, which appeared nowhere else in the app (`Workers.svelte:116` was the only site) and **was not sticky**: it sits inside the scrolling body, so at a short viewport a 83px scroll puts it 83px above the scroller's top edge. It disappeared exactly when the list got long enough for a summary to be worth having. Per-workspace status is already on every card (dormant dimming, chips) and on every rail capsule (left border colour). The global `.section-title` and `.pill` stay — the settings panels and `Connections.svelte` still use both. ## The resize | | | |---|---| | rail | 52px, unchanged | | snap to rail below | 120px | | narrowest expanded | 200px | | default | 360px, today's width | | widest | 560px, and never more than `viewport − 240` | `MIN_WIDTH` is set by measurement, not taste: the longest session name in routine use needs 189px of name column, which the pane reaches at 240px. At 200px a normal branch name still fits whole and the long one clips; at 160px two names sharing a prefix stop being distinguishable. Drag the 9px strip over the pane border; double-click it to toggle; or use the keyboard — it is a `role="separator"` with `tabindex`, and `←` `→` `Home` `End` all work. That last part is not decoration: the button was the only non-pointer route to collapsing, so removing it without a focusable separator would be an accessibility regression. There is also a `Collapse sidebar` / `Expand sidebar` row in ⌘K, which costs no pixels at all and is offered only where the rail is reachable. ## Two bugs found by driving it, both now pinned Both were found in the interactive mock before any of this was written, and both have tests **verified to fail when the guard is removed**: 1. **Re-expansion must be gated at the floor, not at the snap point.** A drag is a stream of samples and they are not monotonic — aim at 150px and the interpolated stream overshoots past 120 and comes back. With the two thresholds equal, the sample it settles on undoes the collapse and the pane lands at its minimum instead of in the rail. 2. **The keyboard must collapse at the floor, not at the snap point.** Otherwise `←` walks the width down into the hysteresis band and parks it there, and the next expand comes back narrower than any drag allows. One of those two tests initially did not bind — its sample sequence carried on past the snap point and so re-collapsed, passing with the bug present. Caught by actually running the break rather than assuming the test was good; the sequence now ends inside the band. ## Structure - `sidebar-resize.ts` — pure, DOM-free geometry: constants, `clampWidth`, `fromPointer`, `fromKey`. 19 tests. - `sidebar.svelte.ts` — a store, for the same reason `add-session.svelte.ts` is one: the palette and `focusNewSession` both need to expand the sidebar, and the alternative is one component reaching into another's markup by CSS selector, which has broken this exact handler twice. - `sidebar-state.ts` — gains a width pair beside the collapsed pair. Stored width is clamped, not rejected. Pane width comes from a `--pane-w` custom property on `.main-split`, **not** an inline width on the pane: an inline width outranks the mobile media query and would show a 360px pane on a phone until hydration. ## Verified `pnpm run typecheck` 4905 files / 0 errors · `pnpm run lint` clean · `pnpm test` 124 files / 1254 tests. The suite runs `environment: 'node'` with no DOM, so none of the rendering or dragging is covered by it. Browser pass on a real instance: - the strip, the button and the header are gone; the first workspace sits at the top of the pane - drag 500 → 500, 210 → 210, 119 → rail, then 150 from the rail → **stays in the rail** (the hysteresis, live), 400 → 400, 900 → clamps at 560 - a drag that lands inside the band rests at 200 - keyboard: `End` 360, `←` 344, `→` 360, `Shift+→` 408, 20×`←` collapses without ever parking below the floor, `→` from the rail restores the remembered width, `Home`/`End` - double-click toggles both ways; `aria-valuenow` tracks throughout - grip is transparent at rest, accent on hover **and on keyboard focus**, `col-resize` cursor - width and collapsed state both survive a reload - ⌘K → "sidebar" → `Expand sidebar` → expands to the remembered width and closes - mobile 390×844: pane is full-width, grip is `display: none` and hit-tests to the card underneath, palette offers no sidebar row - the ⋯ menu's "New session…" from a **collapsed** sidebar expands it, renders the add row and focuses it, with no toast — the path that broke twice under previous restyles Zero page errors throughout. ## Review pass `/simplify` plus an explicit bug hunt, five agents. **16 findings applied** in `2126563`, one skipped. Every claim was re-verified against the running app before it was acted on. The bug hunter's closing observation is the finding that matters: **every defect it found was outside `sidebar-resize.ts` — which is where all 19 tests are.** The pure module was clean; the wiring around it held all sixteen. **Correctness** - **The drag could latch on.** `dragging` was cleared only by a `pointerup` reaching the window, and three routes end a pointer sequence without one: a right-click (the context menu eats the release), a `pointercancel`, and a release over the dock's artifact iframe — `sandbox="allow-scripts"` with no `allow-same-origin`, so its events never surface. `pointermove` fires on hover regardless, and nothing checked the button state, so a latched drag made the pane **follow a bare cursor** and collapse if it swept past the snap point. Three independent guards now: `e.button !== 0` at start, `e.buttons === 0` self-heal in the move handler, and `onpointercancel`. - **The dock reserve applied only while dragging.** A 560px width stored on a wide window came back intact on a narrow one — wider than any drag could have produced there. Hydration now clamps against `window.innerWidth` like every other move. - **768 / 769 left a gap.** `min-width: 769px` in JS against `max-width: 768px` in CSS: at a fractional viewport width neither matches, so the stylesheet kept the desktop layout while a collapsed sidebar rendered expanded with no grip to reach the rail. One query, negated — never two. - **`focusNewSession` clobbered the desktop width preference from a phone.** Verified on the base branch: `'true'` → `'false'`. This PR fixes a bug that already shipped in #122. - Plus: a stored `' '` or `'0'` clamped to the floor instead of falling back to the default (`Number(' ')` is `0`, and `0` is finite — `Number.isFinite` alone does not reject it); the grip discarded its grab offset, so the pane jumped by the click's offset within the 9px strip; and a sidebar collapsed at load animated shut on every page load. **Cost:** a one-second drag performed ~59 synchronous `localStorage` writes, each broadcasting a `storage` event to every other tab. Now **0 during the drag, 2 at release** — measured live, along with 2 per arrow press and 0 for a stray `Escape` or `Tab` on the focused grip. **Skipped one.** The efficiency agent proposed attaching the window listeners only while dragging. By its own numbers that is single-digit microseconds, and the fix *adds* lifecycle machinery; it was also wrong that this retires `dragging`, which drives `class:dragging` for the transition, cursor, `user-select` and grip accent. **Not introduced here.** The mobile add-input does not take focus after "New session…" — verified identical on the base branch (`activeElement` is the menu button both times). Out of scope for this PR.
lz added 9 commits 2026-09-05 00:49:01 +02:00
Measured, not estimated: 66px of fixed chrome above the workspace list, and
the count it carries is not sticky — it scrolls away exactly when the list
gets long enough for a summary to matter.

Carries the two bugs the mock already surfaced: the re-open threshold has to
sit above the snap point, and the keyboard has to collapse at the floor
rather than at the snap point.
A strictly-descending sample stream stays collapsed even with the hysteresis
removed, so it asserted nothing. Real pointer streams are not monotonic; the
sample that steps back up is the one that used to undo the collapse.
Re-expansion is gated at the floor, not at the snap point. With the two
thresholds equal, the pointer sample a drag settles on after crossing the
snap point undoes the collapse and the pane lands at its minimum instead of
in the rail. The keyboard collapses at the floor for the mirror-image
reason: a step below it has nowhere legal to land.

Both are pinned by tests verified to fail when the guard is removed.
The 30px strip existed only to hold the collapse button, so removing the
button removes the strip. The pane's width now comes from a custom property
on .main-split rather than an inline style: an inline width outranks the
mobile media query and would show a 360px pane on a phone until hydration.

The grip keeps role=separator and a tabindex despite the a11y rule — ARIA's
window-splitter pattern is a focusable separator, and it is the only keyboard
route left to a control that no longer has a button.
The label is redundant — the pane holds nothing else and every card names a
workspace. The count was the app's only fleet-wide summary but was never
sticky: it sits inside the scrolling body, so it leaves the screen exactly
when the list gets long enough for a summary to be worth having. Per-workspace
status is already on the card and on the rail capsule.

The global .section-title and .pill stay: the settings panels and Connections
still use both.
feat(palette): toggle the sidebar from Ctrl-K
All checks were successful
ci / nexus (pull_request) Successful in 10m1s
ci / images (pull_request) Successful in 13m54s
3e4128c0b9
The zero-pixel replacement for the button, and the route for someone who
never thinks to drag a 9px strip. One 'collapse' | 'expand' | null field
rather than a can-toggle flag beside a collapsed flag: that pair has a
combination meaning nothing, and null is how the row stays off mobile,
where there is no rail to collapse into.
fix(sidebar): the defects the review pass found, all outside the tested module
All checks were successful
ci / nexus (pull_request) Successful in 11m26s
ci / images (pull_request) Successful in 15m20s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 14s
212656331d
Every bug here lived in the wiring, not in the pure geometry — which is
exactly where all the tests were.

Correctness:

- The drag could latch on. `dragging` was cleared only by a pointerup that
  reached the window, and three routes end a pointer sequence without one: a
  right-click (the context menu eats it), a pointercancel, and a release over
  the dock's artifact iframe, which is sandboxed without allow-same-origin so
  its events never surface here. Because pointermove fires on hover and nothing
  checked the buttons, a latched drag made the pane follow a bare cursor — and
  collapse if it swept past the snap point. Fixed three ways: a primary-button
  gate so it cannot start, pointercancel so it ends, and a buttons===0 check so
  it heals on the next move.
- The dock reserve was enforced during a drag but not at rest. hydrate()
  assigned the stored width raw and toggle() passed it through unclamped, so a
  560 stored on a wide window survived onto a narrow one — wider than any drag
  could produce. The viewport clamp now lives in one place every mutation goes
  through.
- 768/769 left a gap. isDesktop used min-width:769px while the stylesheet uses
  max-width:768px; at a fractional viewport neither matched, so the CSS kept the
  desktop layout while a collapsed sidebar rendered expanded with no way to
  reach the rail. One query now, negated.
- focusNewSession expanded without a desktop gate, persisting collapsed=false
  from a phone — measured on the base branch, 'true' became 'false'. It now
  gates on showRail, which is the actual reason to expand.
- Whitespace and '0' clamped to the floor instead of falling back, because
  Number() maps them to 0, which is finite. Only a corrupted key reaches that
  path, which is the path's whole purpose.
- The 9px grip discarded the grab offset, teleporting the pane up to 4px on the
  first move.
- A collapsed sidebar animated shut on every load: SSR renders the store's
  defaults and hydrate() runs in onMount, so the transition turned a one-frame
  correction into a visible sweep. The transition is armed after hydration.

Cost:

- One synchronous localStorage write per pointer sample — measured at 59 for a
  one-second drag, each also broadcasting a storage event to every tab on the
  origin. Persistence is now per gesture: 0 writes during a drag, 2 at release.
- getBoundingClientRect() per sample re-read a left edge that cannot move
  during a drag.
- --pane-w sat on .main-split, inheriting into the dock subtree, when its only
  consumer is .left-pane. Moving it does not disturb the mobile override,
  because an inline custom property is not an inline width.

Cleanup:

- loadSidebarWidth restated clampWidth's range rule; one rule now.
- .left-pane.rail { padding: 0 } overrode nothing and was the sole consumer of
  the rail class.
- The palette re-derived showRail as a ternary; it reads toggleDirection.
- The stale header comment claimed MainSplit gates the flag and that the rail
  has a toggle to leave. Neither is true any more.
lz changed target branch from feat/new-session-affordance to main 2026-09-05 12:06:30 +02:00
lz merged commit b75ef15a23 into main 2026-09-05 12:41:37 +02:00
lz deleted branch feat/sidebar-resize 2026-09-05 12:41:40 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus!124
No description provided.