diff --git a/packages/app/src/shell/sidebar-peek.ts b/packages/app/src/shell/sidebar-peek.ts index 625df427f4f..c9b8e62a675 100644 --- a/packages/app/src/shell/sidebar-peek.ts +++ b/packages/app/src/shell/sidebar-peek.ts @@ -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()) diff --git a/packages/app/test-browser/sidebar-peek.test.ts b/packages/app/test-browser/sidebar-peek.test.ts index e88287fd3a9..b5ea990445e 100644 --- a/packages/app/test-browser/sidebar-peek.test.ts +++ b/packages/app/test-browser/sidebar-peek.test.ts @@ -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" })),