merge: v1.1 navigation motion
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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: "[email protected]", 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))];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# v1.1 M — Navigation Motion Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** answer the user feedback "the navigation between courses, pages and steps could need some light animations and not switch as hard" with light, purposeful motion, without moving the header or sidebar and without slowing anything down.
|
||||
|
||||
**Architecture:**
|
||||
- **Motion tokens** in `@stc/ui` `tokens.css`: durations (`--t-exit` 120 ms, `--t-fast` 140 ms, `--t-enter` 180 ms, `--t-fold` 200 ms, `--t-bar` 220 ms), one ease-out curve, a 5 px rise, and the shared keyframes. They use only `opacity` and the `translate` property. One `prefers-reduced-motion: reduce` block turns off every animation, transition and view-transition pseudo-element.
|
||||
- **Motion helpers** in `@stc/ui` `motion.ts`:
|
||||
- `reducedMotion()`;
|
||||
- `viewTransition(update)`, which runs `update` inside `document.startViewTransition` + `flushSync` when that is supported and motion is allowed, and runs it directly otherwise;
|
||||
- `useHeldValue()`, which keeps the previous value on screen while the next one loads, then swaps inside a view transition.
|
||||
- **Scroll keeper** in `@stc/ui` `scroll.ts`. It saves the scroll position per history entry and applies it on route change: top for PUSH/REPLACE, the saved position for POP. A page that keeps old content on screen can defer this until it swaps.
|
||||
- **Shell (`apps/web`):**
|
||||
- When a navigation changes the matched route pattern (library → course, course → lesson, lesson → account…), the router's `navigate` is wrapped to add React Router's `viewTransition` + `flushSync` options.
|
||||
- The View Transition CSS keeps the header (`stc-top`) and course sidebar (`lr-side`) as their own, non-animated groups. The old page dissolves (120 ms) over the live new page.
|
||||
- Browsers without View Transitions get the CSS fallback: the new content's own enter animation.
|
||||
- **Lesson → lesson** has the same route pattern, so it gets no router transition. `LessonPage` holds the current lesson until the next one has loaded, then swaps inside a view transition and only then applies the scroll. The sidebar highlight moves immediately on click.
|
||||
- **Content enter:** lesson body, course overview, notes, library and account content fade in with a 5 px rise (180 ms) when they mount. The sidebar and dock are not re-animated.
|
||||
- **Smaller pieces:**
|
||||
- sidebar module lists fade/rise in when the user opens them;
|
||||
- `<details>` folds animate height with `interpolate-size` + `::details-content` where supported, and open instantly elsewhere;
|
||||
- the deck viewer double-buffers its canvas and cross-fades to the new slide with a 4 px shift in the direction of travel;
|
||||
- progress fills transition their width;
|
||||
- the toast fades/rises in and fades out;
|
||||
- the course menu and the mobile scrim fade in.
|
||||
|
||||
**Tech Stack:**
|
||||
- the View Transitions API through React Router 6.30's `viewTransition` navigation option;
|
||||
- CSS keyframes and transitions;
|
||||
- no new dependencies.
|
||||
|
||||
**Spec:** `docs/superpowers/plans/2026-10-30-v1-1.md` (Global Constraints, package M). Visual reference: `docs/design/mockups/platform-v1.html`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Everything in `CLAUDE.md` "Project rules" applies.
|
||||
- Motion:
|
||||
- durations 120–220 ms, ease-out, no bounce (cubic-bezier y values within [0, 1]), no scale-pop, no parallax;
|
||||
- only `opacity` and `translate` of at most 8 px in keyframes;
|
||||
- `prefers-reduced-motion: reduce` turns it all off, in CSS and in JS (no `startViewTransition` call);
|
||||
- motion never delays input or blocks navigation: `::view-transition { pointer-events: none }`, swaps happen only once data is there, and every update callback is synchronous.
|
||||
- No new colours or fonts. No new JS dependency.
|
||||
- `bun test`, `bun run typecheck` and the Postgres run (`STC_TEST_PG_URL=postgres://postgres:[email protected]:55433/postgres bun test`) stay green.
|
||||
- Dev server for manual checks: port 3491, `STC_DATA_DIR=/tmp/stc-v11-motion`, `STC_DATABASE_URL=sqlite:///tmp/stc-v11-motion/stc.db`. Never touch port 3480.
|
||||
|
||||
## File map
|
||||
|
||||
- `packages/ui/src/tokens.css`: motion tokens, keyframes, view-transition rules, reduced-motion block, progress fill transition.
|
||||
- `packages/ui/src/motion.ts` (new): `reducedMotion`, `viewTransition`, `useHeldValue`.
|
||||
- `packages/ui/src/scroll.ts` (new): `createScrollKeeper`, `routeScroll` (the window-bound instance).
|
||||
- `packages/ui/src/index.ts`: exports.
|
||||
- `packages/ui/test/motion-css.test.ts` (new): static motion lint over every stylesheet.
|
||||
- `packages/ui/test/motion.test.ts`, `packages/ui/test/scroll.test.ts` (new).
|
||||
- `apps/web/src/nav.ts`: `changesPage(routes, fromPath, to)`.
|
||||
- `apps/web/src/main.tsx`: navigate wrapper, scroll keeper wiring.
|
||||
- `apps/web/src/nav.test.ts`: `changesPage` cases.
|
||||
- `apps/web/src/motion.e2e.test.ts` (new): headless Chrome against the real server in a subprocess.
|
||||
- `packages/feature-learner/src/web/styles.ts`: enter animations, module list, folds, slides, toast, menu, scrim.
|
||||
- `packages/feature-learner/src/web/course/pages.tsx`: held lesson swap, deferred scroll.
|
||||
- `packages/feature-learner/src/web/shell/CourseFrame.tsx`: module list enter only on user toggle.
|
||||
- `packages/feature-learner/src/web/lesson/DeckViewer.tsx`: double-buffered canvas.
|
||||
- `packages/feature-learner/src/web/api.ts`, `shell/HeaderExtras.tsx`: toast leave phase.
|
||||
- `scripts/motion-frames.ts` (new): captures mid-transition frames from a running server (manual evidence).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Motion tokens, keyframes and the reduced-motion switch
|
||||
|
||||
**Files:** `packages/ui/src/tokens.css`, `packages/ui/test/motion-css.test.ts`
|
||||
|
||||
- [ ] Write `motion-css.test.ts`. It reads `tokens.css` and the learner stylesheet string (`learnerCss`) and checks:
|
||||
- (a) the tokens `--t-exit`, `--t-fast`, `--t-enter`, `--t-fold`, `--t-bar` exist and are within 120–220 ms;
|
||||
- (b) every duration used in an `animation`/`transition` declaration resolves (through the tokens) to 0 or 120–220 ms, except the pre-existing off-canvas drawer, which stays at 160 ms;
|
||||
- (c) every `@keyframes` block animates only `opacity` and `translate`, with each translate component ≤ 8 px;
|
||||
- (d) every `cubic-bezier()` has y values within [0, 1];
|
||||
- (e) a `prefers-reduced-motion: reduce` block in `tokens.css` sets `animation: none` and `transition: none` on `*` and on `::view-transition-*` pseudo-elements;
|
||||
- (f) `::view-transition` has `pointer-events: none`.
|
||||
- [ ] Run it and see it fail (no tokens yet).
|
||||
- [ ] Add the tokens (keep `--fast` as an alias of `--t-fast`), the keyframes (`stc-enter`, `stc-fade-in`, `stc-fade-out`, `stc-drop`, `stc-hold-out`, `stc-slide-next`, `stc-slide-prev`), the view-transition rules (header and sidebar groups static, old root dissolving over the new, `mix-blend-mode: normal`), the `.stc-bar > i` width transition and the reduced-motion block.
|
||||
- [ ] Run the test until it passes; run `bun run typecheck`. Commit `feat(ui): motion tokens, keyframes and reduced-motion switch`.
|
||||
|
||||
### Task 2: Motion helpers
|
||||
|
||||
**Files:** `packages/ui/src/motion.ts`, `packages/ui/src/index.ts`, `packages/ui/test/motion.test.ts`
|
||||
|
||||
- [ ] Tests, using a stub `document`/`matchMedia` on `globalThis`:
|
||||
- `reducedMotion()` follows `matchMedia('(prefers-reduced-motion: reduce)')` and is false without `window`;
|
||||
- `viewTransition(fn)` calls `startViewTransition` once and runs `fn` inside it when supported;
|
||||
- it runs `fn` directly when reduced motion is on, when `startViewTransition` is missing, or when the document is hidden;
|
||||
- `holdDecision(prev, next, keyOf, holdable)`, the pure core of `useHeldValue`, returns `show-next` / `hold` / `swap` / `replace` correctly.
|
||||
- [ ] See them fail. Implement them. See them pass. Commit `feat(ui): view-transition helper and held-value hook`.
|
||||
|
||||
### Task 3: Scroll keeper
|
||||
|
||||
**Files:** `packages/ui/src/scroll.ts`, `packages/ui/test/scroll.test.ts`
|
||||
|
||||
- [ ] Tests with a fake environment:
|
||||
- PUSH scrolls to 0;
|
||||
- POP restores the saved position of that key;
|
||||
- POP without a saved position goes to 0;
|
||||
- a hash scrolls its target into view;
|
||||
- positions are saved for the current key on `onScroll`;
|
||||
- `defer()` before `routeChanged` postpones the scroll until `settle()`, and keeps saving to the old key meanwhile;
|
||||
- a restore past the current page height retries until the page is tall enough (budget 1 s);
|
||||
- a newer `routeChanged` cancels a pending retry;
|
||||
- positions persist to `sessionStorage`.
|
||||
- [ ] See them fail. Implement them. See them pass. Commit `feat(ui): scroll keeper with deferred settle`.
|
||||
|
||||
### Task 4: Route-change transitions in the shell, plus the e2e harness
|
||||
|
||||
**Files:** `apps/web/src/nav.ts`, `apps/web/src/nav.test.ts`, `apps/web/src/main.tsx`, `apps/web/src/motion.e2e.test.ts`, `packages/feature-learner/src/web/styles.ts`
|
||||
|
||||
- [ ] `nav.test.ts`: `changesPage(routes, "/", "/c/sample")` is true; `/c/sample` → `/c/sample/m1-intro` is true; `/c/sample/m1-intro` → `/c/sample/m1-code` is false; a query-only change is false; an unknown path is true. See it fail; implement it with `matchRoutes`.
|
||||
- [ ] `motion.e2e.test.ts` (skipped without Chrome):
|
||||
- start `apps/server/src/main.ts` in a subprocess (production bundle, temp data dir, fixtures, a free fixed port so the CSRF origin matches);
|
||||
- open `/` in headless Chrome, with a probe that counts `document.startViewTransition` calls and records `animationstart` names;
|
||||
- test "library → course": clicking the sample course runs one view transition and the course article plays `stc-enter`.
|
||||
- [ ] See it fail. Wire `main.tsx`:
|
||||
- wrap `router.navigate` (number targets and same-pattern targets are untouched; motion is skipped when reduced);
|
||||
- install the scroll keeper (`routeChanged` in a layout effect keyed on `location.key`);
|
||||
- add the `view-transition-name`s;
|
||||
- add the learner enter rules (`.lr-lesson`, `.lr-main > article`, `.lr-lib`, account content).
|
||||
- [ ] See it pass. Commit `feat(web): view transitions on route changes; content enter animation`.
|
||||
|
||||
### Task 5: Lesson → lesson without a hard switch
|
||||
|
||||
**Files:** `packages/feature-learner/src/web/course/pages.tsx`, `apps/web/src/motion.e2e.test.ts`
|
||||
|
||||
- [ ] e2e, on `/c/sample/m1-intro`:
|
||||
- scroll down, then click Next in the dock;
|
||||
- the URL changes at once and the sidebar's `aria-current` moves at once;
|
||||
- the old lesson title stays until the new lesson renders (it is never blank);
|
||||
- one view transition runs;
|
||||
- `.lr-lesson` plays `stc-enter`;
|
||||
- the sidebar and header rects are identical before and after;
|
||||
- `scrollY` is 0 after the swap;
|
||||
- `history.back()` returns to the intro at the saved scroll position.
|
||||
- [ ] See it fail. Implement:
|
||||
- `useHeldValue` in `LessonPage`, holding only within the same course;
|
||||
- `routeScroll.defer()` while held and `settle()` after the swap (and on error);
|
||||
- remove the old `window.scrollTo(0, 0)`;
|
||||
- the dock's "Mark done" is disabled while the shown lesson is stale.
|
||||
- [ ] See it pass, and also the existing learner tests. Commit `feat(learner): hold the lesson until the next one loads, swap with a view transition`.
|
||||
|
||||
### Task 6: Sidebar modules and folds
|
||||
|
||||
**Files:** `packages/feature-learner/src/web/shell/CourseFrame.tsx`, `packages/feature-learner/src/web/styles.ts`, e2e
|
||||
|
||||
- [ ] e2e:
|
||||
- opening a collapsed module plays `stc-enter` on its list;
|
||||
- lists are not animated on page load or lesson change;
|
||||
- opening the transcript fold runs a `height` transition on `::details-content`;
|
||||
- closing it does too;
|
||||
- the fold content is reachable at once (`open` is set synchronously).
|
||||
- [ ] See it fail. Implement:
|
||||
- `data-enter` on lists opened by the user;
|
||||
- the `@supports selector(::details-content)` fold rules with `interpolate-size: allow-keywords`, the height and `content-visibility allow-discrete` transitions, and an opacity fade.
|
||||
- [ ] See it pass. Commit `feat(learner): module lists and folds open with motion`.
|
||||
|
||||
### Task 7: Deck slide changes
|
||||
|
||||
**Files:** `packages/feature-learner/src/web/lesson/DeckViewer.tsx`, `styles.ts`, e2e
|
||||
|
||||
- [ ] e2e on `/c/sample/m1-video` (the fixture deck):
|
||||
- Next slide flips the front canvas;
|
||||
- the new front plays `stc-slide-next`;
|
||||
- the old one plays `stc-hold-out`;
|
||||
- the stage is never blank: one canvas is fully opaque at every sampled frame;
|
||||
- the arrow keys still work.
|
||||
- [ ] See it fail. Implement:
|
||||
- two stacked canvases;
|
||||
- render into the back canvas, then flip `data-front` and `data-dir`;
|
||||
- a resize redraws the front without animation;
|
||||
- only the front has `role="img"`.
|
||||
- [ ] See it pass. Commit `feat(learner): cross-fade deck slides`.
|
||||
|
||||
### Task 8: Progress fills, toast, menu and scrim
|
||||
|
||||
**Files:** `packages/feature-learner/src/web/api.ts`, `shell/HeaderExtras.tsx`, `styles.ts`, e2e
|
||||
|
||||
- [ ] e2e (signed in through the API from the page):
|
||||
- Mark done runs a `width` transition on the sidebar progress fills;
|
||||
- the toast (choose Reading on an exercise) plays `stc-enter` and, when it leaves, `stc-fade-out` before it is removed;
|
||||
- opening the course switcher plays `stc-drop` on `.lr-menu`;
|
||||
- Escape still closes the menu immediately.
|
||||
- [ ] See it fail. Implement:
|
||||
- a toast store of `{ text, leaving }`, removed after `--t-exit`;
|
||||
- the CSS for the menu, scrim and toast.
|
||||
- [ ] See it pass. Commit `feat(learner): progress, toast and menu motion`.
|
||||
|
||||
### Task 9: Reduced motion turns it all off
|
||||
|
||||
**Files:** e2e
|
||||
|
||||
- [ ] e2e with `Emulation.setEmulatedMedia({ features: [{ name: "prefers-reduced-motion", value: "reduce" }] })`. It repeats the flows from Tasks 4–8: route change, lesson Next, module open, fold, slide, menu, toast. For each it checks `startViewTransition` calls = 0, `animationstart`/`transitionstart` events = 0 and `document.getAnimations().length` = 0. The behaviour is still correct: navigation, held swap, scroll and slide flip.
|
||||
- [ ] Fix anything that still moves. Commit `test(web): reduced motion disables all motion`.
|
||||
|
||||
### Task 10: Evidence and full verification
|
||||
|
||||
**Files:** `scripts/motion-frames.ts`
|
||||
|
||||
- [ ] `scripts/motion-frames.ts <baseUrl> <outDir>` drives Chrome over CDP:
|
||||
- slows animations with `Animation.setPlaybackRate`;
|
||||
- captures before/mid/after PNGs for library → course, course → lesson, lesson Next, module open, fold open, slide change, toast and menu;
|
||||
- captures one reduced-motion frame per flow.
|
||||
- [ ] Run it against the dev server on port 3491 (with `STC_DATA_DIR=/tmp/stc-v11-motion`). Look at every frame.
|
||||
- [ ] Run `bun test`, `bun run typecheck`, and the PG run. Check that `bun.lock` and the `package.json` dependencies are unchanged.
|
||||
- [ ] Write the report to `/home/user/STCourses/.superpowers/sdd/2026-10-30-stcourses/v11-motion-report.md`. Commit `chore: motion frame capture script`.
|
||||
|
||||
## Deviations found during execution
|
||||
|
||||
- **`flushSync` on route changes became `v7_startTransition`.** With `flushSync`, the first visit to a lazily loaded route captured an empty page, so the sidebar dropped out and popped back in. Router updates now run in a React transition: the current page stays up until the new route has rendered, then the view transition captures it.
|
||||
- **`::view-transition { pointer-events: none }` has no effect.** Chrome sends pointer input to the document element while a transition runs. Instead, each transition is kept to the old page's 120 ms fade, and the root group's default 250 ms morph is turned off. Keyboard input is unaffected.
|
||||
- **`*` does not match `::details-content`.** The reduced-motion block has a separate `*::details-content` rule.
|
||||
- **Chrome lists no `::details-content` animations in `getAnimations()` and sends no transition events for them.** The fold tests sample the computed height instead.
|
||||
- **Pre-existing bug: pdf.js 6 documents have no `destroy()`.** Leaving a lesson whose deck had loaded crashed the app. Fixed by destroying the loading task instead.
|
||||
|
||||
## Fix round 1 (ruling R19)
|
||||
|
||||
- Document-level View Transitions are gone, along with the lesson hold. Navigation now swaps at once, and only the content region enters: `stc-page`, opacity plus a 6 px rise in 160 ms.
|
||||
- When route code or data takes longer than 150 ms, a 2 px accent loading line (`LoadingLine`) shows at the top of the content region.
|
||||
- The dock's previous/next come from the course outline and the URL, so the dock works while the lesson loads.
|
||||
- After a navigation, focus moves to the new page's `h1`, and its text goes to a polite live region.
|
||||
- Route components are preloadable: once a feature's route renders, its sibling routes load, so the sidebar is never dropped on a first visit.
|
||||
- The scroll keeper loses defer/settle and gains `userInput()`.
|
||||
- The e2e tests are deterministic: they use a fetch gate and condition waits, and finish animations through `getAnimations()`.
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import type { AuthUser } from "@stc/contracts";
|
||||
import { reducedMotion } from "@stc/ui";
|
||||
import type { CoursePayload, LibraryPayload } from "../shared/types";
|
||||
import { learnerCss } from "./styles";
|
||||
|
||||
@@ -154,14 +155,22 @@ export function clearCourseCache(): void { courseCache.clear(); courseVersion.se
|
||||
|
||||
/* ---------- toast ---------- */
|
||||
|
||||
const toastStore = store<string | null>(null);
|
||||
export interface ToastState { text: string; leaving: boolean }
|
||||
const toastStore = store<ToastState | null>(null);
|
||||
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
/** How long the toast takes to fade out (--t-exit). */
|
||||
const TOAST_EXIT_MS = 120;
|
||||
export function toast(msg: string): void {
|
||||
toastStore.set(msg);
|
||||
toastStore.set({ text: msg, leaving: false });
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => toastStore.set(null), 2600);
|
||||
toastTimer = setTimeout(() => {
|
||||
const t = toastStore.get();
|
||||
if (!t || reducedMotion()) { toastStore.set(null); return; }
|
||||
toastStore.set({ ...t, leaving: true });
|
||||
toastTimer = setTimeout(() => toastStore.set(null), TOAST_EXIT_MS);
|
||||
}, 2600);
|
||||
}
|
||||
export function useToast(): string | null { return useStore(toastStore); }
|
||||
export function useToast(): ToastState | null { return useStore(toastStore); }
|
||||
|
||||
/* ---------- formatting ---------- */
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, type CSSProperties } from "react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { CourseCover, FloatingActionBar, Pill, ProgressBar } from "@stc/ui";
|
||||
import type { CoursePayload, LessonPayload, Note, ProgressState } from "../../shared/types";
|
||||
import { CourseCover, FloatingActionBar, LoadingLine, Pill, ProgressBar } from "@stc/ui";
|
||||
import type { CoursePayload, LessonPayload, Note, OutlineLesson, ProgressState } from "../../shared/types";
|
||||
import { api, ensureStyles, fmtMinutes, fmtTime, lessonHref, refreshLibrary, toast, useApi, useCourse, useLook, useMe, useTrack } from "../api";
|
||||
import { Check, Next, Prev } from "../icons";
|
||||
import { CourseFrame, courseDone, moduleNumber } from "../shell/CourseFrame";
|
||||
@@ -52,7 +52,7 @@ export function CoursePage() {
|
||||
const [look] = useLook();
|
||||
useEffect(() => { if (course) document.title = `${course.title} – STCourses`; }, [course]);
|
||||
if (error && !course) return <CourseMissing status={error.status} signedIn={!!user} />;
|
||||
if (!course) return null;
|
||||
if (!course) return <LoadingLine />;
|
||||
const { done, total } = courseDone(course);
|
||||
const next = nextLesson(course);
|
||||
const started = Object.keys(course.progress).length > 0;
|
||||
@@ -100,13 +100,22 @@ export function CoursePage() {
|
||||
|
||||
/* ---------- /c/:courseId/:lessonId ---------- */
|
||||
|
||||
/** The lessons before and after `lessonId` in course order (the same flat order the lesson API uses). */
|
||||
export function lessonNeighbours(c: CoursePayload, lessonId: string): { current: OutlineLesson | null; prev: OutlineLesson | null; next: OutlineLesson | null } {
|
||||
const flat = c.modules.flatMap((m) => m.lessons);
|
||||
const i = flat.findIndex((l) => l.id === lessonId);
|
||||
return i < 0 ? { current: null, prev: null, next: null } : { current: flat[i]!, prev: flat[i - 1] ?? null, next: flat[i + 1] ?? null };
|
||||
}
|
||||
|
||||
export function LessonPage() {
|
||||
const { courseId = "", lessonId = "" } = useParams();
|
||||
const loc = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { course, error: courseError, reload } = useCourse(courseId);
|
||||
const lessonApi = useApi<LessonPayload>(`/api/courses/${encodeURIComponent(courseId)}/lessons/${encodeURIComponent(lessonId)}`);
|
||||
const lesson = lessonApi.data;
|
||||
// Only the lesson the URL names is ever shown: while it loads the column is empty (a loading line after 150 ms),
|
||||
// never the previous lesson.
|
||||
const lesson = lessonApi.data && lessonApi.data.lesson.id === lessonId && lessonApi.data.courseId === courseId ? lessonApi.data : null;
|
||||
const { user } = useMe();
|
||||
const [look] = useLook();
|
||||
const [track, setTrack] = useTrack(courseId, course?.tracks ?? lesson?.tracks ?? []);
|
||||
@@ -117,7 +126,6 @@ export function LessonPage() {
|
||||
const signedIn = !!user;
|
||||
|
||||
useEffect(() => { if (lesson) document.title = `${lesson.lesson.title.replace(/`/g, "")} – ${lesson.courseTitle}`; }, [lesson]);
|
||||
useEffect(() => { window.scrollTo(0, 0); }, [lessonId]);
|
||||
|
||||
const setState = useCallback(async (state: ProgressState, positionSeconds?: number) => {
|
||||
if (!signedIn) return;
|
||||
@@ -128,37 +136,40 @@ export function LessonPage() {
|
||||
}, [courseId, lessonId, signedIn, progress?.state, reload]);
|
||||
|
||||
if (courseError && !course) return <CourseMissing status={courseError.status} signedIn={signedIn} />;
|
||||
if (lessonApi.error && course) {
|
||||
if (!course) return <LoadingLine />;
|
||||
if (lessonApi.error) {
|
||||
return (
|
||||
<CourseFrame course={course} currentLessonId={lessonId} look={look} lookMode={null}>
|
||||
<article className="col"><h1 className="title">Lesson not found</h1><p className="source">This course has no lesson “{lessonId}”.</p></article>
|
||||
</CourseFrame>
|
||||
);
|
||||
}
|
||||
if (!course || !lesson || lesson.lesson.id !== lessonId) {
|
||||
return course ? <CourseFrame course={course} currentLessonId={lessonId} look={look} lookMode={null}><div className="col" /></CourseFrame> : null;
|
||||
}
|
||||
// The dock works from the course outline and the URL, so it is there (and right) while the lesson loads:
|
||||
// a second Next goes on from the lesson the URL names.
|
||||
const { current, prev, next } = lessonNeighbours(course, lessonId);
|
||||
const isDone = progress?.state === "done";
|
||||
const isExercise = lesson.lesson.type === "exercise";
|
||||
const n = moduleNumber(course, lesson.module.index);
|
||||
const isExercise = (lesson?.lesson.type ?? current?.type) === "exercise";
|
||||
return (
|
||||
<CourseFrame course={course} currentLessonId={lessonId} look={look} lookMode={isExercise ? "work" : "toggle"}>
|
||||
<LessonView
|
||||
lesson={lesson} look={look} track={track} onTrackChange={setTrack} progress={progress} signedIn={signedIn}
|
||||
onProgress={setState} onSaved={() => { void reload(); void refreshLibrary(); }}
|
||||
initialTime={initialTime} initialSlide={initialSlide}
|
||||
moduleLabel={`${/^(section|part|chapter)\b/i.test(lesson.module.title) ? "" : "Module "}${n}`.trim()}
|
||||
/>
|
||||
{lesson ? (
|
||||
<LessonView
|
||||
key={lesson.lesson.id}
|
||||
lesson={lesson} look={look} track={track} onTrackChange={setTrack} progress={progress} signedIn={signedIn}
|
||||
onProgress={setState} onSaved={() => { void reload(); void refreshLibrary(); }}
|
||||
initialTime={initialTime} initialSlide={initialSlide}
|
||||
moduleLabel={`${/^(section|part|chapter)\b/i.test(lesson.module.title) ? "" : "Module "}${moduleNumber(course, lesson.module.index)}`.trim()}
|
||||
/>
|
||||
) : <div className="col" aria-busy="true"><LoadingLine /></div>}
|
||||
<FloatingActionBar>
|
||||
<Pill variant="ghost" disabled={!lesson.prev} title={lesson.prev ? `Previous: ${lesson.prev.title}` : "First lesson"} onClick={() => lesson.prev && navigate(lessonHref(courseId, lesson.prev.id))}>
|
||||
<Pill variant="ghost" disabled={!prev} title={prev ? `Previous: ${prev.title}` : "First lesson"} onClick={() => prev && navigate(lessonHref(courseId, prev.id))}>
|
||||
<Prev /><span className="sr">Previous lesson</span>
|
||||
</Pill>
|
||||
<Pill variant="ghost" aria-pressed={isDone} disabled={!signedIn || lesson.locked} title={signedIn ? undefined : "Sign in to keep your progress"}
|
||||
<Pill variant="ghost" aria-pressed={isDone} disabled={!signedIn || !lesson || lesson.locked} title={signedIn ? undefined : "Sign in to keep your progress"}
|
||||
onClick={() => void setState(isDone ? "not-started" : "done")}>
|
||||
<Check size={14} />{isDone ? "Done" : "Mark done"}
|
||||
</Pill>
|
||||
<span className="sep" aria-hidden="true" />
|
||||
<Pill variant="primary" disabled={!lesson.next} title={lesson.next ? `Next: ${lesson.next.title}` : "Last lesson"} onClick={() => lesson.next && navigate(lessonHref(courseId, lesson.next.id))}>
|
||||
<Pill variant="primary" disabled={!next} title={next ? `Next: ${next.title}` : "Last lesson"} onClick={() => next && navigate(lessonHref(courseId, next.id))}>
|
||||
<span>Next<span className="lbl-long"> lesson</span></span><Next />
|
||||
</Pill>
|
||||
</FloatingActionBar>
|
||||
@@ -176,7 +187,7 @@ export function NotesPage() {
|
||||
const notesApi = useApi<Note[]>(user ? `/api/courses/${encodeURIComponent(courseId)}/notes` : null);
|
||||
useEffect(() => { if (course) document.title = `Your notes – ${course.title}`; }, [course]);
|
||||
if (error && !course) return <CourseMissing status={error.status} signedIn={!!user} />;
|
||||
if (!course) return null;
|
||||
if (!course) return <LoadingLine />;
|
||||
const titles = new Map(course.modules.flatMap((m) => m.lessons.map((l) => [l.id, { title: l.title, module: m.title }] as const)));
|
||||
const order = new Map(course.modules.flatMap((m) => m.lessons).map((l, i) => [l.id, i]));
|
||||
const notes = notesApi.data ?? [];
|
||||
|
||||
@@ -16,20 +16,38 @@ function loadPdfjs() {
|
||||
return pdfjsPromise;
|
||||
}
|
||||
|
||||
/** Renders `page` of `doc` into `canvas` at `cssWidth` pixels wide (scaled for the device pixel ratio). */
|
||||
async function renderPage(doc: PdfDoc, page: number, canvas: HTMLCanvasElement, cssWidth: number, cssMaxHeight?: number) {
|
||||
const p = await doc.getPage(page);
|
||||
const base = p.getViewport({ scale: 1 });
|
||||
let scale = cssWidth / base.width;
|
||||
if (cssMaxHeight && base.height * scale > cssMaxHeight) scale = cssMaxHeight / base.height;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const vp = p.getViewport({ scale: scale * dpr });
|
||||
canvas.width = Math.floor(vp.width);
|
||||
canvas.height = Math.floor(vp.height);
|
||||
canvas.style.width = `${Math.floor(vp.width / dpr)}px`;
|
||||
canvas.style.height = `${Math.floor(vp.height / dpr)}px`;
|
||||
const task = p.render({ canvasContext: canvas.getContext("2d")!, viewport: vp, canvas });
|
||||
await task.promise;
|
||||
const jobs = new WeakMap<HTMLCanvasElement, { task?: { cancel(): void }; done: Promise<unknown> }>();
|
||||
const superseded = () => new Error("superseded");
|
||||
|
||||
/**
|
||||
* Renders `page` of `doc` into `canvas` at `cssWidth` pixels wide (scaled for the device pixel ratio). A newer
|
||||
* render into the same canvas cancels this one (its promise rejects), so pdf.js never draws one canvas twice at once.
|
||||
*/
|
||||
function renderPage(doc: PdfDoc, page: number, canvas: HTMLCanvasElement, cssWidth: number, cssMaxHeight?: number): Promise<void> {
|
||||
const prev = jobs.get(canvas);
|
||||
prev?.task?.cancel();
|
||||
const job: { task?: { cancel(): void }; done: Promise<unknown> } = { done: Promise.resolve() };
|
||||
const run = (async () => {
|
||||
await prev?.done;
|
||||
if (jobs.get(canvas) !== job) throw superseded();
|
||||
const p = await doc.getPage(page);
|
||||
if (jobs.get(canvas) !== job) throw superseded();
|
||||
const base = p.getViewport({ scale: 1 });
|
||||
let scale = cssWidth / base.width;
|
||||
if (cssMaxHeight && base.height * scale > cssMaxHeight) scale = cssMaxHeight / base.height;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const vp = p.getViewport({ scale: scale * dpr });
|
||||
canvas.width = Math.floor(vp.width);
|
||||
canvas.height = Math.floor(vp.height);
|
||||
canvas.style.width = `${Math.floor(vp.width / dpr)}px`;
|
||||
canvas.style.height = `${Math.floor(vp.height / dpr)}px`;
|
||||
const task = p.render({ canvasContext: canvas.getContext("2d")!, viewport: vp, canvas });
|
||||
job.task = task;
|
||||
await task.promise;
|
||||
})();
|
||||
job.done = run.catch(() => {});
|
||||
jobs.set(canvas, job);
|
||||
return run;
|
||||
}
|
||||
|
||||
const isTyping = (t: EventTarget | null) => !!(t as Element | null)?.closest?.("input, textarea, select, video, audio, [contenteditable], [role='slider']");
|
||||
@@ -46,32 +64,50 @@ export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, titl
|
||||
const [page, setPage] = useState(initialPage);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const stage = useRef<HTMLElement>(null);
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
// Two stacked canvases: the next slide is drawn into the hidden one, which then fades in over the current one
|
||||
// (shifted 4 px from the direction of travel), so the stage never blanks while pdf.js draws.
|
||||
const canvasA = useRef<HTMLCanvasElement>(null);
|
||||
const canvasB = useRef<HTMLCanvasElement>(null);
|
||||
const [view, setView] = useState<{ front: 0 | 1; page: number; dir: "next" | "prev" | null }>({ front: 0, page: 0, dir: null });
|
||||
const viewRef = useRef(view);
|
||||
viewRef.current = view;
|
||||
|
||||
useEffect(() => {
|
||||
let live = true, loaded: PdfDoc | null = null;
|
||||
setDoc(null); setError(null); setPages(0);
|
||||
// pdf.js 6 documents have no destroy(); the loading task owns the document and the worker port.
|
||||
let live = true, task: { destroy(): Promise<void> } | null = null;
|
||||
setDoc(null); setError(null); setPages(0); setView({ front: 0, page: 0, dir: null });
|
||||
loadPdfjs()
|
||||
.then((pdfjs) => pdfjs.getDocument({ url: deck.url, withCredentials: true }).promise)
|
||||
.then((pdfjs) => {
|
||||
if (!live) throw new Error("unmounted"); // never start a load (and its worker) after cleanup
|
||||
const t = pdfjs.getDocument({ url: deck.url, withCredentials: true });
|
||||
task = t;
|
||||
return t.promise;
|
||||
})
|
||||
.then((d: PdfDoc) => {
|
||||
loaded = d;
|
||||
if (!live) { void d.destroy(); return; }
|
||||
if (!live) return;
|
||||
setDoc(d); setPages(d.numPages); setPage((p) => Math.min(Math.max(1, p), d.numPages));
|
||||
})
|
||||
.catch((e: unknown) => live && setError(`This deck could not be opened. ${(e as Error).message ?? ""}`.trim()));
|
||||
return () => { live = false; if (loaded) void loaded.destroy(); };
|
||||
return () => { live = false; void task?.destroy().catch(() => {}); };
|
||||
}, [deck.url]);
|
||||
|
||||
// Render the current page to fit the stage.
|
||||
// Draw the current page to fit the stage: first into the front canvas, later pages into the back one, then flip.
|
||||
useEffect(() => {
|
||||
if (!doc || !canvas.current || !stage.current) return;
|
||||
if (!doc || !canvasA.current || !canvasB.current || !stage.current) return;
|
||||
let live = true;
|
||||
const draw = () => {
|
||||
const canvases = [canvasA.current, canvasB.current] as const;
|
||||
const draw = (into: 0 | 1, then?: () => void) => {
|
||||
const r = stage.current!.getBoundingClientRect();
|
||||
if (live) renderPage(doc, page, canvas.current!, r.width, r.height).catch(() => {});
|
||||
renderPage(doc, page, canvases[into], r.width, r.height).then(() => { if (live) then?.(); }, () => {});
|
||||
};
|
||||
draw();
|
||||
const ro = new ResizeObserver(() => draw());
|
||||
const cur = viewRef.current;
|
||||
if (cur.page === 0 || cur.page === page) draw(cur.front, () => setView((v) => ({ ...v, page })));
|
||||
else {
|
||||
const back = (1 - cur.front) as 0 | 1;
|
||||
draw(back, () => setView({ front: back, page, dir: page > cur.page ? "next" : "prev" }));
|
||||
}
|
||||
let first = true; // ResizeObserver reports once on observe; that size is already drawn
|
||||
const ro = new ResizeObserver(() => { if (first) { first = false; return; } draw(viewRef.current.front); });
|
||||
ro.observe(stage.current);
|
||||
return () => { live = false; ro.disconnect(); };
|
||||
}, [doc, page]);
|
||||
@@ -105,7 +141,16 @@ export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, titl
|
||||
<figure className="stage" ref={stage}>
|
||||
{error ? <span className="msg">{error} <a href={deck.url} target="_blank" rel="noopener">Open the PDF</a></span> : null}
|
||||
{!error && !doc ? <span className="msg">Loading slides…</span> : null}
|
||||
<canvas ref={canvas} hidden={!doc || !!error} role="img" aria-label={`Slide ${page} of ${pages}`} />
|
||||
{([0, 1] as const).map((i) => {
|
||||
const front = view.front === i;
|
||||
return (
|
||||
<canvas
|
||||
key={i} ref={i ? canvasB : canvasA} hidden={!doc || !!error}
|
||||
data-front={front ? "" : undefined} data-page={front && view.page ? view.page : undefined} data-leaving={!front && view.dir ? "" : undefined} data-dir={view.dir ?? undefined}
|
||||
role={front ? "img" : undefined} aria-hidden={front ? undefined : true} aria-label={front ? `Slide ${view.page || page} of ${pages}` : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</figure>
|
||||
<div className="deckbar">
|
||||
<Pill className="sm" aria-label="Previous slide" disabled={page <= 1} onClick={() => go(page - 1)}><Prev /></Pill>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CourseCover, ProgressBar, SegmentedToggle } from "@stc/ui";
|
||||
import { CourseCover, ProgressBar, SegmentedToggle, LoadingLine } from "@stc/ui";
|
||||
import type { CourseCard, ContinueItem, Format, LibraryPayload, SearchHit } from "../../shared/types";
|
||||
import { api, courseHref, ensureStyles, fmtMinutes, lessonHref, refreshLibrary, useLibrary, useSearch } from "../api";
|
||||
import { HeaderExtras } from "../shell/HeaderExtras";
|
||||
@@ -31,7 +31,7 @@ export default function LibraryPage() {
|
||||
return (
|
||||
<div className="lr" data-look="work">
|
||||
<HeaderExtras course={null} lookMode={null} />
|
||||
{lib ? <LibraryView lib={lib} /> : <div className="lr-lib"><p className="lib-sub">Loading the library…</p></div>}
|
||||
{lib ? <LibraryView lib={lib} /> : <div aria-busy="true"><LoadingLine /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,12 @@ export function Outline({ course, currentLessonId }: { course: CoursePayload; cu
|
||||
.map((m) => ({ m, lessons: query ? m.lessons.filter((l) => fold(l.title).includes(query)) : m.lessons }))
|
||||
.filter(({ m, lessons }) => !query || lessons.length || fold(m.title).includes(query)), [course, query]);
|
||||
|
||||
const toggle = (id: string) => setOpenMods((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });
|
||||
// Only a list the user opens rises in; lists open on page load or after a lesson change stay still.
|
||||
const [opened, setOpened] = useState<string | null>(null);
|
||||
const toggle = (id: string) => {
|
||||
if (!openMods.has(id)) setOpened(id);
|
||||
setOpenMods((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });
|
||||
};
|
||||
const sourceLine = course.authors.length ? course.authors.join(", ") : course.tracks.length > 1 ? `Tracks: ${course.tracks.map((t) => t.label).join(", ")}` : course.pitch ?? "";
|
||||
const errors = course.diagnostics.filter((d) => d.level === "error");
|
||||
|
||||
@@ -108,15 +113,15 @@ export function Outline({ course, currentLessonId }: { course: CoursePayload; cu
|
||||
</div>
|
||||
) : null}
|
||||
{filtered.map(({ m, lessons }) => (
|
||||
<ModuleBlock key={m.id} course={course} m={m} lessons={lessons} open={!!query || openMods.has(m.id)} current={m.id === current?.id} currentLessonId={currentLessonId} onToggle={() => toggle(m.id)} />
|
||||
<ModuleBlock key={m.id} course={course} m={m} lessons={lessons} open={!!query || openMods.has(m.id)} enter={opened === m.id} current={m.id === current?.id} currentLessonId={currentLessonId} onToggle={() => toggle(m.id)} />
|
||||
))}
|
||||
{query && filtered.length === 0 ? <p className="lr-nohit">No lessons match “{q.trim()}”.</p> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleBlock({ course, m, lessons, open, current, currentLessonId, onToggle }: {
|
||||
course: CoursePayload; m: OutlineModule; lessons: OutlineLesson[]; open: boolean; current: boolean; currentLessonId: string | null; onToggle: () => void;
|
||||
function ModuleBlock({ course, m, lessons, open, enter, current, currentLessonId, onToggle }: {
|
||||
course: CoursePayload; m: OutlineModule; lessons: OutlineLesson[]; open: boolean; enter: boolean; current: boolean; currentLessonId: string | null; onToggle: () => void;
|
||||
}) {
|
||||
const d = m.lessons.filter((l) => course.progress[l.id]?.state === "done").length;
|
||||
const pct = m.lessons.length ? d / m.lessons.length : 0;
|
||||
@@ -131,7 +136,7 @@ function ModuleBlock({ course, m, lessons, open, current, currentLessonId, onTog
|
||||
</button>
|
||||
{course.signedIn ? <ProgressBar value={pct} dim={pct === 1 || !current} label={`${m.title} progress`} /> : <span style={{ display: "block", height: 4 }} />}
|
||||
{open ? (
|
||||
<ol className="lr-lessons">
|
||||
<ol className="lr-lessons" data-enter={enter ? "" : undefined}>
|
||||
{lessons.map((l) => {
|
||||
const isDone = course.progress[l.id]?.state === "done";
|
||||
const cls = ["lr-les", isDone && "done", m.locked && "locked"].filter(Boolean).join(" ");
|
||||
|
||||
@@ -37,7 +37,7 @@ export function HeaderExtras({ course, lookMode, outline }: {
|
||||
</div>,
|
||||
host,
|
||||
)}
|
||||
{msg ? <div className="lr lr-toast" role="status">{msg}</div> : null}
|
||||
{msg ? <div className={msg.leaving ? "lr lr-toast is-leaving" : "lr lr-toast"} role="status">{msg.text}</div> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export const learnerCss = String.raw`
|
||||
.lr-shell { display: flex; align-items: flex-start; }
|
||||
.lr-side { position: sticky; top: var(--top); flex: none; width: var(--sidew); height: calc(100vh - var(--top)); overflow-y: auto; background: var(--side); border-right: 1px solid var(--line); overscroll-behavior: contain; }
|
||||
.lr-scrim { display: none; }
|
||||
.lr-main { flex: 1; min-width: 0; padding: 44px 40px 0; }
|
||||
.lr-main { position: relative; flex: 1; min-width: 0; padding: 44px 40px 0; }
|
||||
.lr-side-head { display: flex; gap: 12px; padding: 18px 16px 16px; border-bottom: 1px solid var(--line); }
|
||||
.lr-side-head h2 { margin: 0; font-size: 14.5px; font-weight: 600; color: var(--head); line-height: 1.3; }
|
||||
.lr-side-head h2 a { text-decoration: none; }
|
||||
@@ -331,6 +331,32 @@ 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 ----------
|
||||
Navigation swaps at once; only the new content region enters (fade and 6 px rise). The header, sidebar and dock
|
||||
stay put and nothing cross-fades. While a lesson loads its column keeps the dock at the bottom of the screen. */
|
||||
.lr-main .lr-lesson, .lr-main > article, .stc-main > .lr > article, .lr-lib { animation: stc-page var(--t-page) var(--ease-out) backwards; }
|
||||
.lr-main > .col[aria-busy="true"] { min-height: calc(100vh - var(--top) - 150px); }
|
||||
/* deck slides: the new slide fades in over the old one, 4 px from the side it comes from */
|
||||
.lr .stage canvas { grid-area: 1 / 1; }
|
||||
.lr .stage canvas:not([data-front]) { opacity: 0; }
|
||||
.lr .stage canvas[data-front] { position: relative; z-index: 1; }
|
||||
.lr .stage canvas[data-front][data-dir="next"] { animation: stc-slide-next var(--t-enter) var(--ease-out) backwards; }
|
||||
.lr .stage canvas[data-front][data-dir="prev"] { animation: stc-slide-prev var(--t-enter) var(--ease-out) backwards; }
|
||||
.lr .stage canvas[data-leaving] { animation: stc-hold-out var(--t-enter) linear both; }
|
||||
/* overlays answer the action that opened them; closing is instant except for the toast's short fade */
|
||||
.lr-menu { animation: stc-drop var(--t-fast) var(--ease-out) backwards; }
|
||||
.lr-shell.side-open .lr-scrim { animation: stc-fade-in var(--t-fast) var(--ease-out) backwards; }
|
||||
.lr-toast { animation: stc-enter var(--t-enter) var(--ease-out) backwards; }
|
||||
.lr-toast.is-leaving { animation: stc-fade-out var(--t-exit) var(--ease-out) both; }
|
||||
/* a module list the user opens */
|
||||
.lr-lessons[data-enter] { animation: stc-enter var(--t-enter) var(--ease-out) backwards; }
|
||||
/* folds and hint tiers grow to their height where the browser can animate it, and open instantly elsewhere */
|
||||
@supports selector(::details-content) {
|
||||
.lr details.fold { interpolate-size: allow-keywords; }
|
||||
.lr details.fold::details-content { height: 0; opacity: 0; overflow: clip; transition: height var(--t-fold) var(--ease-out), opacity var(--t-fold) var(--ease-out), content-visibility var(--t-fold) allow-discrete; }
|
||||
.lr details.fold[open]::details-content { height: auto; opacity: 1; }
|
||||
}
|
||||
|
||||
/* ---------- responsive ---------- */
|
||||
@media (max-width: 1100px) {
|
||||
.lr .ex { grid-template-columns: minmax(0, 1fr); }
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MemoryRouter } from "react-router-dom";
|
||||
import { learnerWeb } from "../src/web";
|
||||
import { filterCourses, LibraryView } from "../src/web/library/LibraryPage";
|
||||
import { CourseFrame, moduleNumber } from "../src/web/shell/CourseFrame";
|
||||
import { nextLesson } from "../src/web/course/pages";
|
||||
import { lessonNeighbours, nextLesson } from "../src/web/course/pages";
|
||||
import { safeNext } from "../src/web/account/AccountPage";
|
||||
import type { CourseCard, CoursePayload, LibraryPayload } from "../src/shared/types";
|
||||
|
||||
@@ -97,3 +97,9 @@ test("sign-in only returns to same-site paths", () => {
|
||||
expect(safeNext("https://evil.example")).toBe("/");
|
||||
expect(safeNext(null)).toBe("/");
|
||||
});
|
||||
|
||||
test("lessonNeighbours: the dock's previous and next come from the outline in flat course order", () => {
|
||||
expect(lessonNeighbours(course, "a")).toMatchObject({ current: { id: "a" }, prev: null, next: { id: "b" } });
|
||||
expect(lessonNeighbours(course, "b")).toMatchObject({ current: { id: "b" }, prev: { id: "a" }, next: null });
|
||||
expect(lessonNeighbours(course, "zz")).toEqual({ current: null, prev: null, next: null });
|
||||
});
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export { AppShell } from "./AppShell";
|
||||
export { Pill, SegmentedToggle, ProgressBar, FloatingActionBar, CourseCover, coverFits, monogram } from "./primitives";
|
||||
export { Pill, SegmentedToggle, ProgressBar, FloatingActionBar, CourseCover, coverFits, monogram, LoadingLine } from "./primitives";
|
||||
export { reducedMotion } from "./motion";
|
||||
export { createScrollKeeper, routeScroll, installRouteScroll, type ScrollEnv, type ScrollKeeper } from "./scroll";
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Whether the user asked for reduced motion (false outside a browser). */
|
||||
export function reducedMotion(): boolean {
|
||||
return typeof window !== "undefined" && typeof window.matchMedia === "function"
|
||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type ButtonHTMLAttributes, type CSSProperties, type ReactNode } from "react";
|
||||
import { useEffect, useState, type ButtonHTMLAttributes, type CSSProperties, type ReactNode } from "react";
|
||||
|
||||
export function Pill({ variant = "default", className, ...rest }: { variant?: "default" | "primary" | "ghost" } & ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const cls = ["stc-pill", variant !== "default" && `stc-pill--${variant}`, className].filter(Boolean).join(" ");
|
||||
@@ -63,3 +63,16 @@ export function CourseCover({ title, image, accent }: { title: string; image?: s
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A slim accent line at the top of the content region (the nearest positioned ancestor), shown only when loading
|
||||
* takes longer than `delay` ms, so fast loads show nothing at all.
|
||||
*/
|
||||
export function LoadingLine({ delay = 150 }: { delay?: number }) {
|
||||
const [shown, setShown] = useState(false);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setShown(true), delay);
|
||||
return () => clearTimeout(t);
|
||||
}, [delay]);
|
||||
return shown ? <div className="stc-loading" aria-hidden="true" /> : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/** What the scroll keeper needs from the browser (a fake in tests). */
|
||||
export interface ScrollEnv {
|
||||
getY(): number;
|
||||
scrollTo(y: number): void;
|
||||
maxY(): number;
|
||||
/** Scrolls the element with this id into view; false when there is none. */
|
||||
scrollToId(id: string): boolean;
|
||||
/** Runs `cb` on the next frame. */
|
||||
later(cb: () => void): void;
|
||||
now(): number;
|
||||
load(): string | null;
|
||||
save(s: string): void;
|
||||
}
|
||||
|
||||
export interface ScrollKeeper {
|
||||
/** Records the position for the history entry on screen. */
|
||||
onScroll(): void;
|
||||
/** The shell calls this on every location change: top for PUSH/REPLACE, the saved position for POP. */
|
||||
routeChanged(key: string, pop: boolean, hash?: string): void;
|
||||
/** The user scrolled or pressed a key: a restore still waiting for the page to grow stops pulling them back. */
|
||||
userInput(): void;
|
||||
}
|
||||
|
||||
interface Route { key: string; pop: boolean; hash?: string }
|
||||
|
||||
const KEEP = 60;
|
||||
const RETRY_MS = 1000;
|
||||
|
||||
/**
|
||||
* Scroll positions per history entry: a new navigation starts at the top, back/forward returns to where the entry
|
||||
* was left, retrying for up to a second while the page renders its content late.
|
||||
*/
|
||||
export function createScrollKeeper(env: ScrollEnv): ScrollKeeper {
|
||||
const saved = new Map<string, number>();
|
||||
try {
|
||||
const raw = JSON.parse(env.load() ?? "[]") as unknown;
|
||||
if (Array.isArray(raw)) for (const [k, y] of raw) if (typeof k === "string" && typeof y === "number") saved.set(k, y);
|
||||
} catch { /* unreadable: start empty */ }
|
||||
let current: string | null = null;
|
||||
let run = 0;
|
||||
let restoring = false;
|
||||
let persistQueued = false;
|
||||
|
||||
const persist = () => {
|
||||
if (persistQueued) return;
|
||||
persistQueued = true;
|
||||
env.later(() => {
|
||||
persistQueued = false;
|
||||
try { env.save(JSON.stringify([...saved].slice(-KEEP))); } catch { /* storage full or blocked */ }
|
||||
});
|
||||
};
|
||||
|
||||
const apply = (r: Route) => {
|
||||
current = r.key;
|
||||
const id = ++run;
|
||||
restoring = false;
|
||||
if (r.hash && r.hash.length > 1) {
|
||||
let target = r.hash.slice(1);
|
||||
try { target = decodeURIComponent(target); } catch { /* keep raw */ }
|
||||
if (env.scrollToId(target)) return;
|
||||
}
|
||||
const y = r.pop ? saved.get(r.key) ?? 0 : 0;
|
||||
const start = env.now();
|
||||
const step = () => {
|
||||
if (id !== run) return;
|
||||
env.scrollTo(Math.min(y, env.maxY()));
|
||||
restoring = env.maxY() < y && env.now() - start < RETRY_MS;
|
||||
if (restoring) env.later(step);
|
||||
};
|
||||
step();
|
||||
};
|
||||
|
||||
return {
|
||||
onScroll() {
|
||||
if (current === null || restoring) return;
|
||||
saved.delete(current);
|
||||
saved.set(current, env.getY());
|
||||
persist();
|
||||
},
|
||||
routeChanged(key, pop, hash) { apply({ key, pop, hash }); },
|
||||
userInput() {
|
||||
if (!restoring) return;
|
||||
run++;
|
||||
restoring = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let instance: ScrollKeeper | null = null;
|
||||
|
||||
/** The window-bound keeper shared by the shell and the pages. */
|
||||
export function routeScroll(): ScrollKeeper {
|
||||
instance ??= createScrollKeeper({
|
||||
getY: () => window.scrollY,
|
||||
scrollTo: (y) => window.scrollTo(0, y),
|
||||
maxY: () => Math.max(0, document.documentElement.scrollHeight - window.innerHeight),
|
||||
scrollToId: (id) => { const el = document.getElementById(id); el?.scrollIntoView({ block: "start" }); return !!el; },
|
||||
later: (cb) => { requestAnimationFrame(() => cb()); },
|
||||
now: () => performance.now(),
|
||||
load: () => { try { return sessionStorage.getItem("stc.scroll"); } catch { return null; } },
|
||||
save: (s) => { try { sessionStorage.setItem("stc.scroll", s); } catch { /* private mode */ } },
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** Takes scroll restoration over from the browser and records positions. Returns a cleanup function. */
|
||||
export function installRouteScroll(): () => void {
|
||||
const keeper = routeScroll();
|
||||
try { history.scrollRestoration = "manual"; } catch { /* unsupported */ }
|
||||
const onScroll = () => keeper.onScroll();
|
||||
const onInput = () => keeper.userInput();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
for (const t of ["wheel", "touchstart", "keydown"]) window.addEventListener(t, onInput, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
for (const t of ["wheel", "touchstart", "keydown"]) window.removeEventListener(t, onInput);
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,10 @@
|
||||
--sans: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--serif: "Literata", Georgia, "Times New Roman", serif;
|
||||
--mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
|
||||
--top: 52px; --sidew: 296px; --fast: 140ms;
|
||||
--top: 52px; --sidew: 296px;
|
||||
/* motion (v1.1): light, ease-out, opacity and short rises only; prefers-reduced-motion turns it all off */
|
||||
--t-exit: 120ms; --t-fast: 140ms; --t-page: 160ms; --t-enter: 180ms; --t-fold: 200ms; --t-bar: 220ms; --fast: 140ms;
|
||||
--ease-out: cubic-bezier(.2, .7, .3, 1); --rise: 6px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
@@ -33,7 +36,7 @@ code, pre { font-family: var(--mono); }
|
||||
@media (max-width: 680px) { .stc-nav { display: none; } }
|
||||
.stc-shell { display: flex; align-items: flex-start; }
|
||||
.stc-side { position: sticky; top: var(--top); flex: none; width: var(--sidew); height: calc(100vh - var(--top)); overflow-y: auto; background: var(--side); border-right: 1px solid var(--line); }
|
||||
.stc-main { flex: 1; min-width: 0; padding: 44px 40px 0; }
|
||||
.stc-main { position: relative; flex: 1; min-width: 0; padding: 44px 40px 0; }
|
||||
|
||||
/* pill */
|
||||
.stc-pill { display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 15px; border: 1px solid var(--line2); border-radius: 18px; background: none; font-size: 13px; font-weight: 500; text-decoration: none; white-space: nowrap; transition: background var(--fast), color var(--fast), border-color var(--fast); }
|
||||
@@ -52,7 +55,7 @@ code, pre { font-family: var(--mono); }
|
||||
|
||||
/* progress */
|
||||
.stc-bar { display: block; flex: 1; height: 4px; border-radius: 2px; background: var(--line); overflow: hidden; }
|
||||
.stc-bar > i { display: block; height: 100%; background: var(--accent); border-radius: 2px; }
|
||||
.stc-bar > i { display: block; height: 100%; background: var(--accent); border-radius: 2px; transition: width var(--t-bar) var(--ease-out); }
|
||||
.stc-bar--dim > i { background: var(--accent-dim); }
|
||||
|
||||
/* floating action bar */
|
||||
@@ -65,3 +68,33 @@ code, pre { font-family: var(--mono); }
|
||||
.stc-cover { flex: none; position: relative; width: var(--cw, 44px); aspect-ratio: 3 / 4; border-radius: 6px; background: var(--cbg, var(--raised)); color: var(--cfg, var(--accent)); border: 1px solid color-mix(in srgb, var(--cfg, var(--accent)) 18%, transparent); overflow: hidden; font: 600 calc(var(--cw, 44px) * .34) / 1 var(--sans); letter-spacing: -.03em; display: flex; align-items: flex-end; padding: calc(var(--cw, 44px) * .12); }
|
||||
.stc-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; opacity: 0; transition: opacity 120ms ease-out; }
|
||||
.stc-cover img[data-shown] { opacity: 1; }
|
||||
|
||||
/* ---------- motion ----------
|
||||
Navigation swaps at once and only the new content region enters (stc-page); the header and sidebar never move
|
||||
and nothing cross-fades, so input is never blocked. No View Transitions (ruling R19). */
|
||||
@keyframes stc-page { from { opacity: 0; translate: 0 var(--rise); } }
|
||||
@keyframes stc-enter { from { opacity: 0; translate: 0 var(--rise); } }
|
||||
@keyframes stc-fade-in { from { opacity: 0; } }
|
||||
@keyframes stc-fade-out { to { opacity: 0; } }
|
||||
@keyframes stc-drop { from { opacity: 0; translate: 0 -4px; } }
|
||||
/* the outgoing slide stays fully visible under the incoming one, then goes */
|
||||
@keyframes stc-hold-out { from { opacity: 1; } 99% { opacity: 1; } to { opacity: 0; } }
|
||||
@keyframes stc-slide-next { from { opacity: 0; translate: 4px 0; } }
|
||||
@keyframes stc-slide-prev { from { opacity: 0; translate: -4px 0; } }
|
||||
.stc-page { animation: stc-page var(--t-page) var(--ease-out) backwards; }
|
||||
|
||||
/* Loading line: shown at the top of the content region when route code or data takes over 150 ms. A progress
|
||||
indicator, not a transition: its sweep is the one long-running animation (reduced motion: a static line). */
|
||||
@keyframes stc-loading-sweep { to { background-position: -200% 0; } }
|
||||
.stc-loading { position: absolute; top: 0; left: 0; right: 0; height: 2px; z-index: 5; pointer-events: none;
|
||||
background: linear-gradient(90deg, var(--accent-dim) 0%, var(--accent) 50%, var(--accent-dim) 100%); background-size: 200% 100%;
|
||||
animation: stc-fade-in var(--t-page) var(--ease-out) backwards, stc-loading-sweep 1200ms linear infinite; }
|
||||
|
||||
/* After a navigation the new page's heading takes focus (tabindex -1) so screen readers land there; no ring for that. */
|
||||
.stc-main h1[tabindex="-1"]:focus { outline: none; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
|
||||
/* separate rule: browsers without ::details-content drop only this one */
|
||||
*::details-content { transition: none !important; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Static motion lint (v1.1 package M): every animation and transition in the shared and learner stylesheets is
|
||||
// light (120–220 ms, ease-out without overshoot), animates only opacity and short translates, and is switched off
|
||||
// by prefers-reduced-motion.
|
||||
import { expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { learnerCss } from "../../feature-learner/src/web/styles";
|
||||
|
||||
const tokensCss = readFileSync(join(import.meta.dir, "../src/tokens.css"), "utf8");
|
||||
const sheets: [string, string][] = [["tokens.css", tokensCss], ["learnerCss", learnerCss]];
|
||||
const strip = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
/** The loading line's sweep is a progress indicator, not navigation motion: exempt from the duration and property rules. */
|
||||
const INDICATORS = ["stc-loading-sweep"];
|
||||
|
||||
/** Duration tokens, in ms. */
|
||||
function tokens(): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const m of strip(tokensCss).matchAll(/(--t-[\w-]+|--fast)\s*:\s*([\d.]+)ms/g)) out[m[1]!] = Number(m[2]);
|
||||
return out;
|
||||
}
|
||||
|
||||
test("the motion duration tokens exist and are 120–220 ms", () => {
|
||||
const t = tokens();
|
||||
for (const name of ["--t-exit", "--t-fast", "--t-page", "--t-enter", "--t-fold", "--t-bar"]) {
|
||||
expect([name, t[name] !== undefined]).toEqual([name, true]);
|
||||
expect(t[name]!).toBeGreaterThanOrEqual(120);
|
||||
expect(t[name]!).toBeLessThanOrEqual(220);
|
||||
}
|
||||
});
|
||||
|
||||
test("every animation and transition duration is 0 or 120–220 ms", () => {
|
||||
const t = tokens();
|
||||
const bad: string[] = [];
|
||||
for (const [name, css] of sheets) {
|
||||
for (const m of strip(css).matchAll(/(?:^|[;{\s])(animation|transition)(?:-duration)?\s*:\s*([^;}]+)/g)) {
|
||||
const value = m[2]!;
|
||||
if (/^\s*none/.test(value) || INDICATORS.some((n) => value.includes(n))) continue;
|
||||
const durations: number[] = [];
|
||||
for (const d of value.matchAll(/var\((--[\w-]+)\)|(?<![\w.-])([\d.]+)(ms|s)\b/g)) {
|
||||
if (d[1]) { if (d[1] in t) durations.push(t[d[1]]!); continue; }
|
||||
durations.push(Number(d[2]) * (d[3] === "s" ? 1000 : 1));
|
||||
}
|
||||
// The first time in a declaration is the duration; a second one is a delay, which must be 0 or within range too.
|
||||
for (const ms of durations) if (ms !== 0 && (ms < 120 || ms > 220)) bad.push(`${name}: ${m[0].trim()}`);
|
||||
}
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
test("keyframes animate only opacity and short translates", () => {
|
||||
const bad: string[] = [];
|
||||
let count = 0;
|
||||
for (const [name, css] of sheets) {
|
||||
for (const k of strip(css).matchAll(/@keyframes\s+([\w-]+)\s*\{((?:[^{}]*\{[^}]*\})*)\s*\}/g)) {
|
||||
count++;
|
||||
if (INDICATORS.includes(k[1]!)) continue;
|
||||
for (const d of k[2]!.matchAll(/([\w-]+)\s*:\s*([^;}]+)/g)) {
|
||||
const [prop, value] = [d[1]!, d[2]!.trim()];
|
||||
if (prop === "opacity") continue;
|
||||
if (prop === "translate") {
|
||||
const px = [...value.matchAll(/(-?[\d.]+)px/g)].map((x) => Math.abs(Number(x[1])));
|
||||
if (px.every((n) => n <= 8) && !/%|em|vw|vh/.test(value)) continue;
|
||||
}
|
||||
bad.push(`${name} @keyframes ${k[1]}: ${prop}: ${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(count).toBeGreaterThan(0);
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
test("easing curves do not overshoot (no bounce)", () => {
|
||||
const bad: string[] = [];
|
||||
for (const [name, css] of sheets) {
|
||||
for (const m of css.matchAll(/cubic-bezier\(([^)]*)\)/g)) {
|
||||
const [, y1, , y2] = m[1]!.split(",").map(Number);
|
||||
if (!(y1! >= 0 && y1! <= 1 && y2! >= 0 && y2! <= 1)) bad.push(`${name}: ${m[0]}`);
|
||||
}
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
test("prefers-reduced-motion turns off every animation and transition", () => {
|
||||
const css = strip(tokensCss);
|
||||
const at = css.indexOf("@media (prefers-reduced-motion: reduce)");
|
||||
expect(at).toBeGreaterThan(-1);
|
||||
const block = css.slice(at, css.indexOf("\n}", at));
|
||||
expect(block).toMatch(/\*,\s*\*::before,\s*\*::after/);
|
||||
expect(block).toContain("animation: none !important");
|
||||
expect(block).toContain("transition: none !important");
|
||||
// `*` does not reach ::details-content (fold heights), so it has its own rule.
|
||||
expect(block).toMatch(/\*::details-content\s*\{\s*transition:\s*none !important/);
|
||||
});
|
||||
|
||||
test("no View Transitions (ruling R19): navigation never blocks input", () => {
|
||||
for (const [name, css] of sheets) expect([name, /view-transition/.test(strip(css))]).toEqual([name, false]);
|
||||
});
|
||||
|
||||
test("page entrance: opacity plus a 6 px rise in 160 ms", () => {
|
||||
const css = strip(tokensCss);
|
||||
expect(tokens()["--t-page"]).toBe(160);
|
||||
expect(css).toMatch(/--rise:\s*6px/);
|
||||
expect(css).toMatch(/@keyframes stc-page\s*\{\s*from\s*\{\s*opacity:\s*0;\s*translate:\s*0 var\(--rise\);/);
|
||||
expect(strip(learnerCss)).toMatch(/\.lr-main \.lr-lesson[^{]*\{\s*animation:\s*stc-page var\(--t-page\)/);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { LoadingLine, reducedMotion } from "../src";
|
||||
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
afterEach(() => { delete g.window; });
|
||||
|
||||
test("reducedMotion follows the media query and is false without a window", () => {
|
||||
expect(reducedMotion()).toBe(false);
|
||||
g.window = { matchMedia: (q: string) => ({ matches: q.includes("prefers-reduced-motion: reduce") }) };
|
||||
expect(reducedMotion()).toBe(true);
|
||||
g.window = { matchMedia: () => ({ matches: false }) };
|
||||
expect(reducedMotion()).toBe(false);
|
||||
});
|
||||
|
||||
test("LoadingLine renders nothing until its delay has passed (fast loads show no indicator)", () => {
|
||||
expect(renderToString(<LoadingLine />)).toBe("");
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createScrollKeeper } from "../src/scroll";
|
||||
|
||||
function fakeEnv(opts: { max?: number; stored?: string | null; ids?: string[] } = {}) {
|
||||
const e = {
|
||||
y: 0, max: opts.max ?? 10_000, t: 0, stored: opts.stored ?? null as string | null, queue: [] as (() => void)[], intoView: [] as string[],
|
||||
getY: () => e.y,
|
||||
scrollTo: (y: number) => { e.y = Math.min(y, e.max); },
|
||||
maxY: () => e.max,
|
||||
scrollToId: (id: string) => { if (!(opts.ids ?? []).includes(id)) return false; e.intoView.push(id); return true; },
|
||||
later: (cb: () => void) => { e.queue.push(cb); },
|
||||
now: () => e.t,
|
||||
load: () => e.stored,
|
||||
save: (s: string) => { e.stored = s; },
|
||||
flush() { const q = e.queue.splice(0); q.forEach((f) => f()); },
|
||||
};
|
||||
return e;
|
||||
}
|
||||
|
||||
test("PUSH goes to the top, POP restores the saved position of that entry", () => {
|
||||
const env = fakeEnv();
|
||||
const k = createScrollKeeper(env);
|
||||
k.routeChanged("a", false);
|
||||
env.y = 640; k.onScroll();
|
||||
k.routeChanged("b", false);
|
||||
expect(env.y).toBe(0);
|
||||
env.y = 120; k.onScroll();
|
||||
k.routeChanged("a", true);
|
||||
expect(env.y).toBe(640);
|
||||
k.routeChanged("b", true);
|
||||
expect(env.y).toBe(120);
|
||||
k.routeChanged("c", true); // never seen: top
|
||||
expect(env.y).toBe(0);
|
||||
});
|
||||
|
||||
test("a hash scrolls its target into view", () => {
|
||||
const env = fakeEnv({ ids: ["part 2"] });
|
||||
const k = createScrollKeeper(env);
|
||||
env.y = 300;
|
||||
k.routeChanged("a", false, "#part%202");
|
||||
expect(env.intoView).toEqual(["part 2"]);
|
||||
k.routeChanged("b", false, "#missing");
|
||||
expect(env.y).toBe(0);
|
||||
});
|
||||
|
||||
test("user input stops a restore that is still waiting for the page to grow", () => {
|
||||
const env = fakeEnv();
|
||||
const k = createScrollKeeper(env);
|
||||
k.routeChanged("a", false);
|
||||
env.y = 2000; k.onScroll();
|
||||
k.routeChanged("b", false);
|
||||
env.max = 300;
|
||||
k.routeChanged("a", true);
|
||||
expect(env.y).toBe(300);
|
||||
k.userInput(); // the user starts scrolling
|
||||
env.y = 120; k.onScroll();
|
||||
env.max = 5000; env.flush();
|
||||
expect(env.y).toBe(120);
|
||||
k.userInput(); // no restore pending: harmless
|
||||
});
|
||||
|
||||
test("a restore beyond the current page height retries until the page is tall enough", () => {
|
||||
const env = fakeEnv();
|
||||
const k = createScrollKeeper(env);
|
||||
k.routeChanged("a", false);
|
||||
env.y = 2000; k.onScroll();
|
||||
k.routeChanged("b", false);
|
||||
env.max = 300; // the old page is still loading
|
||||
k.routeChanged("a", true);
|
||||
expect(env.y).toBe(300);
|
||||
env.flush();
|
||||
expect(env.y).toBe(300);
|
||||
env.max = 5000; // content arrived
|
||||
env.flush();
|
||||
expect(env.y).toBe(2000);
|
||||
expect(env.queue.length).toBe(0);
|
||||
});
|
||||
|
||||
test("the retry gives up after a second, and a newer route change cancels it", () => {
|
||||
const env = fakeEnv();
|
||||
const k = createScrollKeeper(env);
|
||||
k.routeChanged("a", false);
|
||||
env.y = 2000; k.onScroll();
|
||||
k.routeChanged("b", false);
|
||||
env.max = 300;
|
||||
k.routeChanged("a", true);
|
||||
k.onScroll(); // our own clamped scroll must not overwrite the saved 2000
|
||||
env.t = 1200; env.flush();
|
||||
expect(env.queue.length).toBe(0);
|
||||
env.max = 5000;
|
||||
k.routeChanged("a", true);
|
||||
expect(env.y).toBe(2000);
|
||||
|
||||
env.max = 300;
|
||||
k.routeChanged("c", false);
|
||||
k.routeChanged("a", true);
|
||||
k.routeChanged("d", false); // cancels the restore of "a"
|
||||
env.max = 5000; env.flush();
|
||||
expect(env.y).toBe(0);
|
||||
});
|
||||
|
||||
test("positions survive a reload through storage", () => {
|
||||
const env = fakeEnv();
|
||||
const k = createScrollKeeper(env);
|
||||
k.routeChanged("a", false);
|
||||
env.y = 777; k.onScroll(); env.flush();
|
||||
const env2 = fakeEnv({ stored: env.stored });
|
||||
createScrollKeeper(env2).routeChanged("a", true);
|
||||
expect(env2.y).toBe(777);
|
||||
const env3 = fakeEnv({ stored: "{not json" });
|
||||
createScrollKeeper(env3).routeChanged("a", true);
|
||||
expect(env3.y).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
// Captures before / mid / after frames of each navigation motion from a running server, for visual review.
|
||||
// Animations are slowed 10x through the DevTools Animation domain, so a screenshot taken a few hundred ms in
|
||||
// lands mid-entrance. Lesson fetches can be held (window.__gate) to show the loading line. A second pass with prefers-reduced-motion shows the same
|
||||
// moment with no motion. Uses the sample course; sign-up creates a throwaway account on that server.
|
||||
//
|
||||
// bun scripts/motion-frames.ts http://localhost:3491 /tmp/frames
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { Chrome } from "../packages/feature-learner/test/chrome";
|
||||
|
||||
const [base = "http://localhost:3491", out = "motion-frames"] = process.argv.slice(2);
|
||||
const SLOW = 0.1;
|
||||
mkdirSync(out, { recursive: true });
|
||||
let dir = out;
|
||||
|
||||
const chrome = await Chrome.launch();
|
||||
await chrome.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false });
|
||||
await chrome.send("Animation.enable");
|
||||
await chrome.send("Page.addScriptToEvaluateOnNewDocument", { source: `
|
||||
const waiting = [];
|
||||
window.__release = () => { window.__gate = false; waiting.splice(0).forEach((r) => r()); };
|
||||
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);
|
||||
};` });
|
||||
|
||||
const js = (s: string) => JSON.stringify(s);
|
||||
async function until(cond: string, ms = 8000) {
|
||||
const end = Date.now() + ms;
|
||||
while (!(await chrome.eval<boolean>(cond).catch(() => false))) {
|
||||
if (Date.now() > end) throw new Error(`timed out: ${cond}`);
|
||||
await Bun.sleep(30);
|
||||
}
|
||||
}
|
||||
async function shot(name: string) {
|
||||
const r = await chrome.send("Page.captureScreenshot", { format: "png" });
|
||||
writeFileSync(join(dir, `${name}.png`), Buffer.from(r.result.data, "base64"));
|
||||
}
|
||||
async function open(path: string, ready: string) {
|
||||
await chrome.goto(`${base}${path}`, 100);
|
||||
await until(`location.pathname === ${js(path)} && !!document.querySelector(${js(ready)})`);
|
||||
await Bun.sleep(500);
|
||||
}
|
||||
/** Runs `action` (page JS), then captures frames at the given real-time offsets (ms). */
|
||||
async function frames(name: string, action: string, at: number[]) {
|
||||
await shot(`${name}-0-before`);
|
||||
const t0 = Date.now();
|
||||
await chrome.eval(`(${action}, true)`);
|
||||
for (const [i, t] of at.entries()) {
|
||||
await Bun.sleep(Math.max(0, t - (Date.now() - t0)));
|
||||
await shot(`${name}-${i + 1}-${t}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
const flows: [string, () => Promise<void>][] = [
|
||||
["01-library-to-course", async () => {
|
||||
await open("/", 'a[href="/c/sample"]');
|
||||
await frames("01-library-to-course", `document.querySelector('a[href="/c/sample"]').click()`, [300, 700, 2500]);
|
||||
}],
|
||||
["02-course-to-lesson", async () => {
|
||||
await open("/c/sample", ".lr-main > article h1");
|
||||
await frames("02-course-to-lesson", `document.querySelector('.lr-les[href="/c/sample/m1-code"]').click()`, [300, 700, 2500]);
|
||||
}],
|
||||
["03-lesson-next", async () => {
|
||||
await open("/c/sample/m1-code", ".lr-main .lr-lesson h1.title");
|
||||
await frames("03-lesson-next", `document.querySelector(".stc-dock .stc-pill--primary").click()`, [300, 700, 2500]);
|
||||
}],
|
||||
["03b-lesson-loading", async () => {
|
||||
// The next lesson held back: the column is empty at once and the loading line shows after 150 ms.
|
||||
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
|
||||
await chrome.eval("window.__gate = true");
|
||||
await frames("03b-lesson-loading", `document.querySelector(".stc-dock .stc-pill--primary").click()`, [80, 600]);
|
||||
await chrome.eval("window.__release()");
|
||||
await Bun.sleep(400);
|
||||
await shot("03b-lesson-loading-3-released-400ms");
|
||||
}],
|
||||
["04-module-open", async () => {
|
||||
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
|
||||
await frames("04-module-open", `[...document.querySelectorAll(".lr-mod-head")][1].click()`, [500, 2500]);
|
||||
}],
|
||||
["05-fold-open", async () => {
|
||||
await open("/c/sample/m1-video", "details.fold.transcript");
|
||||
await chrome.eval(`document.querySelector("details.fold.transcript").scrollIntoView({ block: "center" })`);
|
||||
// ::details-content transitions are not slowed by the Animation domain; sample real time instead.
|
||||
await frames("05-fold-open", `document.querySelector("details.fold.transcript > summary").click()`, [80, 140, 1000]);
|
||||
}],
|
||||
["06-slide-next", async () => {
|
||||
await open("/c/sample/m1-video", '.stage canvas[data-page="1"]');
|
||||
await chrome.eval(`document.querySelector(".deck").scrollIntoView({ block: "center" })`);
|
||||
await Bun.sleep(300);
|
||||
await frames("06-slide-next", `document.querySelector('.deckbar [aria-label="Next slide"]').click()`, [600, 2500]);
|
||||
}],
|
||||
["07-menu", async () => {
|
||||
await open("/c/sample/m1-intro", ".lr-switch-btn");
|
||||
await frames("07-menu", `document.querySelector(".lr-switch-btn").click()`, [500, 2000]);
|
||||
}],
|
||||
["08-toast", async () => {
|
||||
await open("/c/sample/m2-ex-add", ".stc-seg button");
|
||||
await frames("08-toast", `[...document.querySelectorAll(".stc-seg button")].find((b) => b.textContent === "Reading").click()`, [500, 2000]);
|
||||
}],
|
||||
];
|
||||
|
||||
try {
|
||||
await chrome.send("Animation.setPlaybackRate", { playbackRate: SLOW });
|
||||
for (const [, run] of flows) await run();
|
||||
// The same moments with reduced motion: the end state at once.
|
||||
await chrome.send("Animation.setPlaybackRate", { playbackRate: 1 });
|
||||
await chrome.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: "reduce" }] });
|
||||
dir = join(out, "reduced");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
for (const [name, run] of flows) await run().catch((e) => console.error(name, e));
|
||||
} finally {
|
||||
await chrome.close();
|
||||
}
|
||||
console.log(`frames written to ${out}`);
|
||||
Reference in New Issue
Block a user