From 3e708f4141d4353d2119bdb1b39436955f4d427a Mon Sep 17 00:00:00 2001 From: matthewdias Date: Sun, 4 Oct 2026 17:26:22 -0500 Subject: [PATCH 1/6] Animate the return to Threads in Top Tabs The strip opens the sidebar instantly on a tab switch, so the page lays out once at its final width. Coming back to Threads, the sidebar and a freshly rendered thread therefore just appeared. Keep the instant layout and draw the motion on top with compositor-only properties: the sidebar slides in over the space it already holds, and the thread fades in. Reduced motion turns both off. Co-Authored-By: Claude Opus 5.5 --- plugins/top-tabs/README.md | 12 +++-- plugins/top-tabs/components/TopTabs.tsx | 22 +++++++-- plugins/top-tabs/lib/shell.ts | 61 +++++++++++++++++++++++++ plugins/top-tabs/top-tabs.css | 34 ++++++++++++++ 4 files changed, 122 insertions(+), 7 deletions(-) diff --git a/plugins/top-tabs/README.md b/plugins/top-tabs/README.md index a9c79fc..34b4b12 100644 --- a/plugins/top-tabs/README.md +++ b/plugins/top-tabs/README.md @@ -236,10 +236,14 @@ The sidebar belongs to Threads: undoes that. See [The Settings tab](#the-settings-tab). - **A split** pauses all of this until it closes. -When the strip moves the sidebar as part of a switch, it does so instantly. -The sidebar changes in the same step as the page, so the page lays out once, -at its final width. A slide would make a long thread lay itself out again on -every frame. Opening or closing the sidebar yourself still slides. +When the strip moves the sidebar as part of a switch, the space it takes +changes instantly, in the same step as the page, so the page lays out once, +at its final width. Sliding that space open would make a long thread lay +itself out again on every frame. What moves is drawn on top: going back to +Threads, the sidebar slides in over the space it already has and the thread +fades in, both animated without laying anything out again. Leaving Threads +is instant. Opening or closing the sidebar yourself still slides as bb +draws it. With reduced motion on, nothing animates. Above the thread list, the sidebar keeps bb's own navigation, unchanged. Its rows, drag-to-reorder, options menu, More, customize editor and diff --git a/plugins/top-tabs/components/TopTabs.tsx b/plugins/top-tabs/components/TopTabs.tsx index f670a91..262bee3 100644 --- a/plugins/top-tabs/components/TopTabs.tsx +++ b/plugins/top-tabs/components/TopTabs.tsx @@ -33,6 +33,8 @@ import { interceptPageClose, isSidebarOpen, observeSidebar, + playEntrance, + stopEntrances, toggleSidebar, } from "../lib/shell.ts"; import { useBridgedSplits } from "../lib/split-bridge.ts"; @@ -252,6 +254,8 @@ export function TopTabs() { const syncSidebar = useCallback((next: TabId | null) => { const before = previous.current; previous.current = next; + // An entrance left running would carry on over the page being left for. + if (next !== THREADS) stopEntrances(); const { compact, collapseSidebar, inSplit } = live.current; if (compact || !collapseSidebar || inSplit) return; const sidebarOpen = isSidebarOpen(); @@ -267,7 +271,13 @@ export function TopTabs() { ? s : { ...s, threadsSidebarOpen: step.threadsSidebarOpen }, ); - if (step.action !== null) toggleSidebar({ instant: true }); + if (step.action === null) return; + // Coming back to Threads slides the sidebar in, on the compositor; see + // playEntrance. Not on load, where there is nothing to come back from. + if (step.action === "expand" && next === THREADS && before !== undefined) { + playEntrance("sidebar"); + } + toggleSidebar({ instant: true }); }, []); useEffect(() => { @@ -328,7 +338,10 @@ export function TopTabs() { } // No early return for Threads: navigating to the saved location is a // no-op when already there, and anywhere else it is the way back. - if (active !== THREADS) syncSidebar(THREADS); + if (active !== THREADS) { + syncSidebar(THREADS); + if (!live.current.inSplit) playEntrance("page"); + } if (saved === undefined || !navigateToPath(saved)) bbNavigate.toCompose(); return; } @@ -379,7 +392,10 @@ export function TopTabs() { const { active, threadActions } = live.current; setSwitcher(null); stripMove.current = { to: THREADS, at: performance.now() }; - if (active !== THREADS) syncSidebar(THREADS); + if (active !== THREADS) { + syncSidebar(THREADS); + if (!live.current.inSplit) playEntrance("page"); + } // bb's own open: it focuses the thread's pane if a split shows it. threadActions.open(thread.id); }, diff --git a/plugins/top-tabs/lib/shell.ts b/plugins/top-tabs/lib/shell.ts index a2ec240..987c150 100644 --- a/plugins/top-tabs/lib/shell.ts +++ b/plugins/top-tabs/lib/shell.ts @@ -120,3 +120,64 @@ export function toggleSidebar(options: { instant?: boolean } = {}): boolean { trigger.click(); return true; } + +/** + * The motion an instant switch keeps. Each one is a keyframe animation in + * top-tabs.css keyed on a class on , and animates only `translate` or + * `opacity`, which the compositor runs without laying the page out again — + * the page still lays out once, at its final width, as `instant` promises. + * A class rather than an animation on an element, because the navigation it + * covers may replace the element before the first frame. + */ +const ENTRANCES = { + /** The sidebar slides back in over the space it already holds. */ + sidebar: { className: "bb-top-tabs-sidebar-enter", animation: "bb-top-tabs-sidebar-in" }, + /** The page fades in, so a thread rendered afresh does not just appear. */ + page: { className: "bb-top-tabs-page-enter", animation: "bb-top-tabs-page-in" }, +} as const; + +/** + * Longest an entrance class stays once a frame has drawn it: past its 200ms, + * for an element that mounts a little late. The animation's own end removes + * it sooner; this covers reduced motion, where nothing runs, and an element + * that never appears. + */ +const ENTRANCE_MAX_MS = 600; + +const playing = new Map void>(); + +/** + * Play one entrance from the next frame. One already running carries on + * rather than starting over. Its end is the only signal listened for: a + * cancel also fires when bb replaces the element mid-animation, and the + * class has to stay for the element that replaces it. + */ +export function playEntrance(kind: keyof typeof ENTRANCES): void { + const { className, animation } = ENTRANCES[kind]; + const root = document.documentElement; + playing.get(className)?.(); + let timer: number | undefined; + const frame = requestAnimationFrame(() => { + // Counted from the first frame that applies the class, since the render + // that blocks before it can last longer than the animation. + timer = window.setTimeout(stop, ENTRANCE_MAX_MS); + }); + const onEnd = (event: AnimationEvent) => { + if (event.animationName === animation) stop(); + }; + function stop() { + cancelAnimationFrame(frame); + window.clearTimeout(timer); + document.removeEventListener("animationend", onEnd, true); + root.classList.remove(className); + playing.delete(className); + } + playing.set(className, stop); + document.addEventListener("animationend", onEnd, true); + root.classList.add(className); +} + +/** Stop every entrance now, for a switch away before one has finished. */ +export function stopEntrances(): void { + for (const stop of [...playing.values()]) stop(); +} diff --git a/plugins/top-tabs/top-tabs.css b/plugins/top-tabs/top-tabs.css index ff93ac0..bb40f18 100644 --- a/plugins/top-tabs/top-tabs.css +++ b/plugins/top-tabs/top-tabs.css @@ -83,6 +83,40 @@ html.bb-top-tabs-instant-sidebar [data-testid="app-page-header-content-row"] { transition: none !important; } +/* What an instant switch keeps of motion (playEntrance in lib/shell.ts). + Only `translate` and `opacity`, which the compositor animates without + laying the page out again, so the page still lays out once and the + animation stays smooth while bb finishes rendering a long thread. The + sidebar slides in over the space bb has already given it; the page + fades in rather than appearing. Neither uses `transform` on the page, + which would become the containing block of everything fixed inside it. */ +html.bb-top-tabs-sidebar-enter [data-side="left"][data-state="expanded"] > [data-sidebar="panel"] { + animation: bb-top-tabs-sidebar-in 200ms cubic-bezier(0.2, 0, 0, 1); +} + +html.bb-top-tabs-page-enter [data-testid="app-layout-content-shell"] { + animation: bb-top-tabs-page-in 180ms ease-out; +} + +@keyframes bb-top-tabs-sidebar-in { + from { + translate: -100% 0; + } +} + +@keyframes bb-top-tabs-page-in { + from { + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + html.bb-top-tabs-sidebar-enter [data-side="left"] > [data-sidebar="panel"], + html.bb-top-tabs-page-enter [data-testid="app-layout-content-shell"] { + animation: none; + } +} + /* ---------------------------------------------------------------- strip */ .bb-top-tabs { From 013c28d64db3ebabe030ef9b7291d07f5cb59ea7 Mon Sep 17 00:00:00 2001 From: matthewdias Date: Sun, 4 Oct 2026 19:30:52 -0500 Subject: [PATCH 2/6] Play the Threads entrance once, and slide the Threads tab's title Leaving Plugins or Skills, bb mounts a fresh sidebar and page shell for the thread after the old ones are on screen. The entrance was a class on , so the new elements started the slide and fade over again. Play it with the Web Animations API on the elements there, and give anything bb mounts in their place the running start time, so it carries on. The Threads tab's thread title now opens and shuts by width, keyed on the route, so it moves with the sidebar and the page instead of snapping and pulling every tab after it left. Co-Authored-By: Claude Opus 5.5 --- plugins/top-tabs/README.md | 11 ++- plugins/top-tabs/components/TopTabs.tsx | 2 +- plugins/top-tabs/components/threads-tab.tsx | 22 ++++- plugins/top-tabs/lib/shell.ts | 100 +++++++++++++------- plugins/top-tabs/top-tabs.css | 66 +++++++------ 5 files changed, 123 insertions(+), 78 deletions(-) diff --git a/plugins/top-tabs/README.md b/plugins/top-tabs/README.md index 34b4b12..82514b5 100644 --- a/plugins/top-tabs/README.md +++ b/plugins/top-tabs/README.md @@ -63,8 +63,9 @@ showed, so what you used is where you look for it. ### The Threads tab While another tab is in view, the Threads tab shows the thread it will return -to beside its name. Clicking it returns to that thread, or to the compose -screen if you were there. +to beside its name. The name slides open as you leave and shut as you come +back, so the tabs after it move rather than jump. Clicking it returns to that +thread, or to the compose screen if you were there. It always shows three counts: @@ -241,8 +242,10 @@ changes instantly, in the same step as the page, so the page lays out once, at its final width. Sliding that space open would make a long thread lay itself out again on every frame. What moves is drawn on top: going back to Threads, the sidebar slides in over the space it already has and the thread -fades in, both animated without laying anything out again. Leaving Threads -is instant. Opening or closing the sidebar yourself still slides as bb +fades in, both animated without laying anything out again. Coming back from +bb's own pages, such as Plugins and Skills, bb mounts a new sidebar partway +through; it takes over the slide where it is rather than starting another. +Leaving Threads is instant. Opening or closing the sidebar yourself still slides as bb draws it. With reduced motion on, nothing animates. Above the thread list, the sidebar keeps bb's own navigation, unchanged. diff --git a/plugins/top-tabs/components/TopTabs.tsx b/plugins/top-tabs/components/TopTabs.tsx index 262bee3..c00a07c 100644 --- a/plugins/top-tabs/components/TopTabs.tsx +++ b/plugins/top-tabs/components/TopTabs.tsx @@ -814,7 +814,7 @@ export function TopTabs() { > {threadsLabelled && ( - + )} {threadsLabelled && } diff --git a/plugins/top-tabs/components/threads-tab.tsx b/plugins/top-tabs/components/threads-tab.tsx index a0fb655..a705a48 100644 --- a/plugins/top-tabs/components/threads-tab.tsx +++ b/plugins/top-tabs/components/threads-tab.tsx @@ -1,22 +1,38 @@ // The Threads tab's contents: its label, and what the hidden thread list // would tell you if you could see it. -import { useMemo } from "react"; +import { useMemo, useRef } from "react"; import { experimental_useSidebarThreads } from "@get-bb/plugin-sdk/app"; import { groupThreads, threadIdFromPath } from "../lib/tabs-model.ts"; /** * "Threads", plus the thread it will return to while another tab is in view — * the strip's answer to a browser tab's page title. + * + * The title stays mounted and slides open and shut rather than appearing, + * so the tabs after it glide instead of jumping (see top-tabs.css). It keys + * on `active`, the tab the route is on, not the one drawn selected: the + * strip selects a tab before bb renders it, and a thread's render would + * freeze the slide halfway. Keyed on the route, it moves with the sidebar + * and the page. It keeps its last title while it closes. */ export function ThreadsLabel({ active, savedPath }: { active: boolean; savedPath: string | undefined }) { const { threads } = experimental_useSidebarThreads(); - const threadId = active ? null : threadIdFromPath(savedPath); + const threadId = threadIdFromPath(savedPath); const title = threadId === null ? null : (threads.find((t) => t.id === threadId)?.displayTitle ?? null); + const lastTitle = useRef(title); + if (title !== null) lastTitle.current = title; + const shown = !active && title !== null; return ( Threads - {title !== null && {title}} + {lastTitle.current !== null && ( + + + {lastTitle.current} + + + )} ); } diff --git a/plugins/top-tabs/lib/shell.ts b/plugins/top-tabs/lib/shell.ts index 987c150..f09d580 100644 --- a/plugins/top-tabs/lib/shell.ts +++ b/plugins/top-tabs/lib/shell.ts @@ -122,59 +122,87 @@ export function toggleSidebar(options: { instant?: boolean } = {}): boolean { } /** - * The motion an instant switch keeps. Each one is a keyframe animation in - * top-tabs.css keyed on a class on , and animates only `translate` or + * The motion an instant switch keeps. Each animates only `translate` or * `opacity`, which the compositor runs without laying the page out again — - * the page still lays out once, at its final width, as `instant` promises. - * A class rather than an animation on an element, because the navigation it - * covers may replace the element before the first frame. + * the page still lays out once, at its final width, as `instant` promises — + * and which keeps going while bb finishes rendering a long thread. Nothing + * here uses `transform` on the page, which would become the containing block + * of everything fixed inside it. */ const ENTRANCES = { /** The sidebar slides back in over the space it already holds. */ - sidebar: { className: "bb-top-tabs-sidebar-enter", animation: "bb-top-tabs-sidebar-in" }, + sidebar: { + selector: '[data-side="left"] > [data-sidebar="panel"]', + keyframes: [{ translate: "-100% 0" }, { translate: "0 0" }], + timing: { duration: 200, easing: "cubic-bezier(0.2, 0, 0, 1)" }, + }, /** The page fades in, so a thread rendered afresh does not just appear. */ - page: { className: "bb-top-tabs-page-enter", animation: "bb-top-tabs-page-in" }, -} as const; + page: { + selector: '[data-testid="app-layout-content-shell"]', + keyframes: [{ opacity: 0 }, { opacity: 1 }], + timing: { duration: 180, easing: "ease-out" }, + }, +} satisfies Record; /** - * Longest an entrance class stays once a frame has drawn it: past its 200ms, - * for an element that mounts a little late. The animation's own end removes - * it sooner; this covers reduced motion, where nothing runs, and an element - * that never appears. + * How long after its first frame an entrance adopts elements bb mounts. + * Leaving bb's own pages (Plugins, Skills) mounts a fresh sidebar and page + * shell for the thread, a moment after the old ones are on screen. */ -const ENTRANCE_MAX_MS = 600; +const ADOPT_MS = 600; -const playing = new Map void>(); +const playing = new Map void>(); /** - * Play one entrance from the next frame. One already running carries on - * rather than starting over. Its end is the only signal listened for: a - * cancel also fires when bb replaces the element mid-animation, and the - * class has to stay for the element that replaces it. + * Play one entrance from the next frame, on the elements there then and on + * any bb mounts in their place shortly after. A replacement takes the + * running animation's start time, so it carries on rather than starting + * over: one slide, however many times bb swaps the element under it. */ export function playEntrance(kind: keyof typeof ENTRANCES): void { - const { className, animation } = ENTRANCES[kind]; - const root = document.documentElement; - playing.get(className)?.(); + playing.get(kind)?.(); + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + const { selector, keyframes, timing } = ENTRANCES[kind]; + const animations: Animation[] = []; + const animated = new WeakSet(); + // The frame the entrance began on. A replacement can only mount after that + // frame was drawn, so it never starts its own slide: it takes the running + // animation's start time, or this frame's while bb's render is still + // holding that back, which errs toward arriving finished. + let firstFrame: number | null = null; + const adopt = (element: Element) => { + if (animated.has(element)) return; + animated.add(element); + const animation = element.animate(keyframes, timing); + if (firstFrame !== null) animation.startTime = animations[0]?.startTime ?? firstFrame; + animations.push(animation); + }; + const observer = new MutationObserver((records) => { + for (const record of records) { + for (const node of Array.from(record.addedNodes)) { + if (!(node instanceof Element)) continue; + if (node.matches(selector)) adopt(node); + else node.querySelectorAll(selector).forEach(adopt); + } + } + }); let timer: number | undefined; - const frame = requestAnimationFrame(() => { - // Counted from the first frame that applies the class, since the render - // that blocks before it can last longer than the animation. - timer = window.setTimeout(stop, ENTRANCE_MAX_MS); + const frame = requestAnimationFrame((now) => { + document.querySelectorAll(selector).forEach(adopt); + // Set after: the elements there now start as this frame draws them. + firstFrame = now; + observer.observe(document.body, { childList: true, subtree: true }); + // Counted from the first frame, since the render that blocks before it + // can last longer than the animation. + timer = window.setTimeout(() => observer.disconnect(), ADOPT_MS); }); - const onEnd = (event: AnimationEvent) => { - if (event.animationName === animation) stop(); - }; - function stop() { + playing.set(kind, () => { cancelAnimationFrame(frame); window.clearTimeout(timer); - document.removeEventListener("animationend", onEnd, true); - root.classList.remove(className); - playing.delete(className); - } - playing.set(className, stop); - document.addEventListener("animationend", onEnd, true); - root.classList.add(className); + observer.disconnect(); + for (const animation of animations) animation.cancel(); + playing.delete(kind); + }); } /** Stop every entrance now, for a switch away before one has finished. */ diff --git a/plugins/top-tabs/top-tabs.css b/plugins/top-tabs/top-tabs.css index bb40f18..5a49102 100644 --- a/plugins/top-tabs/top-tabs.css +++ b/plugins/top-tabs/top-tabs.css @@ -83,40 +83,6 @@ html.bb-top-tabs-instant-sidebar [data-testid="app-page-header-content-row"] { transition: none !important; } -/* What an instant switch keeps of motion (playEntrance in lib/shell.ts). - Only `translate` and `opacity`, which the compositor animates without - laying the page out again, so the page still lays out once and the - animation stays smooth while bb finishes rendering a long thread. The - sidebar slides in over the space bb has already given it; the page - fades in rather than appearing. Neither uses `transform` on the page, - which would become the containing block of everything fixed inside it. */ -html.bb-top-tabs-sidebar-enter [data-side="left"][data-state="expanded"] > [data-sidebar="panel"] { - animation: bb-top-tabs-sidebar-in 200ms cubic-bezier(0.2, 0, 0, 1); -} - -html.bb-top-tabs-page-enter [data-testid="app-layout-content-shell"] { - animation: bb-top-tabs-page-in 180ms ease-out; -} - -@keyframes bb-top-tabs-sidebar-in { - from { - translate: -100% 0; - } -} - -@keyframes bb-top-tabs-page-in { - from { - opacity: 0; - } -} - -@media (prefers-reduced-motion: reduce) { - html.bb-top-tabs-sidebar-enter [data-side="left"] > [data-sidebar="panel"], - html.bb-top-tabs-page-enter [data-testid="app-layout-content-shell"] { - animation: none; - } -} - /* ---------------------------------------------------------------- strip */ .bb-top-tabs { @@ -322,7 +288,39 @@ svg.bb-top-tab-icon, white-space: nowrap; } +/* The Threads tab's thread title opens and shuts by its width, so the tabs + after it glide rather than jump. A one-column grid going from 0fr to 1fr + animates to the title's own width without measuring it. Only the strip + lays out, and it is small and fixed. The title inside keeps its full width + and is clipped, so it doesn't re-ellipsize on every frame. */ +.bb-top-tab-sublabel-reveal { + display: grid; + grid-template-columns: 0fr; + opacity: 0; + transition: + grid-template-columns 200ms cubic-bezier(0.2, 0, 0, 1), + opacity 150ms ease-out; +} + +.bb-top-tab-sublabel-reveal[data-shown] { + grid-template-columns: 1fr; + opacity: 1; +} + +.bb-top-tab-sublabel-clip { + min-width: 0; + overflow: hidden; +} + +@media (prefers-reduced-motion: reduce) { + .bb-top-tab-sublabel-reveal { + transition: none; + } +} + .bb-top-tab-sublabel { + display: block; + width: max-content; min-width: 0; max-width: 200px; overflow: hidden; From 08e70893a73e68c18a5325b110cfbeb4888266b3 Mon Sep 17 00:00:00 2001 From: matthewdias Date: Sun, 4 Oct 2026 20:56:29 -0500 Subject: [PATCH 3/6] Slide the thread list in, not the sidebar of the page being left Coming back to Threads from Plugins, Skills or Settings, the first frame still shows that page's own sidebar and content while bb renders the thread, so the entrance slid Skills' sidebar in and then bb swapped the thread list in without one. Hold another page's sidebar and content at the entrance's first keyframe until that sidebar leaves, then start the entrance on what is there. bb marks each page's sidebar in its top row's test id; only non-thread-list marks count, so a rename falls back to the old behaviour. Co-Authored-By: Claude Opus 5.5 --- plugins/top-tabs/README.md | 5 +- plugins/top-tabs/lib/shell.ts | 92 ++++++++++++++++++++++++++++------- 2 files changed, 77 insertions(+), 20 deletions(-) diff --git a/plugins/top-tabs/README.md b/plugins/top-tabs/README.md index 82514b5..31c21c3 100644 --- a/plugins/top-tabs/README.md +++ b/plugins/top-tabs/README.md @@ -243,8 +243,9 @@ at its final width. Sliding that space open would make a long thread lay itself out again on every frame. What moves is drawn on top: going back to Threads, the sidebar slides in over the space it already has and the thread fades in, both animated without laying anything out again. Coming back from -bb's own pages, such as Plugins and Skills, bb mounts a new sidebar partway -through; it takes over the slide where it is rather than starting another. +a page with a sidebar of its own (Plugins, Skills, Settings), that sidebar +and page stay out of sight until bb has the thread list ready, and the +thread list is what slides in. Leaving Threads is instant. Opening or closing the sidebar yourself still slides as bb draws it. With reduced motion on, nothing animates. diff --git a/plugins/top-tabs/lib/shell.ts b/plugins/top-tabs/lib/shell.ts index f09d580..8fe725b 100644 --- a/plugins/top-tabs/lib/shell.ts +++ b/plugins/top-tabs/lib/shell.ts @@ -149,35 +149,90 @@ const ENTRANCES = { * Leaving bb's own pages (Plugins, Skills) mounts a fresh sidebar and page * shell for the thread, a moment after the old ones are on screen. */ -const ADOPT_MS = 600; +const ADOPT_MS = 1000; + +/** + * Longest another page's sidebar and content are held out of sight, waiting + * for bb to mount the thread. Going back to Threads always ends on the + * thread list, so this only bounds a render slower than bb ever is. + */ +const HOLD_MS = 3000; + +/** + * A page's own sidebar rather than the thread list. bb marks each sidebar's + * top row by its page: `app-sidebar-top-reserve-row` for the thread list, + * `skills-sidebar-top-reserve-row` on Skills. Matching every other page's + * mark, not the thread list's, means a rename leaves everything counted as + * the thread list, which is how this behaved before it knew the difference. + */ +const OTHER_PAGES_SIDEBAR = + '[data-side="left"] [data-testid$="-sidebar-top-reserve-row"]:not([data-testid="app-sidebar-top-reserve-row"])'; const playing = new Map void>(); /** * Play one entrance from the next frame, on the elements there then and on - * any bb mounts in their place shortly after. A replacement takes the - * running animation's start time, so it carries on rather than starting - * over: one slide, however many times bb swaps the element under it. + * any bb mounts in their place shortly after, so it plays once however many + * times bb swaps the element under it. + * + * On the way back from a page with its own sidebar, what is on screen at + * first is that page's sidebar and content, still up while bb renders the + * thread. Those are held at the entrance's first keyframe, out of sight, + * until that sidebar leaves; the entrance then starts on what is there, the + * thread list's sidebar and the page shell bb kept. Otherwise a replacement + * takes the running animation's start time and carries on rather than + * starting over. */ export function playEntrance(kind: keyof typeof ENTRANCES): void { playing.get(kind)?.(); if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; const { selector, keyframes, timing } = ENTRANCES[kind]; + const holds: Animation[] = []; const animations: Animation[] = []; - const animated = new WeakSet(); - // The frame the entrance began on. A replacement can only mount after that - // frame was drawn, so it never starts its own slide: it takes the running - // animation's start time, or this frame's while bb's render is still - // holding that back, which errs toward arriving finished. - let firstFrame: number | null = null; + const seen = new WeakSet(); + // When the first animation was first drawn. A replacement mounts after + // that, so it never starts its own entrance: it takes the first one's + // start time, or this while bb's render is still holding that back, which + // errs toward arriving finished. + let begun: number | null = null; const adopt = (element: Element) => { - if (animated.has(element)) return; - animated.add(element); + if (seen.has(element)) return; + seen.add(element); const animation = element.animate(keyframes, timing); - if (firstFrame !== null) animation.startTime = animations[0]?.startTime ?? firstFrame; + const lead = animations[0]; + if (lead === undefined) requestAnimationFrame((now) => (begun ??= now)); + else if ((lead.startTime ?? begun) !== null) animation.startTime = lead.startTime ?? begun; animations.push(animation); }; + const held: Element[] = []; + const hold = (element: Element) => { + held.push(element); + holds.push(element.animate([keyframes[0]!, keyframes[0]!], { duration: HOLD_MS * 2 })); + }; + const release = () => { + for (const animation of holds) animation.cancel(); + holds.length = 0; + }; + let timer: number | undefined; + /** Adopt replacements for a while from now, then let anything held show. */ + const settle = (ms: number) => { + window.clearTimeout(timer); + timer = window.setTimeout(() => { + observer.disconnect(); + release(); + }, ms); + }; const observer = new MutationObserver((records) => { + // Held: wait for the other page's sidebar to go, then start on whatever + // is there, new or kept. + if (holds.length > 0) { + if (document.querySelector(OTHER_PAGES_SIDEBAR) !== null) return; + release(); + for (const element of held) if (element.isConnected) adopt(element); + document.querySelectorAll(selector).forEach(adopt); + settle(ADOPT_MS); + return; + } for (const record of records) { for (const node of Array.from(record.addedNodes)) { if (!(node instanceof Element)) continue; @@ -186,20 +241,21 @@ export function playEntrance(kind: keyof typeof ENTRANCES): void { } } }); - let timer: number | undefined; const frame = requestAnimationFrame((now) => { - document.querySelectorAll(selector).forEach(adopt); - // Set after: the elements there now start as this frame draws them. - firstFrame = now; + const otherPage = document.querySelector(OTHER_PAGES_SIDEBAR) !== null; + document.querySelectorAll(selector).forEach(otherPage ? hold : adopt); + // Drawn on this frame, so a replacement knows how far along it is. + if (!otherPage) begun = now; observer.observe(document.body, { childList: true, subtree: true }); // Counted from the first frame, since the render that blocks before it // can last longer than the animation. - timer = window.setTimeout(() => observer.disconnect(), ADOPT_MS); + settle(otherPage ? HOLD_MS : ADOPT_MS); }); playing.set(kind, () => { cancelAnimationFrame(frame); window.clearTimeout(timer); observer.disconnect(); + release(); for (const animation of animations) animation.cancel(); playing.delete(kind); }); From 4909a1c1adbd0c32701f50309dfff992f998f069 Mon Sep 17 00:00:00 2001 From: matthewdias Date: Sun, 4 Oct 2026 21:12:36 -0500 Subject: [PATCH 4/6] Start the Threads entrance once bb has drawn the thread The slide and fade started as the navigation began, then bb laid out the thread in long frames: the compositor's clock ran while nothing new was on screen, so the sidebar appeared most of the way in, or stalled partway. The Threads tab's title animates its width on the main thread and froze outright. Hold the entrance at its first keyframe until frames come steadily again (two quick frames, or 300ms at most), then start the slide, the fade and the title together. Co-Authored-By: Claude Opus 5.5 --- plugins/top-tabs/README.md | 14 +- plugins/top-tabs/components/threads-tab.tsx | 20 ++- plugins/top-tabs/lib/shell.ts | 136 +++++++++++++------- 3 files changed, 109 insertions(+), 61 deletions(-) diff --git a/plugins/top-tabs/README.md b/plugins/top-tabs/README.md index 31c21c3..c89cad2 100644 --- a/plugins/top-tabs/README.md +++ b/plugins/top-tabs/README.md @@ -242,12 +242,14 @@ changes instantly, in the same step as the page, so the page lays out once, at its final width. Sliding that space open would make a long thread lay itself out again on every frame. What moves is drawn on top: going back to Threads, the sidebar slides in over the space it already has and the thread -fades in, both animated without laying anything out again. Coming back from -a page with a sidebar of its own (Plugins, Skills, Settings), that sidebar -and page stay out of sight until bb has the thread list ready, and the -thread list is what slides in. -Leaving Threads is instant. Opening or closing the sidebar yourself still slides as bb -draws it. With reduced motion on, nothing animates. +fades in, both animated without laying anything out again. They wait until +bb has finished drawing the thread, so the motion plays from start to end +instead of freezing partway or appearing half done, and the Threads tab's +title moves with them. Coming back from a page with a sidebar of its own +(Plugins, Skills, Settings), that sidebar and page stay out of sight until +bb has the thread list ready, and the thread list is what slides in. +Leaving Threads is instant. Opening or closing the sidebar yourself still +slides as bb draws it. With reduced motion on, nothing animates. Above the thread list, the sidebar keeps bb's own navigation, unchanged. Its rows, drag-to-reorder, options menu, More, customize editor and diff --git a/plugins/top-tabs/components/threads-tab.tsx b/plugins/top-tabs/components/threads-tab.tsx index a705a48..c13e545 100644 --- a/plugins/top-tabs/components/threads-tab.tsx +++ b/plugins/top-tabs/components/threads-tab.tsx @@ -1,7 +1,8 @@ // The Threads tab's contents: its label, and what the hidden thread list // would tell you if you could see it. -import { useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { experimental_useSidebarThreads } from "@get-bb/plugin-sdk/app"; +import { whenSettled } from "../lib/shell.ts"; import { groupThreads, threadIdFromPath } from "../lib/tabs-model.ts"; /** @@ -10,10 +11,10 @@ import { groupThreads, threadIdFromPath } from "../lib/tabs-model.ts"; * * The title stays mounted and slides open and shut rather than appearing, * so the tabs after it glide instead of jumping (see top-tabs.css). It keys - * on `active`, the tab the route is on, not the one drawn selected: the - * strip selects a tab before bb renders it, and a thread's render would - * freeze the slide halfway. Keyed on the route, it moves with the sidebar - * and the page. It keeps its last title while it closes. + * on `active`, the tab the route is on, not the one drawn selected, and + * waits for bb to finish drawing: the strip selects a tab before bb renders + * it, and a thread's render would freeze the slide halfway. It keeps its + * last title while it closes. */ export function ThreadsLabel({ active, savedPath }: { active: boolean; savedPath: string | undefined }) { const { threads } = experimental_useSidebarThreads(); @@ -22,7 +23,14 @@ export function ThreadsLabel({ active, savedPath }: { active: boolean; savedPath threadId === null ? null : (threads.find((t) => t.id === threadId)?.displayTitle ?? null); const lastTitle = useRef(title); if (title !== null) lastTitle.current = title; - const shown = !active && title !== null; + // Moves with the sidebar and the page, once bb has drawn the thread: the + // width is animated on the main thread, which bb's render would stall. + const wanted = !active && title !== null; + const [shown, setShown] = useState(wanted); + useEffect(() => { + if (wanted === shown) return; + return whenSettled(() => setShown(wanted)); + }, [wanted, shown]); return ( Threads diff --git a/plugins/top-tabs/lib/shell.ts b/plugins/top-tabs/lib/shell.ts index 8fe725b..8054d5f 100644 --- a/plugins/top-tabs/lib/shell.ts +++ b/plugins/top-tabs/lib/shell.ts @@ -145,15 +145,15 @@ const ENTRANCES = { } satisfies Record; /** - * How long after its first frame an entrance adopts elements bb mounts. + * How long after it starts an entrance adopts elements bb mounts. * Leaving bb's own pages (Plugins, Skills) mounts a fresh sidebar and page * shell for the thread, a moment after the old ones are on screen. */ const ADOPT_MS = 1000; /** - * Longest another page's sidebar and content are held out of sight, waiting - * for bb to mount the thread. Going back to Threads always ends on the + * Longest an entrance holds its elements out of sight, waiting for bb to + * mount and draw the thread. Going back to Threads always ends on the * thread list, so this only bounds a render slower than bb ever is. */ const HOLD_MS = 3000; @@ -168,53 +168,81 @@ const HOLD_MS = 3000; const OTHER_PAGES_SIDEBAR = '[data-side="left"] [data-testid$="-sidebar-top-reserve-row"]:not([data-testid="app-sidebar-top-reserve-row"])'; +/** A frame this quick means the browser has caught up with bb's render. */ +const STEADY_FRAME_MS = 25; + +/** Longest to wait for steady frames before moving anyway. */ +const SETTLE_MAX_MS = 300; + +/** + * Run `run` once the browser has caught up: two quick frames in a row, or + * at most `SETTLE_MAX_MS` from now. bb renders a thread in long frames, and + * motion started during them is spent unseen or stalls, so the strip moves + * once they're over. Returns a cancel. + */ +export function whenSettled(run: (now: number) => void): () => void { + const deadline = performance.now() + SETTLE_MAX_MS; + let last: number | null = null; + let steady = 0; + let frame = 0; + const tick = (now: number) => { + steady = last !== null && now - last < STEADY_FRAME_MS ? steady + 1 : 0; + last = now; + if (steady >= 2 || now >= deadline) run(now); + else frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); +} + const playing = new Map void>(); /** - * Play one entrance from the next frame, on the elements there then and on - * any bb mounts in their place shortly after, so it plays once however many - * times bb swaps the element under it. + * Play one entrance, once bb has drawn what it is for. * - * On the way back from a page with its own sidebar, what is on screen at - * first is that page's sidebar and content, still up while bb renders the - * thread. Those are held at the entrance's first keyframe, out of sight, - * until that sidebar leaves; the entrance then starts on what is there, the - * thread list's sidebar and the page shell bb kept. Otherwise a replacement - * takes the running animation's start time and carries on rather than - * starting over. + * Until then the elements are held at the entrance's first keyframe, out of + * sight: through bb's render of the thread, which would otherwise spend or + * stall the motion (see whenSettled), and, on the way back from a page with + * its own sidebar, until that sidebar has left — what is on screen at first + * is that page's sidebar and content, still up while bb renders the thread. + * The entrance then starts on what is there, new or kept. + * + * Once it is playing, anything bb mounts in place of its elements takes the + * running animation's start time and carries on rather than starting over. */ export function playEntrance(kind: keyof typeof ENTRANCES): void { playing.get(kind)?.(); if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; const { selector, keyframes, timing } = ENTRANCES[kind]; - const holds: Animation[] = []; + const holds = new Map(); const animations: Animation[] = []; const seen = new WeakSet(); - // When the first animation was first drawn. A replacement mounts after - // that, so it never starts its own entrance: it takes the first one's - // start time, or this while bb's render is still holding that back, which - // errs toward arriving finished. + let started = false; + let otherPage = false; + // When the entrance was first drawn. A replacement mounts after that, so + // it takes the first animation's start time, or this while that is still + // on its way to the compositor. let begun: number | null = null; + const hold = (element: Element) => { + if (holds.has(element)) return; + holds.set(element, element.animate([keyframes[0]!, keyframes[0]!], { duration: HOLD_MS * 2 })); + }; + const release = () => { + for (const animation of holds.values()) animation.cancel(); + holds.clear(); + }; const adopt = (element: Element) => { if (seen.has(element)) return; seen.add(element); const animation = element.animate(keyframes, timing); const lead = animations[0]; - if (lead === undefined) requestAnimationFrame((now) => (begun ??= now)); - else if ((lead.startTime ?? begun) !== null) animation.startTime = lead.startTime ?? begun; + if (lead !== undefined && (lead.startTime ?? begun) !== null) { + animation.startTime = lead.startTime ?? begun; + } animations.push(animation); }; - const held: Element[] = []; - const hold = (element: Element) => { - held.push(element); - holds.push(element.animate([keyframes[0]!, keyframes[0]!], { duration: HOLD_MS * 2 })); - }; - const release = () => { - for (const animation of holds) animation.cancel(); - holds.length = 0; - }; let timer: number | undefined; - /** Adopt replacements for a while from now, then let anything held show. */ + /** Stop watching for bb's elements in a while, and show anything held. */ const settle = (ms: number) => { window.clearTimeout(timer); timer = window.setTimeout(() => { @@ -222,37 +250,47 @@ export function playEntrance(kind: keyof typeof ENTRANCES): void { release(); }, ms); }; + const start = (now: number) => { + started = true; + const targets = [...holds.keys()].filter((element) => element.isConnected); + release(); + targets.forEach(adopt); + document.querySelectorAll(selector).forEach(adopt); + begun = now; + settle(ADOPT_MS); + }; + let cancelSettled = () => {}; const observer = new MutationObserver((records) => { - // Held: wait for the other page's sidebar to go, then start on whatever - // is there, new or kept. - if (holds.length > 0) { - if (document.querySelector(OTHER_PAGES_SIDEBAR) !== null) return; - release(); - for (const element of held) if (element.isConnected) adopt(element); - document.querySelectorAll(selector).forEach(adopt); - settle(ADOPT_MS); - return; - } + const added: Element[] = []; for (const record of records) { for (const node of Array.from(record.addedNodes)) { if (!(node instanceof Element)) continue; - if (node.matches(selector)) adopt(node); - else node.querySelectorAll(selector).forEach(adopt); + if (node.matches(selector)) added.push(node); + else added.push(...Array.from(node.querySelectorAll(selector))); } } + if (started) { + added.forEach(adopt); + return; + } + added.forEach(hold); + if (otherPage && document.querySelector(OTHER_PAGES_SIDEBAR) === null) { + otherPage = false; + cancelSettled = whenSettled(start); + } }); - const frame = requestAnimationFrame((now) => { - const otherPage = document.querySelector(OTHER_PAGES_SIDEBAR) !== null; - document.querySelectorAll(selector).forEach(otherPage ? hold : adopt); - // Drawn on this frame, so a replacement knows how far along it is. - if (!otherPage) begun = now; + const frame = requestAnimationFrame(() => { + otherPage = document.querySelector(OTHER_PAGES_SIDEBAR) !== null; + document.querySelectorAll(selector).forEach(hold); observer.observe(document.body, { childList: true, subtree: true }); // Counted from the first frame, since the render that blocks before it - // can last longer than the animation. - settle(otherPage ? HOLD_MS : ADOPT_MS); + // can last longer than the animation. Anything still held by then shows. + settle(HOLD_MS); + if (!otherPage) cancelSettled = whenSettled(start); }); playing.set(kind, () => { cancelAnimationFrame(frame); + cancelSettled(); window.clearTimeout(timer); observer.disconnect(); release(); From d863e08e7c3d4e4578245a6196db79d583bdd397 Mon Sep 17 00:00:00 2001 From: matthewdias Date: Sun, 4 Oct 2026 21:32:29 -0500 Subject: [PATCH 5/6] Slide the sidebar out leaving Threads, and in opening Settings Leaving Threads, the strip still collapses the sidebar instantly so the new page lays out once at full width, but the sidebar is now drawn over it where it was until bb has drawn that page, then slides away on the compositor. bb hides a collapsed sidebar and moves it a width left, so visibility is held and the slide translates back by that width. Opening Settings slides its sections in with the same entrance as the return to Threads, waiting for Settings' own sidebar rather than the thread list's. syncSidebar stopped running entrances every time it ran, including the second pass once bb has rendered the page, which cut the exit off before it started. It now stops them only on a real move. Co-Authored-By: Claude Opus 5.5 --- plugins/top-tabs/README.md | 5 +- plugins/top-tabs/components/TopTabs.tsx | 16 ++++-- plugins/top-tabs/lib/shell.ts | 71 ++++++++++++++++++++----- 3 files changed, 73 insertions(+), 19 deletions(-) diff --git a/plugins/top-tabs/README.md b/plugins/top-tabs/README.md index c89cad2..a96e995 100644 --- a/plugins/top-tabs/README.md +++ b/plugins/top-tabs/README.md @@ -248,8 +248,9 @@ instead of freezing partway or appearing half done, and the Threads tab's title moves with them. Coming back from a page with a sidebar of its own (Plugins, Skills, Settings), that sidebar and page stay out of sight until bb has the thread list ready, and the thread list is what slides in. -Leaving Threads is instant. Opening or closing the sidebar yourself still -slides as bb draws it. With reduced motion on, nothing animates. +Leaving Threads, the sidebar stays over the new page until bb has drawn it, +then slides away; opening Settings, its sections slide in the same way. +Opening or closing the sidebar yourself still slides as bb draws it. With reduced motion on, nothing animates. Above the thread list, the sidebar keeps bb's own navigation, unchanged. Its rows, drag-to-reorder, options menu, More, customize editor and diff --git a/plugins/top-tabs/components/TopTabs.tsx b/plugins/top-tabs/components/TopTabs.tsx index c00a07c..9798c46 100644 --- a/plugins/top-tabs/components/TopTabs.tsx +++ b/plugins/top-tabs/components/TopTabs.tsx @@ -34,6 +34,7 @@ import { isSidebarOpen, observeSidebar, playEntrance, + playSidebarExit, stopEntrances, toggleSidebar, } from "../lib/shell.ts"; @@ -255,7 +256,9 @@ export function TopTabs() { const before = previous.current; previous.current = next; // An entrance left running would carry on over the page being left for. - if (next !== THREADS) stopEntrances(); + // Only on a real move: this runs again once bb has rendered the page, + // and the exit started on the way out has to survive that. + if (next !== before && next !== THREADS) stopEntrances(); const { compact, collapseSidebar, inSplit } = live.current; if (compact || !collapseSidebar || inSplit) return; const sidebarOpen = isSidebarOpen(); @@ -272,10 +275,13 @@ export function TopTabs() { : { ...s, threadsSidebarOpen: step.threadsSidebarOpen }, ); if (step.action === null) return; - // Coming back to Threads slides the sidebar in, on the compositor; see - // playEntrance. Not on load, where there is nothing to come back from. - if (step.action === "expand" && next === THREADS && before !== undefined) { - playEntrance("sidebar"); + // The sidebar slides in coming back to Threads or opening Settings, and + // out leaving Threads, on the compositor; see playEntrance and + // playSidebarExit. Not on load, where there is nothing to move from. + if (before !== undefined) { + if (step.action === "expand" && next === THREADS) playEntrance("sidebar"); + if (step.action === "expand" && next === SETTINGS) playEntrance("sidebar", "page"); + if (step.action === "collapse" && before === THREADS) playSidebarExit(); } toggleSidebar({ instant: true }); }, []); diff --git a/plugins/top-tabs/lib/shell.ts b/plugins/top-tabs/lib/shell.ts index 8054d5f..5ae5a80 100644 --- a/plugins/top-tabs/lib/shell.ts +++ b/plugins/top-tabs/lib/shell.ts @@ -195,30 +195,36 @@ export function whenSettled(run: (now: number) => void): () => void { return () => cancelAnimationFrame(frame); } -const playing = new Map void>(); +const playing = new Map void>(); + +/** Whose sidebar an entrance is for: the thread list's, or a page's own. */ +export type SidebarOwner = "threads" | "page"; /** * Play one entrance, once bb has drawn what it is for. * * Until then the elements are held at the entrance's first keyframe, out of - * sight: through bb's render of the thread, which would otherwise spend or - * stall the motion (see whenSettled), and, on the way back from a page with - * its own sidebar, until that sidebar has left — what is on screen at first - * is that page's sidebar and content, still up while bb renders the thread. - * The entrance then starts on what is there, new or kept. + * sight: through bb's render, which would otherwise spend or stall the + * motion (see whenSettled), and until the sidebar on screen is `into`'s. + * Going back to Threads from a page with its own sidebar, what is up at + * first is that page's sidebar and content, still there while bb renders + * the thread; going to Settings, it is the thread list. The entrance then + * starts on what is there, new or kept. * * Once it is playing, anything bb mounts in place of its elements takes the * running animation's start time and carries on rather than starting over. */ -export function playEntrance(kind: keyof typeof ENTRANCES): void { +export function playEntrance(kind: keyof typeof ENTRANCES, into: SidebarOwner = "threads"): void { playing.get(kind)?.(); + if (kind === "sidebar") playing.get("sidebarExit")?.(); if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; const { selector, keyframes, timing } = ENTRANCES[kind]; const holds = new Map(); const animations: Animation[] = []; const seen = new WeakSet(); let started = false; - let otherPage = false; + let waiting = false; + const wrongSidebar = () => (document.querySelector(OTHER_PAGES_SIDEBAR) !== null) !== (into === "page"); // When the entrance was first drawn. A replacement mounts after that, so // it takes the first animation's start time, or this while that is still // on its way to the compositor. @@ -274,19 +280,19 @@ export function playEntrance(kind: keyof typeof ENTRANCES): void { return; } added.forEach(hold); - if (otherPage && document.querySelector(OTHER_PAGES_SIDEBAR) === null) { - otherPage = false; + if (waiting && !wrongSidebar()) { + waiting = false; cancelSettled = whenSettled(start); } }); const frame = requestAnimationFrame(() => { - otherPage = document.querySelector(OTHER_PAGES_SIDEBAR) !== null; + waiting = wrongSidebar(); document.querySelectorAll(selector).forEach(hold); observer.observe(document.body, { childList: true, subtree: true }); // Counted from the first frame, since the render that blocks before it // can last longer than the animation. Anything still held by then shows. settle(HOLD_MS); - if (!otherPage) cancelSettled = whenSettled(start); + if (!waiting) cancelSettled = whenSettled(start); }); playing.set(kind, () => { cancelAnimationFrame(frame); @@ -299,6 +305,47 @@ export function playEntrance(kind: keyof typeof ENTRANCES): void { }); } +/** + * Slide the sidebar out as the strip collapses it on leaving Threads. Call + * it just before the collapse, while the sidebar is still there to find. + * + * The collapse is instant, so the page beneath lays out once at full width; + * the sidebar is drawn over it, where it was, until bb has drawn that page, + * then slides away. bb hides a collapsed sidebar and moves it a width to the + * left, so it is kept visible and translated back by that width. Only + * `translate` moves, on the compositor; visibility is held separately so it + * cannot stop that. If bb replaces the sidebar on the way — another page's + * sidebar, as on Plugins — it simply goes with the element. + */ +export function playSidebarExit(): void { + playing.get("sidebarExit")?.(); + playing.get("sidebar")?.(); + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + const panel = document.querySelector(ENTRANCES.sidebar.selector); + if (panel === null) return; + const visible = panel.animate([{ visibility: "visible" }, { visibility: "visible" }], { duration: HOLD_MS * 2 }); + let motion = panel.animate([{ translate: "100% 0" }, { translate: "100% 0" }], { duration: HOLD_MS * 2 }); + const stop = () => { + cancelSettled(); + window.clearTimeout(timer); + visible.cancel(); + motion.cancel(); + if (playing.get("sidebarExit") === stop) playing.delete("sidebarExit"); + }; + const cancelSettled = whenSettled(() => { + motion.cancel(); + if (!panel.isConnected) return stop(); + motion = panel.animate([{ translate: "100% 0" }, { translate: "0 0" }], { + duration: 160, + easing: "cubic-bezier(0.3, 0, 0.8, 0.15)", + }); + motion.finished.then(stop, () => {}); + }); + // However long bb takes, the sidebar doesn't stay over the page. + const timer = window.setTimeout(stop, HOLD_MS); + playing.set("sidebarExit", stop); +} + /** Stop every entrance now, for a switch away before one has finished. */ export function stopEntrances(): void { for (const stop of [...playing.values()]) stop(); From 092083a44bbd65e3b0f20478bd1001204c3cfeab Mon Sep 17 00:00:00 2001 From: matthewdias Date: Sun, 4 Oct 2026 21:45:21 -0500 Subject: [PATCH 6/6] Fade the new page in on leaving Threads and on Settings Leaving Threads, the destination appeared at once while the sidebar slid off it. It now fades in, as the thread does on the way back, and so does the page going to or from Settings, whose sidebar is its own. Switching between other tabs keeps the layout and stays instant. Both the fade and the sidebar's exit wait for the page being left to go (bb keeps its page shell and
and swaps what is inside), then for bb to finish drawing, so they start together and never fade the old page back in. Entrances now take that wait as a predicate. Co-Authored-By: Claude Opus 5.5 --- plugins/top-tabs/README.md | 4 +- plugins/top-tabs/components/TopTabs.tsx | 25 +++++++-- plugins/top-tabs/lib/shell.ts | 71 +++++++++++++++++++------ 3 files changed, 78 insertions(+), 22 deletions(-) diff --git a/plugins/top-tabs/README.md b/plugins/top-tabs/README.md index a96e995..5917e4f 100644 --- a/plugins/top-tabs/README.md +++ b/plugins/top-tabs/README.md @@ -249,7 +249,9 @@ title moves with them. Coming back from a page with a sidebar of its own (Plugins, Skills, Settings), that sidebar and page stay out of sight until bb has the thread list ready, and the thread list is what slides in. Leaving Threads, the sidebar stays over the new page until bb has drawn it, -then slides away; opening Settings, its sections slide in the same way. +then slides away as the page fades in; opening Settings, its sections slide +in the same way. Only these changes of layout move: going between other +tabs keeps the layout, and switches instantly, as a browser's tabs do. Opening or closing the sidebar yourself still slides as bb draws it. With reduced motion on, nothing animates. Above the thread list, the sidebar keeps bb's own navigation, unchanged. diff --git a/plugins/top-tabs/components/TopTabs.tsx b/plugins/top-tabs/components/TopTabs.tsx index 9798c46..0a7dacd 100644 --- a/plugins/top-tabs/components/TopTabs.tsx +++ b/plugins/top-tabs/components/TopTabs.tsx @@ -33,10 +33,13 @@ import { interceptPageClose, isSidebarOpen, observeSidebar, + pageGone, playEntrance, playSidebarExit, + sidebarOf, stopEntrances, toggleSidebar, + type Ready, } from "../lib/shell.ts"; import { useBridgedSplits } from "../lib/split-bridge.ts"; import { getState, initStore, update, useTabsState } from "../lib/store.ts"; @@ -250,9 +253,10 @@ export function TopTabs() { * instead of the page rendering at one width and then reflowing to * another; navigation from anywhere else is caught by the effect below. * Either way it runs once per move, because it records `next` as where - * the strip now is. + * the strip now is. `pageLeft`, from a tab click, holds the sidebar's exit + * until the page being left has gone. */ - const syncSidebar = useCallback((next: TabId | null) => { + const syncSidebar = useCallback((next: TabId | null, pageLeft?: Ready) => { const before = previous.current; previous.current = next; // An entrance left running would carry on over the page being left for. @@ -280,8 +284,8 @@ export function TopTabs() { // playSidebarExit. Not on load, where there is nothing to move from. if (before !== undefined) { if (step.action === "expand" && next === THREADS) playEntrance("sidebar"); - if (step.action === "expand" && next === SETTINGS) playEntrance("sidebar", "page"); - if (step.action === "collapse" && before === THREADS) playSidebarExit(); + if (step.action === "expand" && next === SETTINGS) playEntrance("sidebar", sidebarOf("page")); + if (step.action === "collapse" && before === THREADS) playSidebarExit(pageLeft); } toggleSidebar({ instant: true }); }, []); @@ -354,7 +358,18 @@ export function TopTabs() { const item = byId.get(id); if (id === active || item === undefined) return; markMove(); - syncSidebar(id); + // The page being left, before navigating away from it: the sidebar's + // exit and the new page's entrance both wait for it to go. + const pageLeft = pageGone(); + syncSidebar(id, pageLeft); + // A change of layout fades the new page in, as the return to Threads + // does: leaving Threads, and going to or from Settings, whose sidebar is + // its own. Tab to tab keeps the layout and switches instantly, as a + // browser's tabs do. It waits for the page being left to go, so it never + // fades that one back in. + if (!live.current.inSplit && (active === THREADS || active === SETTINGS || id === SETTINGS)) { + playEntrance("page", pageLeft); + } update((s) => adopt(s, id)); if (saved !== undefined && navigateToPath(saved)) return; // Settings is the strip's own entry; bb's actions do not know it. diff --git a/plugins/top-tabs/lib/shell.ts b/plugins/top-tabs/lib/shell.ts index 5ae5a80..d780b32 100644 --- a/plugins/top-tabs/lib/shell.ts +++ b/plugins/top-tabs/lib/shell.ts @@ -195,26 +195,65 @@ export function whenSettled(run: (now: number) => void): () => void { return () => cancelAnimationFrame(frame); } +/** + * Run `run` once `ready` says yes and the browser has caught up with bb's + * render. Returns a cancel. + */ +function whenReady(ready: Ready, run: (now: number) => void): () => void { + let cancelSettled = () => {}; + if (ready()) { + cancelSettled = whenSettled(run); + return () => cancelSettled(); + } + const observer = new MutationObserver(() => { + if (!ready()) return; + observer.disconnect(); + cancelSettled = whenSettled(run); + }); + observer.observe(document.body, { childList: true, subtree: true }); + return () => { + observer.disconnect(); + cancelSettled(); + }; +} + const playing = new Map void>(); -/** Whose sidebar an entrance is for: the thread list's, or a page's own. */ -export type SidebarOwner = "threads" | "page"; +/** + * What an entrance waits for before it may start: until it says yes, its + * elements stay held out of sight. + */ +export type Ready = () => boolean; + +/** Ready once the sidebar on screen is the thread list's, or a page's own. */ +export function sidebarOf(owner: "threads" | "page"): Ready { + return () => (document.querySelector(OTHER_PAGES_SIDEBAR) !== null) === (owner === "page"); +} + +/** + * Ready once the page on screen now has gone. Call it before navigating. bb + * keeps its page shell and `
` across pages and swaps what is inside. + */ +export function pageGone(): Ready { + const page = document.querySelector(`${ENTRANCES.page.selector} > main > *`); + return () => page === null || !page.isConnected; +} /** * Play one entrance, once bb has drawn what it is for. * * Until then the elements are held at the entrance's first keyframe, out of - * sight: through bb's render, which would otherwise spend or stall the - * motion (see whenSettled), and until the sidebar on screen is `into`'s. - * Going back to Threads from a page with its own sidebar, what is up at - * first is that page's sidebar and content, still there while bb renders - * the thread; going to Settings, it is the thread list. The entrance then - * starts on what is there, new or kept. + * sight: until `ready`, and then through bb's render, which would otherwise + * spend or stall the motion (see whenSettled). By default it waits for the + * thread list: going back to Threads from a page with its own sidebar, what + * is up at first is that page's sidebar and content, still there while bb + * renders the thread. The entrance then starts on what is there, new or + * kept. * * Once it is playing, anything bb mounts in place of its elements takes the * running animation's start time and carries on rather than starting over. */ -export function playEntrance(kind: keyof typeof ENTRANCES, into: SidebarOwner = "threads"): void { +export function playEntrance(kind: keyof typeof ENTRANCES, ready: Ready = sidebarOf("threads")): void { playing.get(kind)?.(); if (kind === "sidebar") playing.get("sidebarExit")?.(); if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; @@ -224,7 +263,6 @@ export function playEntrance(kind: keyof typeof ENTRANCES, into: SidebarOwner = const seen = new WeakSet(); let started = false; let waiting = false; - const wrongSidebar = () => (document.querySelector(OTHER_PAGES_SIDEBAR) !== null) !== (into === "page"); // When the entrance was first drawn. A replacement mounts after that, so // it takes the first animation's start time, or this while that is still // on its way to the compositor. @@ -280,13 +318,13 @@ export function playEntrance(kind: keyof typeof ENTRANCES, into: SidebarOwner = return; } added.forEach(hold); - if (waiting && !wrongSidebar()) { + if (waiting && ready()) { waiting = false; cancelSettled = whenSettled(start); } }); const frame = requestAnimationFrame(() => { - waiting = wrongSidebar(); + waiting = !ready(); document.querySelectorAll(selector).forEach(hold); observer.observe(document.body, { childList: true, subtree: true }); // Counted from the first frame, since the render that blocks before it @@ -310,14 +348,15 @@ export function playEntrance(kind: keyof typeof ENTRANCES, into: SidebarOwner = * it just before the collapse, while the sidebar is still there to find. * * The collapse is instant, so the page beneath lays out once at full width; - * the sidebar is drawn over it, where it was, until bb has drawn that page, - * then slides away. bb hides a collapsed sidebar and moves it a width to the + * the sidebar is drawn over it, where it was, until `ready` and bb has drawn + * that page, then slides away. Pass the `pageGone()` the page's own entrance + * waits on, so the two move together. bb hides a collapsed sidebar and moves it a width to the * left, so it is kept visible and translated back by that width. Only * `translate` moves, on the compositor; visibility is held separately so it * cannot stop that. If bb replaces the sidebar on the way — another page's * sidebar, as on Plugins — it simply goes with the element. */ -export function playSidebarExit(): void { +export function playSidebarExit(ready: Ready = () => true): void { playing.get("sidebarExit")?.(); playing.get("sidebar")?.(); if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; @@ -332,7 +371,7 @@ export function playSidebarExit(): void { motion.cancel(); if (playing.get("sidebarExit") === stop) playing.delete("sidebarExit"); }; - const cancelSettled = whenSettled(() => { + const cancelSettled = whenReady(ready, () => { motion.cancel(); if (!panel.isConnected) return stop(); motion = panel.animate([{ translate: "100% 0" }, { translate: "0 0" }], {