Skip to content

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

Merged
resure merged 2 commits into
resure:mainfrom
ykamendrovskiy:resizable-panels
Aug 10, 2026
Merged

feat: resizable side panels (folder rail + note list)#15
resure merged 2 commits into
resure:mainfrom
ykamendrovskiy:resizable-panels

Conversation

@ykamendrovskiy

Copy link
Copy Markdown
Contributor

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

@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

ykamendrovskiy and others added 2 commits August 10, 2026 16:41
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>
- Gate both dividers out of the mobile single-pane layout (the rail is a
  drawer there and the list fills the width, so a drag could only rewrite
  the desktop widths sight unseen)
- Don't commit a zero-movement click (it pinned the stylesheet default
  into localStorage as a chosen width)
- The editor-room cap now floors at the current width: it stops growth
  but never yanks an already-wider panel back (pointer and keyboard)
- Commit a mid-drag unmount (closing the rail under a held divider left
  an orphaned inline var with no matching state)
- Keep aria-valuenow live during a drag, written next to the width var
- :focus-visible uses the dedicated focus color — Tab is the only way to
  reach a divider, and the quiet hover shade undersold it
- Round widths at the clamp boundary (no fractional persists)
- Scope container-type to the toolbar — out of the overlay's
  intrinsic-width math; threshold rebased to the toolbar's content box
- Docs: README feature bullet, shortcuts (Panels section + Mouse),
  architecture (per-workspace layout keys)
- Tests: stray click, cap-below-current, unmount mid-drag, live
  aria-valuenow, no dividers when narrow

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

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — I agree on each of the numbered items. All four are addressed, plus the smaller notes; rebased onto main (the review response is a separate commit, fix: address code review on the panel dividers, so the delta is diffable).

1. Mobile. Rebased; both dividers now gate on !isNarrow || noteWindow, mirroring the body-className branch. Went with the layout condition rather than useHasHover: a wide touch screen still has the resizable column layout, and the dividers drag fine under touch-action: none pointer capture — it's the single-pane layout, not touch input, that makes resizing meaningless. New test asserts no separators render while narrow.

2. Stray click. Guarded exactly as you suggested (last !== startWidth). Test added.

3. Backwards cap snap. The cap now floors at the current width at gesture start — growth stops, nothing yanks back. Applied to the keyboard path too: ArrowLeft otherwise overshot its 16px step to the cap, and ArrowRight/End would shrink the panel they promise to grow. Test covers both pointer and keyboard.

4. Mid-drag unmount. The [dragging] effect cleanup now commits the pending width along with dropping the body class (no-op on a normal release — endDrag nulls the gesture first). Test unmounts mid-drag and asserts the commit + class removal.

Smaller notes, in order:

  • aria-valuenow is now written directly next to the width var on every move — live for AT, still no re-render.
  • Focus indicator switched to --g-color-line-focus for :focus-visible — brighter than the hover shade, still not the accent but with greater contrast and looking consistent with other components utilizing focus ring, like buttons.
  • Rounding moved into clampPanelWidth, symmetric with parsePanelWidth.
  • Containment scoped to .note-list__toolbar (threshold rebased to its content box, 225 = 249 − 24px padding), with the stacking-context/containing-block clause in the comment. Kept the explicit overlay width though: the WebKit underestimate isn't only the containment — the dividers' negative margins also sit in the shrink-to-fit sum (and a live containment-off toggle in Safari didn't bring the shadow back when we chased this). If you'd rather not have the border math there, an alternative is dropping the ±margins on the dividers in the overlay state — say the word.
  • Cross-window storage: agreed, deliberate.

Docs: README feature bullet, a Panels section + Mouse line in docs/shortcuts.md, and the per-workspace layout keys sentence in docs/architecture.md §Workspaces & windows.

Tests: 5 new (stray click, cap-below-current on both input paths, mid-drag unmount, live aria-valuenow, no-dividers-when-narrow) — 969 green, typecheck/format clean.

One transparency note: Workspace's cyclomatic complexity was already past the lint cap on main (29 > 20); the mobile gate adds three plain conditional renders → 32. Extracting them to satisfy the number read worse than the number — but if you'd like it under the cap I'm glad to pull the sidebar block into a component.

🤖 Generated with Claude Code

@resure
resure merged commit f068d42 into resure:main Aug 10, 2026
1 of 2 checks passed
resure added a commit that referenced this pull request Aug 10, 2026
…op identity (#16)

* fix: decouple the divider's unmount-commit from the onCommit prop identity

The mid-drag-unmount cleanup added in #15 tears down the live gesture
(it nulls `drag`), so having `onCommit` in its dep list made correctness
depend on the caller passing a referentially stable callback. Workspace
happens to pass useState setters, so this is latent — but an inline
lambda anywhere would break dragging on the next parent render: a
premature commit, every later pointermove ignored, and `panel-resizing`
stuck on <body> (endDrag returns early on a null gesture, so `dragging`
never clears) leaving the whole app in col-resize/no-select.

Read onCommit through a ref and key the effect on [dragging] alone.
Regression test asserts a parent re-render mid-drag leaves the gesture
intact; it fails on the old code at the premature-commit assertion.

Also two comment corrections and a docs catch-up:

- The cleanup's "⌃R workspace switch" example was inert: App keys
  Workspace by workspace id, so a switch unmounts the whole thing —
  the .workspace element (and the inline var) goes with it, and the
  commit lands on an unmounting component, so nothing persists and
  nothing needed to. ⌘⇧\ closing the rail is the real case.
- Note that the pointer path freezes its editor-room floor for the
  gesture while the keyboard path re-tightens per keypress, so the two
  disagree after shrinking past the cap and coming back.
- CLAUDE.md: list the two panel-width keys with the other per-workspace
  layout keys, and give PanelResizer an entry in the components map.

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

* style: cargo fmt src-tauri (unbreak the rust CI job)

`cargo fmt --check` has been red on main since the iOS work landed: the
two cfg-gated `MetadataExt` imports in `is_dataless` are out of
alphabetical order, which put the rust job in a failing state and so
skipped its `cargo test` and `cargo clippy` steps entirely.

Pure `cargo fmt` output; the two imports are mutually exclusive by cfg,
so the order carries no meaning. Locally: fmt clean, 25 Rust tests pass,
clippy --all-targets -D warnings clean (macOS host — CI builds Linux,
where the macOS-only paths are cfg'd out).

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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