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