From 55cfc668ce6bfa7c1fe643d33bd821e3725a2121 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 22 Oct 2026 16:54:10 -0700 Subject: [PATCH] feat(app): glide the sidebar tooltip between rows (#57947) --- .../app/src/shell/titlebar/sidebar-hover.tsx | 199 ------------------ packages/app/src/shell/titlebar/tab-nav.css | 28 --- .../app/src/shell/titlebar/tab-popover.tsx | 197 ++++++++++++++++- packages/app/src/shell/titlebar/titlebar.tsx | 2 - 4 files changed, 195 insertions(+), 231 deletions(-) delete mode 100644 packages/app/src/shell/titlebar/sidebar-hover.tsx diff --git a/packages/app/src/shell/titlebar/sidebar-hover.tsx b/packages/app/src/shell/titlebar/sidebar-hover.tsx deleted file mode 100644 index b3ae1a90be8..00000000000 --- a/packages/app/src/shell/titlebar/sidebar-hover.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { frameLoop, prefersReducedMotion, Spring } from "@opencode/ui/motion" -import { motionTuning } from "@opencode/ui/motion-tuning" -import { makeEventListener } from "@solid-primitives/event-listener" -import { createSignal, onCleanup, onMount, Show } from "solid-js" - -const choice = motionTuning.choice("sidebar.hover", { - label: "Style", - group: "Experiments", - card: "Sidebar hover", - order: 2, - value: "today", - labels: { today: "Today", glide: "Glide" }, -}) - -const tuning = motionTuning.spring("sidebar.hover.glide", { - label: "Glide", - group: "Experiments", - card: "Sidebar hover", - order: 2, - presets: { - snappy: { duration: 0.14, bounce: 0 }, - smooth: { duration: 0.2, bounce: 0 }, - calm: { duration: 0.28, bounce: 0 }, - }, -}) - -const [mode, setMode] = createSignal(choice()) - -motionTuning.subscribe(() => setMode(choice())) - -const rows = "[data-titlebar-tab], [data-titlebar-tab-action]" - -// The pill's corner radius, matching the rows'. -const radius = 6 - -const grid = (value: number) => Math.round(value * 64) / 64 - -const edges = ["top", "bottom", "left", "right"] as const - -/** - * Experiment: one hover pill glides behind the sidebar's rows, instead of each row lighting its own background. It - * targets the row nearest the pointer, so crossing a gap between rows holds it rather than dropping it. - */ -export function SidebarHover() { - return ( - - - - ) -} - -function Pill() { - let pill!: HTMLDivElement - let top!: HTMLDivElement - let fill!: HTMLDivElement - let bottom!: HTMLDivElement - let pointer: { x: number; y: number } | undefined - // The hovered row's edges relative to the sidebar, from the last frame's read phase. - let hit: { top: number; bottom: number; left: number; right: number } | undefined - let frame: (() => void) | undefined - - const motion = { - top: Spring.fromDuration(0, tuning()), - bottom: Spring.fromDuration(0, tuning()), - left: Spring.fromDuration(0, tuning()), - right: Spring.fromDuration(0, tuning()), - opacity: Spring.fromDuration(0, { duration: 0.15, bounce: 0 }), - } - - onMount(() => { - // SAFETY: the pill renders inside the sidebar's portal root. - const root = pill.parentElement! - root.dataset.sidebarHover = "glide" - onCleanup(() => delete root.dataset.sidebarHover) - - const read = () => { - hit = pointer && !root.querySelector('[data-dragging="true"]') ? nearest(root, pointer) : undefined - } - - const write = (dt: number) => { - const spring = tuning() - const reduced = prefersReducedMotion() - - const target = hit - - edges.forEach((edge) => { - motion[edge].omega = (2 * Math.PI) / spring.duration - motion[edge].zeta = 1 - spring.bounce - - if (!target) return - - // Appearing from hidden starts on the row rather than sweeping in from where it left. - if (motion.opacity.value === 0 || reduced) motion[edge].snap(target[edge]) - motion[edge].set(target[edge]) - }) - - motion.opacity.set(target ? 1 : 0) - - const y0 = grid(motion.top.step(dt)) - const y1 = grid(motion.bottom.step(dt)) - top.style.transform = `translateY(${y0}px)` - bottom.style.transform = `translateY(${y1 - radius * 2}px)` - fill.style.transform = `translateY(${y0 + radius}px) scaleY(${Math.max(0, y1 - y0 - radius * 2)})` - - const x0 = grid(motion.left.step(dt)) - const width = `${grid(motion.right.step(dt)) - x0}px` - pill.style.transform = `translateX(${x0}px)` - - // Only Home beside the sidebar toggle is narrower, so width changes only on the way to or from it. - if (pill.style.width !== width) pill.style.width = width - pill.style.opacity = String(motion.opacity.step(dt)) - - if (Object.values(motion).some((item) => !item.resting)) return true - frame = undefined - - return false - } - - const wake = () => { - frame ??= frameLoop.addMeasured(read, write) - } - - // While the pointer rests inside, rows that open, close, resize or finish a drag move the pill to the row now under it. - const resize = new ResizeObserver(wake) - const mutation = new MutationObserver(wake) - - makeEventListener(root, "pointermove", (event) => { - if (event.pointerType === "touch") return - - if (!pointer) - root.querySelectorAll("[data-titlebar-tab-list]").forEach((list) => { - resize.observe(list) - mutation.observe(list, { childList: true, subtree: true, attributeFilter: ["data-dragging"] }) - }) - - pointer = { x: event.clientX, y: event.clientY } - wake() - }) - makeEventListener(root, "pointerleave", () => { - pointer = undefined - resize.disconnect() - mutation.disconnect() - wake() - }) - makeEventListener(root, "pointerup", wake) - // Rows that scroll under a resting pointer move the pill with them. - makeEventListener(root, "scroll", wake, { capture: true, passive: true }) - onCleanup(() => { - frame?.() - resize.disconnect() - mutation.disconnect() - }) - }) - - // Opaque slices, so the caps can overlap the middle without darkening it; the group fades as one. - return ( -