feat(app): peek the sidebar faster with tunable timing (#58517)

This commit is contained in:
Kit Langton
2026-10-23 12:29:26 -07:00
committed by GitHub
Unverified
parent a5e5f67b6c
commit 146ed0e1fb
2 changed files with 83 additions and 17 deletions
+62 -15
View File
@@ -11,6 +11,45 @@ export const sidebarPeekEnabled = motionTuning.toggle("sidebar.peek", {
value: true,
})
const peekKnobs = { group: "Experiments", when: sidebarPeekEnabled }
// "Matched" slides the peek on the docked sidebar's own spring instead of its quicker one.
const peekMatched = motionTuning.toggle("sidebar.peek.matched", {
...peekKnobs,
label: "Peek matches sidebar spring",
value: false,
})
const peekSpring = motionTuning.spring("sidebar.peek.spring", {
...peekKnobs,
label: "Peek spring",
when: () => sidebarPeekEnabled() && !peekMatched(),
shipped: "smooth",
presets: {
snappy: { duration: 0.12, bounce: 0 },
smooth: { duration: 0.18, bounce: 0 },
calm: { duration: 0.3, bounce: 0 },
},
})
const dwellKnob = (from: "edge" | "toggle", label: string, value: number) =>
motionTuning.number(`sidebar.peek.dwell.${from}`, {
...peekKnobs,
label,
value,
min: 0,
max: 300,
step: 5,
unit: "ms",
})
// How long the pointer rests before the sidebar peeks, in ms. Both sit well under the ~100ms where a response still
// reads as immediate, and above how long a pointer spends crossing: through the ~24px edge zone in a frame or two,
// over the 28px toggle in ~30–60ms on the way to the breadcrumb or the traffic lights. The toggle waits longer because
// the header row is crossed far more often than the bare window edge, and a peek that starts just before a click docks
// smoothly. Both are tunable in Knobs → Experiments.
const dwell = { edge: dwellKnob("edge", "Peek edge dwell", 40), toggle: dwellKnob("toggle", "Peek toggle dwell", 80) }
/** Whether any session in the vertical sidebar draws its unread dot, published by the sidebar's tab strip. */
export const [sidebarUnread, setSidebarUnread] = createSignal(false)
@@ -21,16 +60,21 @@ const menus = '[data-component="menu-v2-content"], [role="menu"]'
// A drag in progress, or a dialog over the app, which no peek should open under.
const busy = '[data-dragging="true"], [role="dialog"], [role="alertdialog"]'
// How long the pointer rests before the sidebar peeks, in ms. Both sit under the ~100–150ms where a response still reads
// as immediate, and above how long a pointer spends crossing: through the 8px edge strip in well under a frame, over the
// 28px toggle in ~30–60ms on the way to the breadcrumb or the traffic lights. The toggle waits longer because the header
// row is crossed far more often than the bare window edge, and a peek that starts just before a click docks smoothly.
const dwell = { edge: 100, toggle: 150 }
// The zone along the window's start edge that peeks the sidebar: the shell's 8px frame gutter, outside the content's
// card, plus a tunable reach into the content. It only reads where the pointer is, so clicks, scrollbars and text
// selection under it work as usual, and a held button never peeks. It starts below the session header's 56px band,
// which holds the traffic lights and the toggle.
const gutter = 8
// The strip along the window's start edge that peeks the sidebar: the shell's 8px frame gutter, outside the content's
// card, so no control, scrollbar or text lives in it. It starts below the session header's 56px band, which holds the
// traffic lights and the toggle.
const edge = 8
const edgeZone = motionTuning.number("sidebar.peek.edgeZone", {
...peekKnobs,
label: "Edge zone",
value: 16,
min: 0,
max: 48,
step: 1,
unit: "px",
})
const header = 56
@@ -48,8 +92,9 @@ type Box = { left: number; right: number; top: number; bottom: number }
/**
* Experiment: while the left sidebar is closed, resting the pointer on the window's start edge or on the sidebar's
* toggle slides the real sidebar in over the app without moving the layout, on the docked sidebar's own spring and in
* its own box. Docking from a peek continues from where it is: the sidebar stays and the layout makes room.
* toggle slides the real sidebar in over the app without moving the layout, on a quicker spring than the docked
* sidebar's and in its own box. Docking from a peek continues from where it is: the sidebar stays, finishing its slide on
* the peek's spring, and the layout makes room on the docked sidebar's.
*
* The peek holds while the pointer stays within the box spanning the toggle and the sidebar, plus a margin, so the way
* from the toggle into the sidebar never closes it. Closing reads the pointer's last known position on every move,
@@ -86,7 +131,7 @@ export function createSidebarPeek(input: {
// Whether the sidebar last docked from a peek, which already shows it.
let handedOff = false
let viewport = window.innerWidth
// Where the edge strip starts, and the box spanning the toggle and the sidebar, until the layout next changes.
// Where the edge zone starts, and the box spanning the toggle and the sidebar, until the layout next changes.
let edgeTop: number | undefined
let corridor: Box | undefined
@@ -106,7 +151,7 @@ export function createSidebarPeek(input: {
// Retargeting keeps the current position and velocity, so a peek that turns around mid-flight stays smooth.
const run = (target: number) => {
const tuning = prefersReducedMotion() ? fade : panelSpring()
const tuning = prefersReducedMotion() ? fade : peekMatched() ? panelSpring() : peekSpring()
motion.omega = (2 * Math.PI) / tuning.duration
motion.zeta = 1 - tuning.bounce
motion.set(target)
@@ -181,7 +226,7 @@ export function createSidebarPeek(input: {
opening = undefined
if (enabled() && input.available() && !interrupted() && still()) show()
}, dwell[from]),
}, dwell[from]()),
}
}
@@ -232,6 +277,8 @@ export function createSidebarPeek(input: {
const atEdge = (point: Point) => {
const rtl = document.documentElement.dir === "rtl"
const edge = gutter + edgeZone()
if (rtl ? point.x < viewport - edge : point.x > edge) return false
edgeTop ??= (input.aside()?.getBoundingClientRect().top ?? Infinity) + header
@@ -370,7 +417,7 @@ export function createSidebarPeek(input: {
const aside = input.aside()
if (!aside) return
// A titlebar or panel that moves the sidebar's row changes where the edge strip starts.
// A titlebar or panel that moves the sidebar's row changes where the edge zone starts.
const observer = new ResizeObserver(relayout)
observer.observe(aside.parentElement ?? aside)
onCleanup(() => observer.disconnect())
+21 -2
View File
@@ -44,9 +44,9 @@ function mount() {
}
// Rests the pointer on the window's start edge, below the header band.
function restOnEdge(target: Element) {
function restOnEdge(target: Element, clientX = 2, buttons = 0) {
target.dispatchEvent(
new PointerEvent("pointermove", { bubbles: true, clientX: 2, clientY: 300, pointerType: "mouse" }),
new PointerEvent("pointermove", { bubbles: true, clientX, clientY: 300, buttons, pointerType: "mouse" }),
)
}
@@ -59,6 +59,25 @@ it("peeks once the pointer rests on the window's start edge", async () => {
expect(view.peek.shown()).toBe(true)
})
it("peeks from the edge zone 16px past the 8px gutter, and not beyond it", async () => {
const inside = mount()
restOnEdge(inside.content, 24)
await afterDwell()
expect(inside.peek.shown()).toBe(true)
const beyond = mount()
restOnEdge(beyond.content, 25)
await afterDwell()
expect(beyond.peek.shown()).toBe(false)
})
it("doesn't peek while a mouse button is held, as when a text selection drags into the edge zone", async () => {
const view = mount()
restOnEdge(view.content, 12, 1)
await afterDwell()
expect(view.peek.shown()).toBe(false)
})
it("a press, a drag, a dialog or a resize clears the pending peek", async () => {
const interruptions = [
() => document.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" })),