feat(web): draw the sidebar's scrollbar only under the pointer

The session list overflows after a handful of sessions, and its scrollbar
was drawn permanently in a column that is at rest most of the time.

SidebarRoot now tracks the pointer over the whole column and rebinds
ui-theme's scrollbar indirection pair to `transparent` while it is
outside, keeping the thumb for 2s after the pointer leaves so it does not
blink out on the way past. Rebinding colour leaves the list's
`scrollbar-gutter: stable` reservation in force, so revealing the bar
moves no row.

ui-theme's gate now states the widened contract: a rebind targets an -l2
token pair or `transparent`, and nothing else.
This commit is contained in:
creatixchu
2026-08-04 15:05:32 +08:00
parent 3c436d781e
commit a030397aca
20 changed files with 430 additions and 28 deletions

View File

@@ -24,6 +24,19 @@
padding: 18px 10px 6px;
}
/* Scrollbars in the column are a pointer affordance: the shell adds this
class whenever the pointer is not inside (SidebarRoot.tsx owns the linger),
and rebinding ui-theme's indirection pair to `transparent` takes the thumb
out of every scroll region nested under it — the workspace browser's
session list today. `transparent` rather than `display: none` on the bar:
the reservation (`scrollbar-gutter: stable` on the list) stays in force, so
revealing the thumb never reflows a row. Rebinding contract and the two
rendering paths it reaches: ui-theme's README. */
.root.quietBars {
--dsh-scrollbar-thumb: transparent;
--dsh-scrollbar-thumb-hover: transparent;
}
/* Collapse phase 1: the whole frozen-width content fades out in place over
150ms; at settle the children unmount/snap to the rail layout. */
.fading > * {

View File

@@ -8,6 +8,11 @@
* button and the foot is the `sidebar.workspaces` registrant's, and the foot
* is the `sidebar.settings` registrant's; the shell hands them the wide flag
* (plus an expand request callback for the browser).
*
* The column also owns whether the scroll regions nested in it draw a
* scrollbar at all: the shell tracks the pointer and rebinds ui-theme's
* scrollbar indirection away while it is elsewhere, so a list the user is not
* pointing at carries no bar.
*/
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
@@ -22,6 +27,14 @@ import css from './SidebarRoot.module.css'
/** Wide-content unmount delay; matches the 150ms wide-content fade-out. */
const COLLAPSE_SETTLE_MS = 150
/**
* How long the column's scrollbars stay drawn after the pointer leaves it.
* The bar is a pointer affordance here, and hiding it on the leave event
* itself makes it blink out while the pointer is only crossing the column's
* edge — on the way to the conversation, or around a portalled menu.
*/
const SCROLLBAR_LINGER_MS = 2000
/**
* Render the sidebar column shell.
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
@@ -56,10 +69,29 @@ export function SidebarRoot({
const everWide = useRef(!collapsed)
if (!collapsed) everWide.current = true
// Scrollbars in the column follow the pointer (.quietBars rebinds them
// away): drawn while it is inside, and for SCROLLBAR_LINGER_MS after it
// leaves. A pointer that returns within that window cancels the pending
// hide rather than restarting from a hidden bar.
const [pointerInside, setPointerInside] = useState(false)
const lingerTimer = useRef<number | undefined>(undefined)
useEffect(() => () => { window.clearTimeout(lingerTimer.current) }, [])
return (
<div
className={clsx(css.root, !wide && css.collapsed, !wide && everWide.current && css.railIn, collapsed && wide && css.fading)}
className={clsx(
css.root, !wide && css.collapsed, !wide && everWide.current && css.railIn,
collapsed && wide && css.fading, !pointerInside && css.quietBars,
)}
style={wide ? { width: collapsed ? lastWideWidth.current : width } : undefined}
onPointerEnter={() => {
window.clearTimeout(lingerTimer.current)
setPointerInside(true)
}}
onPointerLeave={() => {
window.clearTimeout(lingerTimer.current)
lingerTimer.current = window.setTimeout(() => { setPointerInside(false) }, SCROLLBAR_LINGER_MS)
}}
>
<div className={css.logoRow}>
{/* Expanded, the wordmark doubles as a New Session shortcut; the