merge: v1.1 navigation motion
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+84
-12
@@ -1,8 +1,8 @@
|
||||
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 { Suspense, use, useEffect, useLayoutEffect, useRef, useState, type ComponentType } from "react";
|
||||
import { createBrowserRouter, 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, LoadingLine, routeScroll } from "@stc/ui";
|
||||
import { webFeatures } from "./features";
|
||||
import { visibleNav } from "./nav";
|
||||
|
||||
@@ -25,7 +25,53 @@ function useSessionUser(): AuthUser | null {
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Scroll per history entry: top on a new navigation, the saved position on back/forward. */
|
||||
function useRouteScroll() {
|
||||
const loc = useLocation();
|
||||
const type = useNavigationType();
|
||||
useEffect(() => installRouteScroll(), []);
|
||||
useLayoutEffect(() => { routeScroll().routeChanged(loc.key, type === "POP", loc.hash); }, [loc.key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a navigation, the new page's heading (its first h1) is announced in a polite live region once it renders,
|
||||
* and takes focus (tabindex -1), unless the navigation came from the dock or the course outline: those controls
|
||||
* stay put, so focus stays on the one that was used (Enter on Next twice goes two lessons on). The first page load
|
||||
* keeps the browser's own focus.
|
||||
*/
|
||||
function useRouteFocus(): string {
|
||||
const loc = useLocation();
|
||||
const [said, setSaid] = useState("");
|
||||
const last = useRef<Element | null>(null);
|
||||
const first = useRef(true);
|
||||
useEffect(() => {
|
||||
const main = document.querySelector(".stc-main");
|
||||
if (!main) return;
|
||||
const initial = first.current;
|
||||
first.current = false;
|
||||
const take = () => {
|
||||
const h = main.querySelector<HTMLElement>("h1");
|
||||
if (!h || h === last.current) return false;
|
||||
last.current = h;
|
||||
if (initial) return true;
|
||||
setSaid(h.textContent ?? "");
|
||||
if (document.activeElement?.closest(".stc-dock, .lr-side")) return true;
|
||||
if (!h.hasAttribute("tabindex")) h.setAttribute("tabindex", "-1");
|
||||
h.focus({ preventScroll: true });
|
||||
return true;
|
||||
};
|
||||
if (take()) return;
|
||||
const mo = new MutationObserver(() => { if (take()) mo.disconnect(); });
|
||||
mo.observe(main, { childList: true, subtree: true });
|
||||
const stop = setTimeout(() => mo.disconnect(), 10_000);
|
||||
return () => { mo.disconnect(); clearTimeout(stop); };
|
||||
}, [loc.key]);
|
||||
return said;
|
||||
}
|
||||
|
||||
function Layout() {
|
||||
useRouteScroll();
|
||||
const said = useRouteFocus();
|
||||
// Links the user cannot use are hidden; the pages and APIs still enforce access themselves.
|
||||
const nav = visibleNav(webFeatures, useSessionUser());
|
||||
const header = (
|
||||
@@ -39,21 +85,47 @@ function Layout() {
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<AppShell header={header}>
|
||||
<Suspense fallback={null}><Outlet /></Suspense>
|
||||
</AppShell>
|
||||
<>
|
||||
<AppShell header={header}>
|
||||
<Suspense fallback={<LoadingLine />}><Outlet /></Suspense>
|
||||
</AppShell>
|
||||
<div className="sr" role="status" aria-live="polite" aria-atomic="true">{said}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const featureRoutes: RouteObject[] = webFeatures.flatMap((f) =>
|
||||
f.routes.map((r) => {
|
||||
const Element = lazy(r.element);
|
||||
return { path: r.path, element: <Element /> };
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* A route component loaded on first use, like React.lazy, but preloadable: once one route of a feature has
|
||||
* rendered, the feature's other routes load straight after. Moving between them (course page to lesson) then never
|
||||
* suspends, so the course sidebar is not dropped for a frame on the first visit.
|
||||
*/
|
||||
function lazyRoute(load: () => Promise<{ default: ComponentType }>, siblings: (() => unknown)[]) {
|
||||
let Loaded: ComponentType | null = null;
|
||||
let pending: Promise<unknown> | null = null;
|
||||
const preload = () => (pending ??= load().then((m) => { Loaded = m.default; }));
|
||||
function Route() {
|
||||
if (!Loaded) use(preload());
|
||||
useEffect(() => { siblings.forEach((p) => void p()); }, []);
|
||||
const C = Loaded!;
|
||||
return <C />;
|
||||
}
|
||||
return { Route, preload };
|
||||
}
|
||||
|
||||
const featureRoutes: RouteObject[] = webFeatures.flatMap((f) => {
|
||||
const siblings: (() => unknown)[] = [];
|
||||
return f.routes.map((r) => {
|
||||
const { Route, preload } = lazyRoute(r.element, siblings);
|
||||
siblings.push(preload);
|
||||
return { path: r.path, element: <Route /> };
|
||||
});
|
||||
});
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ element: <Layout />, children: [...featureRoutes, ...(featureRoutes.some((r) => r.path === "/") ? [] : [{ path: "*", element: <Home /> }])] },
|
||||
]);
|
||||
|
||||
// Navigation swaps at once (no View Transitions, no holding the old page, ruling R19); the new content region
|
||||
// plays its CSS entrance (@stc/ui tokens.css) and a loading line shows if code or data takes over 150 ms.
|
||||
createRoot(document.getElementById("root")!).render(<RouterProvider router={router} />);
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
// Navigation motion (v1.1 package M, ruling R19), checked in headless Chrome against the real server (every feature,
|
||||
// the production bundle) in a subprocess. Skipped without Chrome.
|
||||
//
|
||||
// Deterministic by construction: no fixed sub-second sleeps. A probe in the page records animationstart/end and
|
||||
// transitionstart events and counts any document.startViewTransition call; lesson fetches can be held behind a gate
|
||||
// (window.__gate / window.__release()); tests wait on conditions, and finish running animations through
|
||||
// document.getAnimations() where they need a settled page.
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { appendFileSync, cpSync, 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;
|
||||
|
||||
const PROBE = `
|
||||
window.__m = { vt: 0, anims: [], ends: [], 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("animationend", (e) => window.__m.ends.push(e.animationName + "@" + who(e)), true);
|
||||
document.addEventListener("transitionstart", (e) => window.__m.trans.push(e.propertyName + "@" + who(e)), true);
|
||||
// With window.__gate set, lesson requests wait until window.__release().
|
||||
const waiting = [];
|
||||
window.__release = () => { window.__gate = false; waiting.splice(0).forEach((r) => r()); return true; };
|
||||
const f = window.fetch;
|
||||
window.fetch = function (input, init) {
|
||||
const u = String((input && input.url) || input);
|
||||
if (window.__gate && u.includes("/lessons/")) return new Promise((r) => waiting.push(r)).then(() => f.call(this, input, init));
|
||||
return f.call(this, input, init);
|
||||
};
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Polls `f` until it returns something truthy. A stalled DevTools call counts as "not yet". */
|
||||
async function until<T>(f: () => Promise<T>, what: string, ms = 10_000): Promise<NonNullable<T>> {
|
||||
const end = Date.now() + ms;
|
||||
for (;;) {
|
||||
const v = await Promise.race([f(), Bun.sleep(3000).then(() => null)]).catch(() => null);
|
||||
if (v) return v as NonNullable<T>;
|
||||
if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
|
||||
await Bun.sleep(20);
|
||||
}
|
||||
}
|
||||
|
||||
const js = (s: string) => JSON.stringify(s);
|
||||
type Probe = { vt: number; anims: string[]; ends: string[]; trans: string[] };
|
||||
const m = () => chrome.eval<Probe>("window.__m");
|
||||
const reset = () => chrome.eval("(window.__m.vt = 0, window.__m.anims = [], window.__m.ends = [], 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())`);
|
||||
const path = () => chrome.eval<string>("location.pathname");
|
||||
const title = () => text(".lr-main .lr-lesson h1.title");
|
||||
/** Two animation frames: the page has rendered and dispatched its animation events. */
|
||||
const frames = () => chrome.eval("new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))");
|
||||
/** Jumps every finite running animation and transition to its end. */
|
||||
const finishAll = () => chrome.eval(`(document.getAnimations().forEach((a) => { try { a.finish(); } catch {} }), true)`);
|
||||
/** What runs now, as "name@class::pseudo" (the infinite loading sweep included). */
|
||||
const running = () => chrome.eval<string[]>(`document.getAnimations().map((a) => (a.transitionProperty || a.animationName) + "@" + (a.effect.target.getAttribute?.("class") || a.effect.target.tagName?.toLowerCase()) + (a.effect.pseudoElement || ""))`);
|
||||
const focusedH1 = () => chrome.eval<string | null>(`document.activeElement?.tagName === "H1" ? document.activeElement.textContent : null`);
|
||||
const announced = () => text('[aria-live="polite"].sr');
|
||||
|
||||
/** Full page load of `p`, waits for `ready`, settles every animation and clears the probe. */
|
||||
async function open(p: string, ready: string) {
|
||||
await chrome.goto(`${base}${p}`, 0);
|
||||
await until(() => chrome.eval<boolean>(`location.pathname === ${js(p)} && !!document.querySelector(${js(ready)})`), `${p} ${ready}`);
|
||||
await frames();
|
||||
await finishAll();
|
||||
await frames();
|
||||
await reset();
|
||||
}
|
||||
|
||||
async function key(k: string, code: number) {
|
||||
// Enter needs its text to activate a focused button or link.
|
||||
await chrome.send("Input.dispatchKeyEvent", { type: "keyDown", key: k, code: k, windowsVirtualKeyCode: code, ...(k === "Enter" ? { text: "\r" } : {}) });
|
||||
await chrome.send("Input.dispatchKeyEvent", { type: "keyUp", key: k, code: k, windowsVirtualKeyCode: code });
|
||||
}
|
||||
|
||||
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-"));
|
||||
// The sample course, with lessons long enough to scroll.
|
||||
const courses = join(dataDir, "courses");
|
||||
cpSync(join(ROOT, "fixtures/courses/sample"), join(courses, "sample"), { recursive: true });
|
||||
const filler = Array.from({ length: 40 }, (_, i) => `Paragraph ${i + 1}. ${"A line of reading text that wraps across the column. ".repeat(4)}`).join("\n\n");
|
||||
for (const f of ["01-intro.md", "03-code.md"]) appendFileSync(join(courses, "sample/lessons", f), `\n\n${filler}\n`);
|
||||
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: courses,
|
||||
STC_PUBLIC_URL: base,
|
||||
},
|
||||
});
|
||||
await until(async () => (await fetch(`${base}/api/health`)).ok, "server", 30_000);
|
||||
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: swaps at once, only the content region enters, focus and announcement follow", async () => {
|
||||
await open("/", 'a[href="/c/sample"]');
|
||||
await click('a[href="/c/sample"]');
|
||||
expect(await path()).toBe("/c/sample");
|
||||
await until(() => exists(".lr-main > article h1"), "course page");
|
||||
await until(async () => (await m()).anims.includes("stc-page@col"), "content entrance");
|
||||
await until(async () => (await focusedH1()) === "Sample course", "focus on the h1");
|
||||
await until(async () => (await announced()) === "Sample course", "announcement");
|
||||
const r = await m();
|
||||
expect(r.vt).toBe(0);
|
||||
expect(r.anims.filter((a) => /lr-side|stc-top|lr-shell/.test(a))).toEqual([]);
|
||||
}, 30_000);
|
||||
|
||||
test("course to lesson: the sidebar is never dropped, not even for a frame", async () => {
|
||||
await open("/c/sample", ".lr-main > article h1");
|
||||
const side = await rect(".lr-side");
|
||||
await chrome.eval(`(() => {
|
||||
window.__sideGone = false;
|
||||
const mo = new MutationObserver(() => { if (!document.querySelector(".lr-side")) window.__sideGone = true; });
|
||||
mo.observe(document.body, { childList: true, subtree: true });
|
||||
return true;
|
||||
})()`);
|
||||
await click('.lr-les[href="/c/sample/m1-intro"]');
|
||||
await until(async () => (await title()) === "Introduction", "lesson");
|
||||
await frames();
|
||||
expect(await chrome.eval<boolean>("window.__sideGone")).toBe(false);
|
||||
expect(await rect(".lr-side")).toBe(side);
|
||||
expect((await m()).anims).toContain("stc-page@lr lr-lesson");
|
||||
}, 30_000);
|
||||
|
||||
test("lesson to lesson: instant swap with a loading line; a second Next advances again; focus, scroll and back", async () => {
|
||||
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
|
||||
const side = await rect(".lr-side"), top = await rect(".stc-top");
|
||||
await chrome.eval("window.scrollTo(0, 900), true");
|
||||
await until(async () => (await chrome.eval<number>("scrollY")) === 900, "scrolled");
|
||||
await frames(); // the scroll event (which records the position) is dispatched on the next frame
|
||||
await chrome.eval("window.__gate = true");
|
||||
await click(".stc-dock .stc-pill--primary");
|
||||
// Swapped at once: the URL, the sidebar highlight, an empty busy column (never the old lesson), the top of the page.
|
||||
expect(await path()).toBe("/c/sample/m1-video");
|
||||
await frames();
|
||||
expect(await title()).toBe(null);
|
||||
expect(await exists('.lr-main > .col[aria-busy="true"]')).toBe(true);
|
||||
expect(await text('.lr-les[aria-current="page"]')).toContain("Watch the clip");
|
||||
expect(await chrome.eval<number>("scrollY")).toBe(0);
|
||||
await until(() => exists(".lr-main .stc-loading"), "loading line");
|
||||
// The dock is still there and acts on the lesson the URL names: a second Next goes on to the third lesson.
|
||||
await click(".stc-dock .stc-pill--primary");
|
||||
expect(await path()).toBe("/c/sample/m1-code");
|
||||
await chrome.eval("window.__release()");
|
||||
await until(async () => (await title()) === "Code along", "third lesson");
|
||||
await until(async () => (await m()).anims.includes("stc-page@lr lr-lesson"), "lesson entrance");
|
||||
expect(await exists(".lr-main .stc-loading")).toBe(false);
|
||||
// Focus was on <body> (a scripted click does not focus the button), so the new heading takes it.
|
||||
await until(async () => (await focusedH1()) === "Code along", "focus on the h1");
|
||||
await until(async () => (await announced()) === "Code along", "announcement");
|
||||
expect(await chrome.eval<boolean>(`getComputedStyle(document.activeElement).outlineStyle === "none"`)).toBe(true);
|
||||
expect(await rect(".lr-side")).toBe(side);
|
||||
expect(await rect(".stc-top")).toBe(top);
|
||||
expect((await m()).vt).toBe(0);
|
||||
|
||||
// Back twice: the intro returns where it was left.
|
||||
await chrome.eval("history.go(-2), true");
|
||||
await until(async () => (await title()) === "Introduction", "intro again");
|
||||
await until(async () => (await chrome.eval<number>("scrollY")) === 900, "restored scroll", 3000);
|
||||
await until(async () => (await focusedH1()) === "Introduction", "back/forward focuses the h1");
|
||||
}, 30_000);
|
||||
|
||||
test("keyboard on the dock: Enter on Next twice goes two lessons on, focus stays on Next, titles are announced", async () => {
|
||||
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
|
||||
const onNext = () => chrome.eval<boolean>(`document.activeElement === document.querySelector(".stc-dock .stc-pill--primary")`);
|
||||
await chrome.eval(`document.querySelector(".stc-dock .stc-pill--primary").focus(), true`);
|
||||
await key("Enter", 13);
|
||||
await until(async () => (await title()) === "Watch the clip", "second lesson");
|
||||
await until(async () => (await announced()) === "Watch the clip", "announcement");
|
||||
expect(await onNext()).toBe(true);
|
||||
await key("Enter", 13);
|
||||
await until(async () => (await title()) === "Code along", "third lesson");
|
||||
await until(async () => (await announced()) === "Code along", "announcement");
|
||||
expect(await onNext()).toBe(true);
|
||||
expect(await path()).toBe("/c/sample/m1-code");
|
||||
|
||||
// The outline keeps focus too: a sidebar link activated from the keyboard stays focused.
|
||||
await chrome.eval(`document.querySelector('.lr-les[href="/c/sample/m1-intro"]').focus(), true`);
|
||||
await key("Enter", 13);
|
||||
await until(async () => (await title()) === "Introduction", "intro");
|
||||
await until(async () => (await announced()) === "Introduction", "announcement");
|
||||
expect(await chrome.eval<boolean>(`document.activeElement === document.querySelector('.lr-les[href="/c/sample/m1-intro"]')`)).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
test("a click right after a navigation is handled", async () => {
|
||||
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
|
||||
await chrome.eval(`(() => {
|
||||
document.querySelector(".stc-dock .stc-pill--primary").click();
|
||||
setTimeout(() => document.querySelector('.lr-les[href="/c/sample/m1-code"]').click(), 0);
|
||||
return true;
|
||||
})()`);
|
||||
await until(async () => (await path()) === "/c/sample/m1-code", "second navigation");
|
||||
await until(async () => (await title()) === "Code along", "code lesson");
|
||||
// A real pointer click within the entrance animation lands too.
|
||||
await chrome.eval(`document.querySelector(".stc-dock .stc-pill--primary").click(), true`);
|
||||
const r = JSON.parse(await rect('.lr-les[href="/c/sample/m1-intro"]')) as { x: number; y: number; width: number; height: number };
|
||||
for (const type of ["mousePressed", "mouseReleased"]) await chrome.send("Input.dispatchMouseEvent", { type, x: r.x + r.width / 2, y: r.y + r.height / 2, button: "left", clickCount: 1 });
|
||||
await until(async () => (await path()) === "/c/sample/m1-intro", "pointer click during the entrance");
|
||||
}, 30_000);
|
||||
|
||||
test("a module opened by the user rises in; lists do not animate on page load or lesson change", async () => {
|
||||
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
|
||||
await click('.lr-les[href="/c/sample/m1-code"]');
|
||||
await until(async () => (await title()) === "Code along", "code lesson");
|
||||
await frames();
|
||||
expect((await m()).anims.filter((a) => a.includes("lr-lessons"))).toEqual([]);
|
||||
await reset();
|
||||
const head = '[...document.querySelectorAll(".lr-mod-head")][1]';
|
||||
expect(await chrome.eval<string>(`${head}.getAttribute("aria-expanded")`)).toBe("false");
|
||||
await chrome.eval(`${head}.click(), true`);
|
||||
expect(await chrome.eval<number>(`${head}.parentElement.querySelectorAll(".lr-lessons a").length`)).toBe(2);
|
||||
await until(async () => (await m()).anims.includes("stc-enter@lr-lessons"), "list entrance");
|
||||
}, 30_000);
|
||||
|
||||
test("folds open and close by animating their height", async () => {
|
||||
// Chrome animates ::details-content without listing it in getAnimations(), so sample the height each frame
|
||||
// until it settles at its end value.
|
||||
const sample = (open: boolean) => chrome.eval<number[]>(`(() => {
|
||||
const d = document.querySelector("details.fold.transcript");
|
||||
d.querySelector("summary").click();
|
||||
if (d.open !== ${open}) return [-1];
|
||||
const h = () => parseFloat(getComputedStyle(d, "::details-content").height);
|
||||
const out = [], t0 = performance.now();
|
||||
return new Promise((r) => { const f = () => {
|
||||
out.push(h());
|
||||
const settled = out.length > 2 && out.at(-1) === out.at(-2) && (${open} ? out.at(-1) > 0 : out.at(-1) === 0);
|
||||
if (settled || performance.now() - t0 > 3000) r(out); else requestAnimationFrame(f);
|
||||
}; f(); });
|
||||
})()`);
|
||||
await open("/c/sample/m1-video", "details.fold.transcript");
|
||||
const opening = await sample(true);
|
||||
const full = opening.at(-1)!;
|
||||
expect(full).toBeGreaterThan(20);
|
||||
expect(opening.some((h) => h > 1 && h < full - 1)).toBe(true);
|
||||
const closing = await sample(false);
|
||||
expect(closing.some((h) => h > 1 && h < full - 1)).toBe(true);
|
||||
expect(closing.at(-1)).toBe(0);
|
||||
}, 30_000);
|
||||
|
||||
test("slides cross-fade in the direction of travel and the stage never blanks", async () => {
|
||||
await open("/c/sample/m1-video", '.stage canvas[data-front][data-page="1"]');
|
||||
// Sample each frame from the click until the old slide's hold-out has ended (or 5 s).
|
||||
const sampled = await chrome.eval<{ minMax: number; frames: number }>(`(() => {
|
||||
document.querySelector('.deckbar [aria-label="Next slide"]').click();
|
||||
let minMax = 1, frames = 0;
|
||||
const t0 = performance.now();
|
||||
return new Promise((r) => { const f = () => {
|
||||
const cs = [...document.querySelectorAll(".stage canvas")];
|
||||
minMax = Math.min(minMax, Math.max(...cs.map((c) => Number(getComputedStyle(c).opacity))));
|
||||
frames++;
|
||||
if (window.__m.ends.some((e) => e.startsWith("stc-hold-out@")) || performance.now() - t0 > 5000) r({ minMax, frames }); else requestAnimationFrame(f);
|
||||
}; f(); });
|
||||
})()`);
|
||||
expect(sampled.minMax).toBe(1);
|
||||
const r = await m();
|
||||
expect(r.anims.some((a) => a.startsWith("stc-slide-next@"))).toBe(true);
|
||||
expect(r.ends.some((a) => a.startsWith("stc-hold-out@"))).toBe(true);
|
||||
expect(await text(".deckbar .count")).toBe("2 / 3");
|
||||
expect(await chrome.eval<number>(`document.querySelectorAll('.stage canvas[role="img"]').length`)).toBe(1);
|
||||
expect(await chrome.eval<string>(`document.querySelector('.stage canvas[data-front]').getAttribute("aria-label")`)).toBe("Slide 2 of 3");
|
||||
|
||||
await reset();
|
||||
await key("ArrowLeft", 37);
|
||||
await until(() => exists('.stage canvas[data-front][data-page="1"]'), "previous slide");
|
||||
await until(async () => (await m()).anims.some((a) => a.startsWith("stc-slide-prev@")), "mirrored motion");
|
||||
|
||||
// Two quick clicks: the second supersedes the first draw and the stage ends on the last slide.
|
||||
await chrome.eval(`(() => { const b = document.querySelector('.deckbar [aria-label="Next slide"]'); b.click(); setTimeout(() => b.click(), 0); return true; })()`);
|
||||
await until(() => exists('.stage canvas[data-front][data-page="3"]'), "slide 3 drawn");
|
||||
expect(await text(".deckbar .count")).toBe("3 / 3");
|
||||
}, 30_000);
|
||||
|
||||
test("progress fills, the toast and the course menu answer with motion; Escape still closes at once", async () => {
|
||||
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
|
||||
const status = await chrome.eval<number>(`fetch("/api/auth/sign-up/email", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email: "motion@example.org", name: "Motion", password: "correct horse battery" }) }).then((r) => r.status)`);
|
||||
expect(status).toBe(200);
|
||||
await open("/c/sample/m1-intro", ".stc-dock button[aria-pressed]:not([disabled])");
|
||||
await click(".stc-dock button[aria-pressed]");
|
||||
await until(async () => (await m()).trans.includes("width@i"), "progress fill transition");
|
||||
|
||||
await open("/c/sample/m2-ex-add", ".stc-seg button");
|
||||
await chrome.eval(`[...document.querySelectorAll(".stc-seg button")].find((b) => b.textContent === "Reading").click(), true`);
|
||||
await until(async () => (await m()).anims.includes("stc-enter@lr lr-toast"), "toast enter");
|
||||
await until(async () => (await m()).anims.includes("stc-fade-out@lr lr-toast is-leaving"), "toast leave", 6000);
|
||||
await until(async () => !(await exists(".lr-toast")), "toast removed", 3000);
|
||||
|
||||
await reset();
|
||||
await click(".lr-switch-btn");
|
||||
await until(async () => (await m()).anims.includes("stc-drop@lr-menu"), "menu enter");
|
||||
await key("Escape", 27);
|
||||
expect(await exists(".lr-menu")).toBe(false);
|
||||
}, 40_000);
|
||||
|
||||
test("prefers-reduced-motion: nothing animates, and everything still works", async () => {
|
||||
await setReducedMotion(true);
|
||||
const still = async (what: string) => {
|
||||
await frames();
|
||||
const r = await m();
|
||||
expect([what, r.vt, r.anims, r.trans, await running()]).toEqual([what, 0, [], [], []]);
|
||||
await reset();
|
||||
};
|
||||
try {
|
||||
await open("/", 'a[href="/c/sample"]');
|
||||
await click('a[href="/c/sample"]');
|
||||
await until(() => exists(".lr-main > article h1"), "course page");
|
||||
await still("library to course");
|
||||
|
||||
await click('.lr-les[href="/c/sample/m1-intro"]');
|
||||
await until(async () => (await title()) === "Introduction", "intro");
|
||||
await still("course to lesson");
|
||||
|
||||
// Next with the lesson held back: the loading line is there, and static.
|
||||
await chrome.eval("window.__gate = true");
|
||||
await click(".stc-dock .stc-pill--primary");
|
||||
await until(() => exists(".lr-main .stc-loading"), "loading line");
|
||||
await still("loading line");
|
||||
await chrome.eval("window.__release()");
|
||||
await until(async () => (await title()) === "Watch the clip", "next lesson");
|
||||
await until(async () => (await focusedH1()) === "Watch the clip", "focus");
|
||||
await still("lesson next");
|
||||
|
||||
await chrome.eval(`[...document.querySelectorAll(".lr-mod-head")][1].click(), true`);
|
||||
await still("module open");
|
||||
|
||||
const heights = await chrome.eval<number[]>(`(() => {
|
||||
const d = document.querySelector("details.fold.transcript");
|
||||
d.querySelector("summary").click();
|
||||
const out = [];
|
||||
return new Promise((r) => { const f = () => { out.push(parseFloat(getComputedStyle(d, "::details-content").height)); if (out.length >= 6) r(out); else requestAnimationFrame(f); }; f(); });
|
||||
})()`);
|
||||
expect(new Set(heights).size).toBe(1);
|
||||
await still("fold");
|
||||
|
||||
await until(() => exists('.stage canvas[data-front][data-page="1"]'), "deck");
|
||||
await click('.deckbar [aria-label="Next slide"]');
|
||||
await until(() => exists('.stage canvas[data-front][data-page="2"]'), "slide 2");
|
||||
await still("slide");
|
||||
|
||||
await click(".stc-dock button[aria-pressed]");
|
||||
await until(() => chrome.eval<boolean>(`document.querySelector(".stc-dock button[aria-pressed]").getAttribute("aria-pressed") === "true"`), "marked done");
|
||||
await still("progress");
|
||||
await click(".lr-switch-btn");
|
||||
await until(() => exists(".lr-menu"), "menu");
|
||||
await still("menu");
|
||||
await key("Escape", 27);
|
||||
await open("/c/sample/m2-ex-add", ".stc-seg button");
|
||||
await chrome.eval(`[...document.querySelectorAll(".stc-seg button")].find((b) => b.textContent === "Reading").click(), true`);
|
||||
await until(() => exists(".lr-toast"), "toast");
|
||||
await still("toast");
|
||||
await until(async () => !(await exists(".lr-toast")), "toast removed", 6000);
|
||||
expect((await m()).anims).toEqual([]);
|
||||
} finally {
|
||||
await setReducedMotion(false);
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -19,3 +19,4 @@ 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);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,3 +13,4 @@ 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))];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user