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); }