diff --git a/plugins/stewards/app.tsx b/plugins/stewards/app.tsx index 0b64fe3..bbb17cf 100644 --- a/plugins/stewards/app.tsx +++ b/plugins/stewards/app.tsx @@ -10,6 +10,7 @@ // and whether their configuration is healthy, then every open steward and // where it stands. Steering stays with the project steward and the CLI. import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { flushSync } from "react-dom"; import { Markdown, ThreadTitle, @@ -63,10 +64,16 @@ function ago(at: number | null, now: number): string { return `${Math.round(s / 86_400)}d ago`; } +/** `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; refetch: () => void } { +function usePolled(load: () => Promise): { 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 + // do not all animate in at once (BB-91). + const [loaded, setLoaded] = useState(false); const mounted = useRef(true); // Only the latest call lands, so a slow answer for the last project does not replace this one's. const latest = useRef(0); @@ -75,14 +82,29 @@ function usePolled(load: () => Promise): { data: T | null; error: string | load().then( (o) => { if (!mounted.current || call !== latest.current) return; - setData(o); - setError(null); + const commit = () => { + setData(o); + 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). + const start = (document as ViewTransitionDocument).startViewTransition; + if (typeof start !== "function") return commit(); + try { + start.call(document, () => flushSync(commit)); + } catch { + commit(); + } }, (e: unknown) => { if (mounted.current && call === latest.current) setError(message(e)); }, ); }, [load]); + // A paint after the first successful load, so rows that mount later animate. + useEffect(() => { + if (data !== null) setLoaded(true); + }, [data]); useEffect(() => { mounted.current = true; refetch(); @@ -99,7 +121,7 @@ function usePolled(load: () => Promise): { data: T | null; error: string | document.removeEventListener("visibilitychange", onVisibility); }; }, [refetch]); - return { data, error, refetch }; + return { data, error, loaded, refetch }; } function useOverview(): { overview: Overview | null; error: string | null } { @@ -1203,10 +1225,25 @@ function kindLabel(kind: string | null): string { /** The phrase's colour follows the glyph's (BB-76): yellow, red, blue, muted. */ function phraseClass(state: SidebarEffortState): string { - if (state === "you") return "text-amber-600 dark:text-amber-500"; - if (state === "blocked") return "text-destructive"; - if (state === "running") return "text-blue-600 dark:text-blue-400"; - return "text-muted-foreground"; + const transition = " motion-safe:transition-colors duration-300"; + if (state === "you") return `text-amber-600 dark:text-amber-500${transition}`; + if (state === "blocked") return `text-destructive${transition}`; + if (state === "running") return `text-blue-600 dark:text-blue-400${transition}`; + return `text-muted-foreground${transition}`; +} + +/** + * A CSS-safe `view-transition-name` for one row, from the surface, row kind and + * key (BB-91). The surface keeps the two sidebar instances (the pinned thread + * list and the board panel's own sidebar) apart, since both can be mounted at + * once and duplicate names skip the whole transition; the kind keeps an effort + * row and an ended row with the same binding key apart; the hash keeps two keys + * that sanitise alike apart. + */ +function viewTransitionName(surface: string, kind: "effort" | "ended" | "thread", key: string): string { + let hash = 0; + for (let i = 0; i < key.length; i += 1) hash = (Math.imul(hash, 31) + key.charCodeAt(i)) | 0; + return `stewards-${kind}-${surface}-${key.replace(/[^A-Za-z0-9_-]/gu, "-")}-${(hash >>> 0).toString(36)}`; } /** @@ -1216,9 +1253,10 @@ function phraseClass(state: SidebarEffortState): string { * CSS only and stops under `prefers-reduced-motion`. */ function StewardGlyph({ kind }: { kind: SidebarEffortState }) { - if (kind === "you") return