feat(web): view transitions on route changes; content enter animation

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 20:36:45 +01:00
co-authored by Claude Opus 5.5
parent 635fd66d04
commit d607938630
5 changed files with 165 additions and 4 deletions
+26 -4
View File
@@ -1,10 +1,10 @@
import { createRoot } from "react-dom/client";
import { lazy, Suspense, useEffect, useState } from "react";
import { createBrowserRouter, Link, NavLink, Outlet, RouterProvider, useLocation, type RouteObject } from "react-router-dom";
import { lazy, Suspense, useEffect, useLayoutEffect, useState } from "react";
import { createBrowserRouter, createPath, Link, NavLink, Outlet, RouterProvider, useLocation, useNavigationType, type RouteObject } from "react-router-dom";
import type { AuthUser } from "@stc/contracts";
import { AppShell } from "@stc/ui";
import { AppShell, installRouteScroll, reducedMotion, routeScroll } from "@stc/ui";
import { webFeatures } from "./features";
import { visibleNav } from "./nav";
import { changesPage, visibleNav } from "./nav";
function Home() {
return <p className="work">No features installed yet</p>;
@@ -25,7 +25,18 @@ function useSessionUser(): AuthUser | null {
return user;
}
/** Scroll per history entry: top on a new page, the saved position on back/forward (a page may defer it). */
function useRouteScroll() {
const loc = useLocation();
const type = useNavigationType();
useEffect(() => installRouteScroll(), []);
// A layout effect, so with a view transition the scroll is part of the captured new state. Pages' layout
// effects run before this one, so a page keeping its old content on screen can defer it first.
useLayoutEffect(() => { routeScroll().routeChanged(loc.key, type === "POP", loc.hash); }, [loc.key]);
}
function Layout() {
useRouteScroll();
// Links the user cannot use are hidden; the pages and APIs still enforce access themselves.
const nav = visibleNav(webFeatures, useSessionUser());
const header = (
@@ -56,4 +67,15 @@ const router = createBrowserRouter([
{ element: <Layout />, children: [...featureRoutes, ...(featureRoutes.some((r) => r.path === "/") ? [] : [{ path: "*", element: <Home /> }])] },
]);
// Navigating to another kind of page (library, course, lesson, account, studio…) runs as a view transition: the old
// page dissolves while the new one's content rises in (styles in @stc/ui tokens.css). Steps within a page (lesson
// to lesson) are animated by the page itself. Reduced motion and browsers without the API navigate plainly.
const routePatterns = featureRoutes.map((r) => ({ path: r.path }));
const plainNavigate = router.navigate.bind(router);
router.navigate = ((to: Parameters<typeof plainNavigate>[0], opts?: Parameters<typeof plainNavigate>[1]) => {
const animate = typeof to !== "number" && to !== null && "startViewTransition" in document && !reducedMotion()
&& changesPage(routePatterns, router.state.location.pathname, typeof to === "string" ? to : createPath(to));
return plainNavigate(to as never, animate ? { ...opts, viewTransition: opts?.viewTransition ?? true, flushSync: opts?.flushSync ?? true } : opts);
}) as typeof router.navigate;
createRoot(document.getElementById("root")!).render(<RouterProvider router={router} />);
+108
View File
@@ -0,0 +1,108 @@
// Navigation motion (v1.1 package M), checked in headless Chrome against the real server (every feature, the
// production bundle) in a subprocess. A probe in the page counts document.startViewTransition calls and records
// animationstart / transitionstart events, so each test can say exactly what moved. Skipped without Chrome.
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { Chrome, chromePath } from "../../../packages/feature-learner/test/chrome";
const ROOT = resolve(import.meta.dir, "../../..");
const run = chromePath ? describe : describe.skip;
/** Injected before any page script: counts view transitions and records animation and transition starts. */
const PROBE = `
window.__m = { vt: 0, anims: [], trans: [] };
const vt = Document.prototype.startViewTransition;
if (vt) Document.prototype.startViewTransition = function (...a) { window.__m.vt++; return vt.apply(this, a); };
const who = (e) => {
const t = e.target;
const cls = t && t.getAttribute ? (t.getAttribute("class") || t.tagName.toLowerCase()) : "?";
return cls + (e.pseudoElement || "");
};
document.addEventListener("animationstart", (e) => window.__m.anims.push(e.animationName + "@" + who(e)), true);
document.addEventListener("transitionstart", (e) => window.__m.trans.push(e.propertyName + "@" + who(e)), true);
`;
let server: Bun.Subprocess, chrome: Chrome, base = "", dataDir = "";
async function freePort(): Promise<number> {
const s = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("") });
const p = s.port!;
await s.stop(true);
return p;
}
export async function until<T>(f: () => Promise<T>, what: string, ms = 8000): Promise<NonNullable<T>> {
const end = Date.now() + ms;
for (;;) {
const v = await f().catch(() => null);
if (v) return v as NonNullable<T>;
if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
await Bun.sleep(25);
}
}
const js = (s: string) => JSON.stringify(s);
const m = () => chrome.eval<{ vt: number; anims: string[]; trans: string[] }>("window.__m");
const reset = () => chrome.eval("(window.__m.vt = 0, window.__m.anims = [], window.__m.trans = [], true)");
const exists = (sel: string) => chrome.eval<boolean>(`!!document.querySelector(${js(sel)})`);
const click = (sel: string) => chrome.eval(`document.querySelector(${js(sel)}).click(), true`);
const text = (sel: string) => chrome.eval<string | null>(`document.querySelector(${js(sel)})?.textContent ?? null`);
const rect = (sel: string) => chrome.eval<string>(`JSON.stringify(document.querySelector(${js(sel)}).getBoundingClientRect())`);
/** Full page load of `path`; waits for `ready` to exist. */
async function open(path: string, ready: string) {
await chrome.goto(`${base}${path}`, 50);
await until(() => chrome.eval<boolean>(`location.pathname === ${js(path)} && !!document.querySelector(${js(ready)})`), `${path} ${ready}`);
// Let the first-render animations finish so they do not count for the step under test.
await Bun.sleep(400);
await reset();
}
async function setReducedMotion(on: boolean) {
await chrome.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: on ? "reduce" : "no-preference" }] });
}
run("navigation motion (headless Chrome)", () => {
beforeAll(async () => {
const port = await freePort();
dataDir = mkdtempSync(join(tmpdir(), "stc-motion-"));
base = `http://localhost:${port}`;
server = Bun.spawn([process.execPath, "apps/server/src/main.ts"], {
cwd: ROOT, stdout: "pipe", stderr: "pipe",
env: {
...process.env, NODE_ENV: "production", STC_PORT: String(port), STC_SECRET: "motion-secret-0123456789abcdef0123456789",
STC_DATA_DIR: dataDir, STC_DATABASE_URL: `sqlite://${join(dataDir, "stc.db")}`, STC_COURSE_DIRS: join(ROOT, "fixtures/courses"),
STC_PUBLIC_URL: base,
},
});
await until(async () => (await fetch(`${base}/api/health`)).ok, "server", 30_000);
// The production bundle is built on the first request.
await until(async () => (await fetch(`${base}/`)).ok, "bundle", 30_000);
chrome = await Chrome.launch();
await chrome.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false });
await chrome.send("Page.addScriptToEvaluateOnNewDocument", { source: PROBE });
await setReducedMotion(false);
}, 60_000);
afterAll(async () => {
await chrome?.close();
server?.kill();
await server?.exited;
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
});
test("library to course: one view transition, the course content rises in", async () => {
await open("/", 'a[href="/c/sample"]');
await click('a[href="/c/sample"]');
// The URL changes at once: motion never holds navigation back.
expect(await chrome.eval<string>("location.pathname")).toBe("/c/sample");
await until(() => exists(".lr-main > article h1"), "course page");
await Bun.sleep(300);
const r = await m();
expect(r.vt).toBe(1);
expect(r.anims).toContain("stc-enter@col");
expect(r.anims.some((a) => a.startsWith("stc-fade-out@") && a.includes("::view-transition-old(root)"))).toBe(true);
}, 30_000);
});
+13
View File
@@ -19,3 +19,16 @@ test("features.ts imports only browser entries, by package name", () => {
for (const s of specs) expect(s === "@stc/contracts" || /^@stc\/feature-[a-z]+\/web$/.test(s)).toBe(true);
expect(specs.filter((s) => s.endsWith("/web")).length).toBe(4);
});
test("changesPage: a view transition for a new kind of page, none between steps of the same page", async () => {
const { changesPage } = await import("./nav");
const routes = webFeatures.flatMap((f) => f.routes.map((r) => ({ path: r.path })));
expect(changesPage(routes, "/", "/c/sample")).toBe(true);
expect(changesPage(routes, "/c/sample", "/c/sample/m1-intro")).toBe(true);
expect(changesPage(routes, "/c/sample/m1-intro", "/c/sample/notes")).toBe(true);
expect(changesPage(routes, "/c/sample/m1-intro", "/c/sample/m1-code")).toBe(false);
expect(changesPage(routes, "/c/sample/m1-intro", "/c/other/x?t=3")).toBe(false);
expect(changesPage(routes, "/c/sample/m1-video", "/c/sample/m1-video?deck=d1&slide=2")).toBe(false);
expect(changesPage(routes, "/c/a%20b/x", "/c/a%20b/y")).toBe(false);
expect(changesPage(routes, "/c/sample", "/nowhere/at/all/deep")).toBe(true);
});
+13
View File
@@ -1,3 +1,4 @@
import { matchRoutes } from "react-router-dom";
import type { AuthUser, Role, WebFeature, WebRoute } from "@stc/contracts";
/** Admin-area links default to the owner; header links default to everyone. `nav.roles` overrides both. */
@@ -13,3 +14,15 @@ export function visibleNav(features: WebFeature[], user: Pick<AuthUser, "roles">
};
return [...all.filter((r) => r.nav!.area === "header" && allowed(r)), ...all.filter((r) => r.nav!.area === "admin" && allowed(r))];
}
/**
* Whether navigating from `fromPath` to `to` shows another kind of page (another route pattern), which gets a
* view transition. Steps within one page (lesson to lesson, a query change) do not: the page animates them itself.
*/
export function changesPage(routes: { path?: string }[], fromPath: string, to: string): boolean {
const pattern = (path: string) => matchRoutes(routes, path)?.at(-1)?.route.path ?? null;
let toPath: string;
try { toPath = new URL(to, `http://x${fromPath}`).pathname; } catch { return true; }
const a = pattern(fromPath), b = pattern(toPath);
return a === null || b === null || a !== b;
}
@@ -331,6 +331,11 @@ export const learnerCss = String.raw`
.lr-toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 60; background: var(--raised); border: 1px solid var(--line2); border-radius: 18px; padding: 9px 16px; font-size: 13px; color: var(--text); }
/* ---------- motion ----------
New content rises in when it arrives (a page, a lesson, the library); the header, sidebar and dock stay put.
With view transitions the old page dissolves over it (tokens.css); without them this is the whole effect. */
.lr-main .lr-lesson, .lr-main > article, .stc-main > .lr > article, .lr-lib { animation: stc-enter var(--t-enter) var(--ease-out) backwards; }
/* ---------- responsive ---------- */
@media (max-width: 1100px) {
.lr .ex { grid-template-columns: minmax(0, 1fr); }