Skip to content

feat: resizable side panels (folder rail + note list) - #15

Open
ykamendrovskiy wants to merge 1 commit into
resure:mainfrom
ykamendrovskiy:resizable-panels
Open

feat: resizable side panels (folder rail + note list)#15
ykamendrovskiy wants to merge 1 commit into
resure:mainfrom
ykamendrovskiy:resizable-panels

Conversation

@ykamendrovskiy

Copy link
Copy Markdown

Takes the "Resizable left panel" line from the README backlog — both left panels, in fact: the folder rail and the note list each get a draggable divider.

What

  • Drag the divider on either edge — rail↔list and list↔editor — to resize the panel to its left. The 7px hit strip straddles the 1px border the panels already draw, so nothing changes visually until you reach for it: a quiet 2px line-generic-active line fades in on hover (slightly delayed, so casual mouse travel doesn't flash it), instantly while dragging or focused.
  • Widths persist per workspace via the existing nsKey localStorage pattern; note windows stay out of it, like the rest of their transient layout.
  • Double-click a divider to reset to the stylesheet defaults (200/280).
  • Keyboard: the dividers are WAI-ARIA window splitters — focusable, ←/→ step the width by 16px, Home/End jump to the range edges.
  • One shared 160–480 range for both panels, plus a drag-time cap that always leaves the editor ≥320px.
  • Dragged tight (<250px), the note list's New button folds to its icon so the sort select keeps a readable width — a container query, so it tracks the live width mid-drag.

Implementation notes

  • During a drag the divider writes --rail-width/--sidebar-width inline on the workspace root; React state commits only on release, so there's no re-render per pointermove. They're the same vars the stylesheet already declared, so the collapse/peek overlay keeps sliding the whole sidebar as one unit, dividers included.
  • pointerdown is canceled at the root: WebKit otherwise starts a text selection that outlives any later user-select: none, and a divider click must not steal focus from wherever the user is working.
  • The collapsed/peeked overlay now carries an explicit width (calc() of the same vars): WebKit underestimates the shrink-to-fit width of the absolutely-positioned flex row and painted the peek shadow narrower than the laid-out panes. With the width pinned, the shadow also tracks live drags.
  • parsePanelWidth clamps anything read back from localStorage — its contents are user-editable and must not be able to break the layout.

Testing

  • New PanelResizer.test.tsx plus Workspace.test.tsx additions: drag/commit/reset flows, keyboard steps, clamping, persistence and restore, rail-closed cases, and the pointerdown-default assertion.
  • 947 tests green; lint/format/typecheck clean, no new warnings.
  • Hand-tested in Chromium and Safari (both WebKit quirks above surfaced there), light/dark, collapse/peek interplay, narrow windows.

🤖 Generated with Claude Code

Drag the divider on either edge — rail↔list and list↔editor — to resize
the panel to its left; widths persist per workspace. Double-click resets
to the defaults. The dividers are WAI-ARIA window splitters: focusable,
arrow keys step the width, Home/End jump the range. One shared 160–480
range for both panels, with a drag-time cap that always leaves the
editor at least 320px.

The pointerdown is canceled at the root so WebKit cannot start a text
selection mid-drag (and a divider click never steals focus). When the
list is dragged tight (<250px), the New button folds to its icon via a
container query so the sort select keeps a readable width.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@resure

resure commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Review

Nice piece of work — the architecture is the right shape for this codebase: ref-based gesture state so pointermoves never re-render the editor, live widths written straight to the CSS vars with state committing only on release, null-means-stylesheet-default persistence, pure exported helpers (clampPanelWidth / parsePanelWidth) that unit-test trivially, note windows excluded from persistence. Comments are at the repo's usual density and explain the why.

Verified locally on d1b7002: PanelResizer.test.tsx + Workspace.test.tsx → 69 passed; tsc --noEmit clean; ESLint on the touched files → 0 errors (4 pre-existing warnings in Workspace.tsx). The PR description's claims hold up.

Four things to fix before merge, then some smaller notes.

1. Stale base — the mobile single-pane layout landed after the branch point

The branch forks at f2a4c56; main is 12 commits ahead and now carries the ≤700px mobile layout plus the iOS work. A trial merge produces one conflict — in Workspace.tsx, exactly the sidebar JSX both sides rewrote. Post-rebase both dividers render unconditionally inside the mobile full-width sidebar, and that isn't benign:

  • On mobile the rail is an absolute drawer, so the rail divider becomes the sidebar's first in-flow child at the left edge — z-index: 2, same as the drawer but later in DOM order, so it paints above the drawer and above the dismiss backdrop (z-index: 1). A 7px cursor: col-resize; touch-action: none strip on the drawer's left edge.
  • The list divider sits at the phone's right screen edge. Mobile overrides the list to width: auto; flex: 1, so a drag there produces no visible change — but it still commits and persists --sidebar-width. A stray phone gesture silently rewrites the user's desktop panel width.
  • Two extra tab stops in the mobile pane.

Gate both renders on the layout that actually has resizable columns — !isNarrow, or !(isNarrow && !noteWindow) to mirror the body-class condition in Workspace.tsx. useIsNarrow and useHasHover are both exported from src/hooks/useIsNarrow.ts; since main already hides the shortcuts sheet on touch, useHasHover may be the better gate for a col-resize affordance.

2. A stray click on a divider commits a width

endDrag commits drag.current.last, seeded to width at pointerdown — so a click with zero movement fires onCommit. Verified at both levels: onCommit called once with 280, and at the Workspace level gravity-notes:test-ws:sidebar-width becomes "280". That pins today's default into localStorage forever, which is exactly what usePanelWidth's own doc comment says must not happen. It also means the first click of every double-click writes the key the second click then removes.

Fix mirrors the guard the keyboard path already has:

if (last !== drag.current.startWidth) onCommit(last);

3. The editor-min cap teleports the panel backwards on the first move

getMaxWidth can return a cap below the current width, and clampPanelWidth applies it to the whole gesture — so the first pointermove yanks the panel to the cap regardless of drag direction. Verified: body 750px with the rail open, dragging the list divider 4px to the right snaps it 280 → 230 and commits 230. The desktop window minimum is 300px and mobile only takes over at ≤700px, so this band is reachable. The cap should stop growth, not retroactively shrink — e.g. max: Math.max(computedCap, startWidth) sampled at drag start.

4. Unmount mid-drag drops the commit and desyncs DOM from state

If the divider unmounts during a drag (⌘⇧\ closes the rail, a ⌃R switch), no pointerup reaches it: the body class is cleaned up by the effect, but onCommit never fires and the inline --rail-width written directly to .workspace survives with no matching state — React only rewrites changed style keys, so nothing sweeps it up until a reload. Commit (or clear the var) from the effect cleanup.

Smaller notes

  • aria-valuenow is frozen during a pointer drag — state commits only on release. The keyboard path is fine; a screen reader following a mouse drag hears nothing. You can set the attribute directly next to setPanelVar and keep the no-re-render property.
  • Focus indicator contrast. outline: none plus a 2px --g-color-line-generic-active hairline is thin for a widget that is only reachable by Tab (the pointerdown preventDefault blocks click-focus). Likely short of WCAG 2.4.11 — consider the accent color for :focus-visible.
  • Fractional widths persist unrounded. clampPanelWidth doesn't round, so a fractional clientX delta can commit 340.5 → stored "340.5" → read back as 341. Round at the clamp/commit boundary, symmetric with parsePanelWidth.
  • container-type: inline-size also brings layout + style containment, making .note-list a stacking context and a containing block for position: fixed descendants. The row DropdownMenu / IconPickerPopup are portaled Gravity popups so they escape it today — worth a clause in the CSS comment so a future non-portaled popup inside the list doesn't get silently trapped. Related: the containment and the collapsed-overlay width: calc(… + 1px) workaround arrive in the same PR, so it's worth checking whether the WebKit shrink-to-fit symptom still reproduces with container-type removed. If it's downstream of the containment, scoping container-type to a toolbar wrapper would let you drop the hardcoded +1px/+2px border math — a third place the panels' border widths are encoded. (Couldn't test that without a browser.)
  • No cross-window storage-event adoption for the new keys — consistent with the existing rail/sidebar-open keys, and the one-window-per-workspace model makes it moot. Noting it's deliberate, not a finding.

Docs

CLAUDE.md's Docs section is explicit: a new user-facing feature gets a README feature bullet; a new/changed shortcut updates docs/shortcuts.md by hand. This PR only removes the backlog line. Missing:

  • a README Features bullet for resizable panels;
  • docs/shortcuts.md — the ←/→/Home/End steps and the double-click reset (the Mouse section already documents double-click/⌘-click conventions);
  • docs/architecture.md §Workspaces & windows — the two new per-workspace localStorage keys alongside the existing layout keys.

The divider chords are widget-local rather than global, so I'd leave the SHORTCUTS descriptor alone and just document them.

Tests

Strong for a UI feature: 13 unit tests over the component and both pure helpers, 4 Workspace integration tests covering commit/persist/restore/reset and rail-closed. The layOutBody helper and the "fireEvent returns false on preventDefault" trick are the right jsdom idioms. Gaps to add alongside the fixes: the zero-movement click (#2), the cap-below-current drag (#3), unmount mid-drag (#4), and — post-rebase — an assertion that no divider renders while isNarrow.


Verdict: rebase onto main and gate the dividers out of the mobile layout (1), fix the stray-click commit (2) and the backwards cap snap (3), then the docs. (4) and the smaller notes are cheap enough to fold into the same pass.

🤖 Reviewed with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants