From c4bfd111005dff1a80fef36de565734bdf5f354a Mon Sep 17 00:00:00 2001 From: Dylan Trotter Date: Wed, 28 Oct 2026 01:27:50 +0000 Subject: [PATCH] stewards: animate sidebar rows only while the list is on screen (BB-91) A view transition hoists every element with a view-transition-name into the top layer for the crossfade, hidden or not. The host keeps the sidebar mounted when it is closed (on a phone the drawer sits under the page content, inert; on desktop the collapsed sidebar is inert or visibility: hidden), so every stewards poll that wrapped its commit in startViewTransition painted the sidebar's rows over the Stewards page and over thread pages for the length of the crossfade. Since BB-94 the list stays mounted on the Stewards page too, which is where it was first seen. usePolled now takes an optional check and starts a transition only when it passes; the sidebar passes "its root is on screen" (no inert ancestor, checkVisibility true), and the page and overview polls, which have no named rows, pass nothing and commit plainly. Co-Authored-By: Claude Fable 5.1 --- plugins/stewards/DESIGN.md | 13 ++++++ plugins/stewards/app.test.tsx | 18 ++++++++ plugins/stewards/app.tsx | 47 +++++++++++++++++--- plugins/stewards/sidebar-app.test.tsx | 63 +++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 7 deletions(-) diff --git a/plugins/stewards/DESIGN.md b/plugins/stewards/DESIGN.md index 173d719..be8d3bb 100644 --- a/plugins/stewards/DESIGN.md +++ b/plugins/stewards/DESIGN.md @@ -1608,6 +1608,19 @@ effort and follows that project, and clears both when the page unmounts. A project pick while the page is mounted moves it. A user who pinned bb's own list sees bb's list on the Stewards page rather than the stewards one. +**Row transitions only while the list is on screen (BB-91).** Each effort, +ended and thread row carries a `view-transition-name`, and the list's poll +wraps a data commit in `document.startViewTransition` so a row moving between +sections glides. A view transition hoists every named element in the document +into the top layer for the crossfade, hidden or not, and the host keeps the +sidebar mounted when it is closed: on a phone the drawer sits under the page +content and is `inert`, on desktop the collapsed sidebar is `inert` (rail) or +`visibility: hidden` (offcanvas). So the poll animates only when the list's +root is on screen (no `[inert]` ancestor and `checkVisibility` true), and the +page and overview polls, which have no named rows, never start one. Otherwise +the sidebar's rows flashed over the Stewards page and thread pages on a phone +for the length of the crossfade. + **Grouping is the steward relationship, then the thread tree.** A worker belongs to its binding through the plugin's `workers` table and a reviewer through the worker the RPC names, so the grouping survives a worker spawned diff --git a/plugins/stewards/app.test.tsx b/plugins/stewards/app.test.tsx index 1c0f4a2..ff1c95e 100644 --- a/plugins/stewards/app.test.tsx +++ b/plugins/stewards/app.test.tsx @@ -158,6 +158,24 @@ describe("the Stewards page's decide items and questions (BB-20)", () => { // BB-29: an approve-pr or merge card links the pull request separately, while // the entity key keeps opening the binding's ticket. +describe("the Stewards page's data commits (BB-91)", () => { + it("never starts a view transition of its own, since only the sidebar's rows are named", async () => { + const startViewTransition = vi.fn((update: () => void) => { + update(); + return { finished: Promise.resolve(), ready: Promise.resolve(), updateCallbackDone: Promise.resolve(), skipTransition: () => {} } as unknown as ViewTransition; + }); + (document as unknown as { startViewTransition: unknown }).startViewTransition = startViewTransition; + try { + const slot = await renderBoard([need({ choices: ["Merge"] })], []); + await waitFor(() => expect(slot.getByText("Merge")).toBeTruthy()); + expect(startViewTransition).not.toHaveBeenCalled(); + slot.lifecycle.unmount(); + } finally { + delete (document as unknown as { startViewTransition?: unknown }).startViewTransition; + } + }); +}); + describe("the Stewards page's need links (BB-29)", () => { const pr = "https://github.com/bitcomplete/bb-gate/pull/17"; const ticket = "https://linear.app/bit-complete/issue/BB-28"; diff --git a/plugins/stewards/app.tsx b/plugins/stewards/app.tsx index 3fee798..3ea8c13 100644 --- a/plugins/stewards/app.tsx +++ b/plugins/stewards/app.tsx @@ -68,8 +68,34 @@ function ago(at: number | null, now: number): string { /** `document.startViewTransition`, where the browser supports it (Chromium). */ type ViewTransitionDocument = Document & { startViewTransition?: (update: () => void) => unknown }; -/** What `load` returns, refetched every half minute while the page is visible, and on demand. */ -function usePolled(load: () => Promise): { data: T | null; error: string | null; loaded: boolean; refetch: () => void } { +/** `Element.checkVisibility`, where the browser supports it. */ +type CheckVisibilityElement = Element & { checkVisibility?: (options?: { visibilityProperty?: boolean }) => boolean }; + +/** + * Whether `el` is on screen for the user, so a view transition around it is + * worth starting (BB-91). The host keeps the sidebar mounted but hidden when + * it is closed: on a phone the drawer sits under the page content and is + * `inert`, and on desktop the collapsed sidebar is `inert` (rail) or + * `visibility: hidden` (offcanvas). A view transition hoists every element + * with a `view-transition-name` into the top layer for the crossfade, so one + * started while the list is hidden paints its rows over whatever page is + * showing. Without `checkVisibility` the element is taken as visible. + */ +function onScreen(el: Element | null): boolean { + if (el === null || !el.isConnected) return false; + if (el.closest("[inert]") !== null) return false; + const check = (el as CheckVisibilityElement).checkVisibility; + return typeof check === "function" ? check.call(el, { visibilityProperty: true }) : true; +} + +/** + * What `load` returns, refetched every half minute while the page is visible, + * and on demand. With `animate`, a commit that it approves is wrapped in a view + * transition so the browser animates rows moving between sections (BB-91); the + * caller passes a check that its animated surface is on screen, since a + * transition captures every named element in the document, hidden or not. + */ +function usePolled(load: () => Promise, animate?: () => boolean): { data: T | null; error: string | null; loaded: boolean; refetch: () => void } { const [data, setData] = useState(null); const [error, setError] = useState(null); // True once a load has been on screen for a paint, so the first load's rows @@ -88,9 +114,10 @@ function usePolled(load: () => Promise): { data: T | null; error: string | setError(null); }; // A view transition around the commit lets the browser animate a row - // moving between sections; without the API the commit is plain (BB-91). + // moving between sections; without the API, or when the caller's + // surface is off screen, the commit is plain (BB-91). const start = (document as ViewTransitionDocument).startViewTransition; - if (typeof start !== "function") return commit(); + if (typeof start !== "function" || animate === undefined || !animate()) return commit(); try { start.call(document, () => flushSync(commit)); } catch { @@ -101,7 +128,7 @@ function usePolled(load: () => Promise): { data: T | null; error: string | if (mounted.current && call === latest.current) setError(message(e)); }, ); - }, [load]); + }, [load, animate]); // A paint after the first successful load, so rows that mount later animate. useEffect(() => { if (data !== null) setLoaded(true); @@ -1736,7 +1763,13 @@ function StewardsSidebarCore({ }) { const rpc = useRpc(); const { status, threads, projects } = experimental_useSidebarThreads(); - const { data, error, loaded, refetch } = usePolled(useCallback(() => rpc.call("stewardsSidebar", null), [rpc])); + // The list's root, so a poll animates only while the list is on screen and + // not from under a closed drawer (BB-91). + const root = useRef(null); + const { data, error, loaded, refetch } = usePolled( + useCallback(() => rpc.call("stewardsSidebar", null), [rpc]), + useCallback(() => onScreen(root.current), []), + ); const tabs = useMemo(() => buildSidebar({ projects, threads, data: data?.projects ?? [], now: Date.now() }), [projects, threads, data]); const needsByProject = useMemo(() => new Map((data?.projects ?? []).map((p) => [p.projectId, p.needs] as const)), [data]); // The picker counts the yellow rows, so the pill and the sections cannot disagree (BB-76). @@ -1794,7 +1827,7 @@ function StewardsSidebarCore({ /> )); return ( -
+
{ slot.lifecycle.unmount(); }); + it("commits a data update plainly while the list is inside an inert ancestor, as under a closed mobile drawer", async () => { + const startViewTransition = vi.fn((update: () => void) => { + update(); + return { finished: Promise.resolve(), ready: Promise.resolve(), updateCallbackDone: Promise.resolve(), skipTransition: () => {} } as unknown as ViewTransition; + }); + (document as unknown as { startViewTransition: unknown }).startViewTransition = startViewTransition; + let calls = 0; + const slot = await renderSidebar({ + sidebarThreads: { projects, threads: [thread({ id: "thr_steward", displayTitle: "ENG-1: Fix login" })] }, + rpc: { + stewardsSidebar: () => { + calls += 1; + return sidebarData({ bindings: [binding({ phase: calls === 1 ? "In Progress" : "In Review" })] }); + }, + } as never, + }); + await waitFor(() => expect(calls).toBe(1)); + startViewTransition.mockClear(); + // The host marks the closed drawer `inert` and keeps the list mounted under the page. + slot.container.setAttribute("inert", ""); + await slot.emitRealtime(PROJECT_CHANGED, { projectId: "p1" }); + await waitFor(() => expect(calls).toBe(2)); + await waitFor(() => expect(lineTwo(slot, "Fix login")).toContain("In Review")); + expect(startViewTransition).not.toHaveBeenCalled(); + slot.container.removeAttribute("inert"); + slot.lifecycle.unmount(); + }); + + it("commits a data update plainly while the list is hidden by visibility, as in a collapsed desktop sidebar", async () => { + const startViewTransition = vi.fn((update: () => void) => { + update(); + return { finished: Promise.resolve(), ready: Promise.resolve(), updateCallbackDone: Promise.resolve(), skipTransition: () => {} } as unknown as ViewTransition; + }); + (document as unknown as { startViewTransition: unknown }).startViewTransition = startViewTransition; + const proto = Element.prototype as unknown as { checkVisibility?: unknown }; + const had = proto.checkVisibility; + let visible = true; + proto.checkVisibility = () => visible; + try { + let calls = 0; + const slot = await renderSidebar({ + sidebarThreads: { projects, threads: [thread({ id: "thr_steward", displayTitle: "ENG-1: Fix login" })] }, + rpc: { + stewardsSidebar: () => { + calls += 1; + return sidebarData({ bindings: [binding({ phase: calls === 1 ? "In Progress" : "In Review" })] }); + }, + } as never, + }); + await waitFor(() => expect(calls).toBe(1)); + startViewTransition.mockClear(); + visible = false; + await slot.emitRealtime(PROJECT_CHANGED, { projectId: "p1" }); + await waitFor(() => expect(calls).toBe(2)); + await waitFor(() => expect(lineTwo(slot, "Fix login")).toContain("In Review")); + expect(startViewTransition).not.toHaveBeenCalled(); + slot.lifecycle.unmount(); + } finally { + if (had === undefined) delete proto.checkVisibility; + else proto.checkVisibility = had; + } + }); + it("still commits a data update when the document has no view transition API", async () => { expect((document as unknown as { startViewTransition?: unknown }).startViewTransition).toBeUndefined(); let calls = 0;