feat(sidebar): remove the header, drag the pane edge, snap to the rail #124
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/sidebar-resize"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Stacked on #122 — both branches touch
MainSplit.svelte, so this targetsfeat/new-session-affordancerather thanmain.What
Reclaims the fixed chrome above the workspace list and replaces the collapse button with the pane edge itself.
.lp-top.ws-section-titleMeasured in the running app at 1440×900. Verified after the change: the first
.wsmoved fromy=126toy=61, and.lp-bodygrew 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:116was 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-titleand.pillstay — the settings panels andConnections.sveltestill use both.The resize
viewport − 240MIN_WIDTHis 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"withtabindex, and←→HomeEndall 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 aCollapse sidebar/Expand sidebarrow 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:
←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 reasonadd-session.svelte.tsis one: the palette andfocusNewSessionboth 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-wcustom 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 typecheck4905 files / 0 errors ·pnpm run lintclean ·pnpm test124 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:End360,←344,→360,Shift+→408, 20×←collapses without ever parking below the floor,→from the rail restores the remembered width,Home/Endaria-valuenowtracks throughoutcol-resizecursorExpand sidebar→ expands to the remembered width and closesdisplay: noneand hit-tests to the card underneath, palette offers no sidebar rowZero page errors throughout.
Review pass
/simplifyplus an explicit bug hunt, five agents. 16 findings applied in2126563, 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
draggingwas cleared only by apointerupreaching the window, and three routes end a pointer sequence without one: a right-click (the context menu eats the release), apointercancel, and a release over the dock's artifact iframe —sandbox="allow-scripts"with noallow-same-origin, so its events never surface.pointermovefires 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 !== 0at start,e.buttons === 0self-heal in the move handler, andonpointercancel.window.innerWidthlike every other move.min-width: 769pxin JS againstmax-width: 768pxin 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.focusNewSessionclobbered the desktop width preference from a phone. Verified on the base branch:'true'→'false'. This PR fixes a bug that already shipped in #122.' 'or'0'clamped to the floor instead of falling back to the default (Number(' ')is0, and0is finite —Number.isFinitealone 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
localStoragewrites, each broadcasting astorageevent to every other tab. Now 0 during the drag, 2 at release — measured live, along with 2 per arrow press and 0 for a strayEscapeorTabon 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 drivesclass:draggingfor the transition, cursor,user-selectand grip accent.Not introduced here. The mobile add-input does not take focus after "New session…" — verified identical on the base branch (
activeElementis the menu button both times). Out of scope for this PR.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.