diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 12bec53..684ef00 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -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
No features installed yet
;
@@ -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: , children: [...featureRoutes, ...(featureRoutes.some((r) => r.path === "/") ? [] : [{ path: "*", element: }])] },
]);
+// 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[0], opts?: Parameters[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();
diff --git a/apps/web/src/motion.e2e.test.ts b/apps/web/src/motion.e2e.test.ts
new file mode 100644
index 0000000..8eafe6c
--- /dev/null
+++ b/apps/web/src/motion.e2e.test.ts
@@ -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 {
+ 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(f: () => Promise, what: string, ms = 8000): Promise> {
+ const end = Date.now() + ms;
+ for (;;) {
+ const v = await f().catch(() => null);
+ if (v) return v as NonNullable;
+ 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(`!!document.querySelector(${js(sel)})`);
+const click = (sel: string) => chrome.eval(`document.querySelector(${js(sel)}).click(), true`);
+const text = (sel: string) => chrome.eval(`document.querySelector(${js(sel)})?.textContent ?? null`);
+const rect = (sel: string) => chrome.eval(`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(`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("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);
+});
diff --git a/apps/web/src/nav.test.ts b/apps/web/src/nav.test.ts
index 3e3a19f..5abf80d 100644
--- a/apps/web/src/nav.test.ts
+++ b/apps/web/src/nav.test.ts
@@ -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);
+});
diff --git a/apps/web/src/nav.ts b/apps/web/src/nav.ts
index 3364a07..39343fe 100644
--- a/apps/web/src/nav.ts
+++ b/apps/web/src/nav.ts
@@ -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
};
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;
+}
diff --git a/packages/feature-learner/src/web/styles.ts b/packages/feature-learner/src/web/styles.ts
index 82df7ce..da80a03 100644
--- a/packages/feature-learner/src/web/styles.ts
+++ b/packages/feature-learner/src/web/styles.ts
@@ -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); }