From dae1effc561796cd3bb72f9454e6cbabb9d2893a Mon Sep 17 00:00:00 2001 From: operator-os-explainer Date: Wed, 2 Sep 2026 04:13:53 -0700 Subject: [PATCH] feat: add canonical transport permalinks --- src/clock/SessionClockProvider.tsx | 63 ++++++- src/clock/useClockPermalink.test.tsx | 246 ++++++++++++++++++++++++++ src/clock/useClockPermalink.ts | 33 ++++ src/components/shell/ConsoleShell.tsx | 9 +- 4 files changed, 340 insertions(+), 11 deletions(-) create mode 100644 src/clock/useClockPermalink.test.tsx create mode 100644 src/clock/useClockPermalink.ts diff --git a/src/clock/SessionClockProvider.tsx b/src/clock/SessionClockProvider.tsx index 628aa7b..5441e0e 100644 --- a/src/clock/SessionClockProvider.tsx +++ b/src/clock/SessionClockProvider.tsx @@ -45,7 +45,14 @@ export interface SessionClock { play: () => void; pause: () => void; toggle: () => void; + /** Programmatic clock move (auto-seek, scene defaults): leaves the URL alone. */ scrub: (t: number) => void; + /** + * Reader-initiated positioning. Identical to `scrub` on the clock, but it + * also reports the landing position to `onReaderSeek` so a router-aware + * owner can make it the canonical, shareable position (SPEC 4.6). + */ + seek: (t: number) => void; setSpeed: (s: Speed) => void; stepBack: () => void; stepForward: () => void; @@ -80,7 +87,21 @@ export function isInteractiveShortcutTarget(target: EventTarget | null): boolean ); } -export function SessionClockProvider({ children }: { children: ReactNode }) { +export interface SessionClockProviderProps { + children: ReactNode; + /** + * Called with the landing position of every READER-initiated move โ€” the + * scrubber, the step buttons, the positioning shortcuts โ€” and never for + * playback frames or programmatic `scrub`. The clock stays router-agnostic; + * the owner decides that this position belongs in the URL. + */ + onReaderSeek?: (t: number) => void; +} + +export function SessionClockProvider({ + children, + onReaderSeek, +}: SessionClockProviderProps) { const duration = dataset.meta.sessionLengthMs; const [t, setT] = useState(0); const tRef = useRef(0); @@ -127,26 +148,48 @@ export function SessionClockProvider({ children }: { children: ReactNode }) { return () => cancelAnimationFrame(raf); }, [playing, speed, duration]); - const scrub = useCallback( + // Latest-ref so `seek` (and everything built on it) keeps a stable identity: + // the keydown listener and the context value must not churn when the owner's + // callback closes over a new location. + const readerSeekRef = useRef(onReaderSeek); + useEffect(() => { + readerSeekRef.current = onReaderSeek; + }); + + const applyPosition = useCallback( (next: number) => { const clamped = Math.min(duration, Math.max(0, next)); tRef.current = clamped; setT(clamped); + return clamped; }, [duration], ); + const scrub = useCallback( + (next: number) => { + applyPosition(next); + }, + [applyPosition], + ); + const seek = useCallback( + (next: number) => { + const clamped = applyPosition(next); + readerSeekRef.current?.(clamped); + }, + [applyPosition], + ); const play = useCallback(() => setPlaying(true), []); const pause = useCallback(() => setPlaying(false), []); const toggle = useCallback(() => setPlaying((p) => !p), []); const boundaries = useMemo(() => eventBoundaries(dataset.events), []); const stepBack = useCallback( - () => scrub(prevBoundary(boundaries, tRef.current)), - [boundaries, scrub], + () => seek(prevBoundary(boundaries, tRef.current)), + [boundaries, seek], ); const stepForward = useCallback( - () => scrub(nextBoundary(boundaries, tRef.current, duration)), - [boundaries, scrub, duration], + () => seek(nextBoundary(boundaries, tRef.current, duration)), + [boundaries, seek, duration], ); // ---- keyboard transport (SPEC 4.6): Space, arrows, Home/End ---- @@ -168,17 +211,17 @@ export function SessionClockProvider({ children }: { children: ReactNode }) { break; case "Home": e.preventDefault(); - scrub(0); + seek(0); break; case "End": e.preventDefault(); - scrub(duration); + seek(duration); break; } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [toggle, stepBack, stepForward, scrub, duration]); + }, [toggle, stepBack, stepForward, seek, duration]); const value = useMemo( () => ({ @@ -194,6 +237,7 @@ export function SessionClockProvider({ children }: { children: ReactNode }) { pause, toggle, scrub, + seek, setSpeed, stepBack, stepForward, @@ -214,6 +258,7 @@ export function SessionClockProvider({ children }: { children: ReactNode }) { pause, toggle, scrub, + seek, stepBack, stepForward, ], diff --git a/src/clock/useClockPermalink.test.tsx b/src/clock/useClockPermalink.test.tsx new file mode 100644 index 0000000..04ced6d --- /dev/null +++ b/src/clock/useClockPermalink.test.tsx @@ -0,0 +1,246 @@ +/** + * The `?t=` permalink contract: every reader transport action leaves a URL you + * can paste to somebody else, and nothing else touches the URL at all. + * + * These mount the REAL ConsoleShell (transport bar, global shortcuts, provider) + * over a memory router, because the whole point is the seam between the clock + * and the router. The scene is a probe rather than a storyboard scene only so + * the assertions stay deterministic: it still runs the real `useAutoSeek`. + */ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { createMemoryRouter } from "react-router"; +import { RouterProvider } from "react-router/dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ConsoleShell } from "../components/shell/ConsoleShell"; +import { useSessionClock } from "./SessionClockProvider.tsx"; +import { useAutoSeek } from "./useAutoSeek.ts"; + +function ProbeScene({ tStart }: { tStart: number }) { + useAutoSeek(tStart); + const clock = useSessionClock(); + return ( +
+

+ Probe +

+ {clock.t} + +
+ ); +} + +function renderConsole( + initialEntries: string[], + { tStart = 0, initialIndex }: { tStart?: number; initialIndex?: number } = {}, +) { + const router = createMemoryRouter( + [ + { + path: "/", + element: , + children: [ + { path: "probe", element: }, + { path: "other", element: }, + ], + }, + ], + { initialEntries, initialIndex }, + ); + return { router, ...render() }; +} + +const search = (router: { state: { location: { search: string } } }) => + router.state.location.search; +const timeParam = (router: { state: { location: { search: string } } }) => + new URLSearchParams(search(router)).get("t"); + +const scrubber = () => + screen.getByRole("slider", { name: "Session clock scrubber" }); + +function scrubTo(ms: number): void { + fireEvent.change(scrubber(), { target: { value: String(ms) } }); +} + +/** Hand-driven rAF so a "playing" clock advances a known number of frames. */ +function stubAnimationFrames(): { advance: (ms: number) => void } { + let pending: FrameRequestCallback[] = []; + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + pending.push(cb); + return pending.length; + }); + vi.stubGlobal("cancelAnimationFrame", () => {}); + let now = 0; + return { + advance(ms) { + now += ms; + const due = pending; + pending = []; + act(() => { + for (const cb of due) cb(now); + }); + }, + }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("transport permalinks", () => { + it("writes the scrubbed position to the route's t parameter", async () => { + const { router } = renderConsole(["/probe"]); + + scrubTo(5_000); + + await waitFor(() => expect(timeParam(router)).toBe("5000")); + // And the URL echo does not fight the clock back to somewhere else. + expect(screen.getByTestId("t")).toHaveTextContent("5000"); + }); + + it("writes the boundary chosen by the previous/next event buttons", async () => { + const { router } = renderConsole(["/probe"]); + + fireEvent.click(screen.getByRole("button", { name: "Step to next event" })); + await waitFor(() => expect(timeParam(router)).toBe("800")); + expect(screen.getByTestId("t")).toHaveTextContent("800"); + + fireEvent.click(screen.getByRole("button", { name: "Step to next event" })); + await waitFor(() => expect(timeParam(router)).toBe("1500")); + + fireEvent.click( + screen.getByRole("button", { name: "Step to previous event" }), + ); + await waitFor(() => expect(timeParam(router)).toBe("800")); + expect(screen.getByTestId("t")).toHaveTextContent("800"); + }); + + it.each([ + ["End", "90000"], + ["Home", "0"], + ["ArrowRight", "4500"], + ["ArrowLeft", "3500"], + ] as const)( + "writes the position reached by the %s shortcut", + async (key, expected) => { + const { router } = renderConsole(["/probe?t=4000"]); + await waitFor(() => + expect(screen.getByTestId("t")).toHaveTextContent("4000"), + ); + + fireEvent.keyDown(window, { key }); + + await waitFor(() => expect(timeParam(router)).toBe(expected)); + expect(screen.getByTestId("t")).toHaveTextContent(expected); + }, + ); + + it("carries unrelated parameters and the hash through the write", async () => { + const { router } = renderConsole([ + "/probe?chips=feature,sweep&rule=push-to-main#go-deeper", + ]); + + scrubTo(5_000); + + await waitFor(() => expect(timeParam(router)).toBe("5000")); + const params = new URLSearchParams(search(router)); + expect(params.get("chips")).toBe("feature,sweep"); + expect(params.get("rule")).toBe("push-to-main"); + expect(router.state.location.hash).toBe("#go-deeper"); + }); + + it("replaces rather than pushes, so Back skips the whole scrub trail", async () => { + const { router } = renderConsole(["/other", "/probe"], { initialIndex: 1 }); + + scrubTo(5_000); + await waitFor(() => expect(timeParam(router)).toBe("5000")); + scrubTo(7_000); + fireEvent.click(screen.getByRole("button", { name: "Step to next event" })); + fireEvent.keyDown(window, { key: "End" }); + await waitFor(() => expect(timeParam(router)).toBe("90000")); + expect(router.state.historyAction).toBe("REPLACE"); + + await act(async () => router.navigate(-1)); + + expect(router.state.location.pathname).toBe("/other"); + }); + + it("does not write an unchanged position twice", async () => { + const { router } = renderConsole(["/probe?t=0"]); + await waitFor(() => expect(screen.getByTestId("t")).toHaveTextContent("0")); + const key = router.state.location.key; + + // Already parked at 0: stepping back and pressing Home both land on 0. + fireEvent.click( + screen.getByRole("button", { name: "Step to previous event" }), + ); + fireEvent.keyDown(window, { key: "Home" }); + + expect(router.state.location.key).toBe(key); + expect(search(router)).toBe("?t=0"); + }); + + it("leaves the URL alone while playback runs", async () => { + const frames = stubAnimationFrames(); + const { router } = renderConsole(["/probe?t=1000"]); + await waitFor(() => + expect(screen.getByTestId("t")).toHaveTextContent("1000"), + ); + + fireEvent.click(screen.getByRole("button", { name: "Play" })); + frames.advance(0); + frames.advance(16); + frames.advance(16); + + expect(Number(screen.getByTestId("t").textContent)).toBeGreaterThan(1_000); + expect(search(router)).toBe("?t=1000"); + }); + + it("leaves the URL alone for route-entry auto-seek and scene-local seeks", async () => { + const { router } = renderConsole(["/probe"], { tStart: 6_000 }); + await waitFor(() => + expect(screen.getByTestId("t")).toHaveTextContent("6000"), + ); + expect(search(router)).toBe(""); + + fireEvent.click(screen.getByRole("button", { name: "scene-scrub" })); + + expect(screen.getByTestId("t")).toHaveTextContent("12000"); + expect(search(router)).toBe(""); + }); + + it("rehydrates a valid t and keeps the URL untouched", async () => { + const { router } = renderConsole(["/probe?t=1500"], { tStart: 6_000 }); + + await waitFor(() => + expect(screen.getByTestId("t")).toHaveTextContent("1500"), + ); + expect(search(router)).toBe("?t=1500"); + }); + + it.each(["/probe?t=", "/probe?t=nope", "/probe?t=-1", "/probe?t=90001"])( + "falls back to the scene start without rewriting %s", + async (entry) => { + const { router } = renderConsole([entry], { tStart: 6_000 }); + + await waitFor(() => + expect(screen.getByTestId("t")).toHaveTextContent("6000"), + ); + expect(search(router)).toBe(entry.slice(entry.indexOf("?"))); + }, + ); + + it("follows a same-route URL change without writing back", async () => { + const { router } = renderConsole(["/probe?t=1500"], { tStart: 6_000 }); + await waitFor(() => + expect(screen.getByTestId("t")).toHaveTextContent("1500"), + ); + + await act(async () => router.navigate("/probe?t=4200")); + + await waitFor(() => + expect(screen.getByTestId("t")).toHaveTextContent("4200"), + ); + expect(search(router)).toBe("?t=4200"); + expect(router.state.historyAction).toBe("PUSH"); + }); +}); diff --git a/src/clock/useClockPermalink.ts b/src/clock/useClockPermalink.ts new file mode 100644 index 0000000..9ea6468 --- /dev/null +++ b/src/clock/useClockPermalink.ts @@ -0,0 +1,33 @@ +import { useCallback } from "react"; +import { useLocation, useNavigate } from "react-router"; + +/** + * The write half of the `?t=` deep link that useAutoSeek reads (DATA-MODEL ยง5): + * it makes the reader's transport position canonical and shareable. + * + * Three rules make it safe to call on every reader action: + * - REPLACE, never push: scrubbing is looking around one page, not a trail of + * history entries the Back button has to unwind. + * - The rest of the URL is carried through untouched โ€” `?chips=`, `?rule=`, and + * any hash are other owners' state. + * - An unchanged `t` writes nothing, so a repeated action (stepping back at 0) + * raises no navigation at all. + * + * Nothing here reads the clock, so playback frames and programmatic seeks + * cannot reach the URL: only an explicit call can. + */ +export function useClockPermalink(): (t: number) => void { + const navigate = useNavigate(); + const { pathname, search, hash } = useLocation(); + + return useCallback( + (t: number) => { + const params = new URLSearchParams(search); + const next = String(Math.round(t)); + if (params.get("t") === next) return; + params.set("t", next); + navigate({ pathname, search: `?${params}`, hash }, { replace: true }); + }, + [navigate, pathname, search, hash], + ); +} diff --git a/src/components/shell/ConsoleShell.tsx b/src/components/shell/ConsoleShell.tsx index bc83b41..435e3bf 100644 --- a/src/components/shell/ConsoleShell.tsx +++ b/src/components/shell/ConsoleShell.tsx @@ -4,6 +4,7 @@ import { SessionClockProvider, useSessionClock, } from "../../clock/SessionClockProvider.tsx"; +import { useClockPermalink } from "../../clock/useClockPermalink.ts"; import { SyntheticBadge } from "./SyntheticBadge"; import { TransportBar } from "./TransportBar"; import { SCENES } from "../../scenes/index.ts"; @@ -23,10 +24,14 @@ const NAV = [ * ABOVE the outlet (SPEC 4.1), so the single rAF loop and clock state survive * scene navigation. The transport bar with its always-visible Pause * (SC 2.2.2) is part of the fixed chrome on every scene. + * + * This is also the router-aware boundary that owns the `?t=` permalink: the + * clock reports reader-initiated positions, the shell writes them to the URL. */ export function ConsoleShell() { + const writeClockParam = useClockPermalink(); return ( - + ); @@ -150,7 +155,7 @@ function ShellChrome() { playing={clock.playing} speed={clock.speed} onPlayPause={clock.toggle} - onScrub={clock.scrub} + onScrub={clock.seek} onSpeedChange={clock.setSpeed} onStepBack={clock.stepBack} onStepForward={clock.stepForward}