merge: v1.1 navigation motion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-30 21:59:26 +01:00
co-authored by Claude Opus 5.5
22 changed files with 1401 additions and 80 deletions
+84 -12
View File
@@ -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} />);
+394
View File
@@ -0,0 +1,394 @@
// Navigation motion (v1.1 package M, ruling R19), checked in headless Chrome against the real server (every feature,
// the production bundle) in a subprocess. Skipped without Chrome.
//
// Deterministic by construction: no fixed sub-second sleeps. A probe in the page records animationstart/end and
// transitionstart events and counts any document.startViewTransition call; lesson fetches can be held behind a gate
// (window.__gate / window.__release()); tests wait on conditions, and finish running animations through
// document.getAnimations() where they need a settled page.
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { appendFileSync, cpSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { Chrome, chromePath } from "../../../packages/feature-learner/test/chrome";
const ROOT = resolve(import.meta.dir, "../../..");
const run = chromePath ? describe : describe.skip;
const PROBE = `
window.__m = { vt: 0, anims: [], ends: [], trans: [] };
const vt = Document.prototype.startViewTransition;
if (vt) Document.prototype.startViewTransition = function (...a) { window.__m.vt++; return vt.apply(this, a); };
const who = (e) => {
const t = e.target;
const cls = t && t.getAttribute ? (t.getAttribute("class") || t.tagName.toLowerCase()) : "?";
return cls + (e.pseudoElement || "");
};
document.addEventListener("animationstart", (e) => window.__m.anims.push(e.animationName + "@" + who(e)), true);
document.addEventListener("animationend", (e) => window.__m.ends.push(e.animationName + "@" + who(e)), true);
document.addEventListener("transitionstart", (e) => window.__m.trans.push(e.propertyName + "@" + who(e)), true);
// With window.__gate set, lesson requests wait until window.__release().
const waiting = [];
window.__release = () => { window.__gate = false; waiting.splice(0).forEach((r) => r()); return true; };
const f = window.fetch;
window.fetch = function (input, init) {
const u = String((input && input.url) || input);
if (window.__gate && u.includes("/lessons/")) return new Promise((r) => waiting.push(r)).then(() => f.call(this, input, init));
return f.call(this, input, init);
};
`;
let server: Bun.Subprocess, chrome: Chrome, base = "", dataDir = "";
async function freePort(): Promise<number> {
const s = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("") });
const p = s.port!;
await s.stop(true);
return p;
}
/** Polls `f` until it returns something truthy. A stalled DevTools call counts as "not yet". */
async function until<T>(f: () => Promise<T>, what: string, ms = 10_000): Promise<NonNullable<T>> {
const end = Date.now() + ms;
for (;;) {
const v = await Promise.race([f(), Bun.sleep(3000).then(() => null)]).catch(() => null);
if (v) return v as NonNullable<T>;
if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
await Bun.sleep(20);
}
}
const js = (s: string) => JSON.stringify(s);
type Probe = { vt: number; anims: string[]; ends: string[]; trans: string[] };
const m = () => chrome.eval<Probe>("window.__m");
const reset = () => chrome.eval("(window.__m.vt = 0, window.__m.anims = [], window.__m.ends = [], window.__m.trans = [], true)");
const exists = (sel: string) => chrome.eval<boolean>(`!!document.querySelector(${js(sel)})`);
const click = (sel: string) => chrome.eval(`document.querySelector(${js(sel)}).click(), true`);
const text = (sel: string) => chrome.eval<string | null>(`document.querySelector(${js(sel)})?.textContent ?? null`);
const rect = (sel: string) => chrome.eval<string>(`JSON.stringify(document.querySelector(${js(sel)}).getBoundingClientRect())`);
const path = () => chrome.eval<string>("location.pathname");
const title = () => text(".lr-main .lr-lesson h1.title");
/** Two animation frames: the page has rendered and dispatched its animation events. */
const frames = () => chrome.eval("new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))");
/** Jumps every finite running animation and transition to its end. */
const finishAll = () => chrome.eval(`(document.getAnimations().forEach((a) => { try { a.finish(); } catch {} }), true)`);
/** What runs now, as "name@class::pseudo" (the infinite loading sweep included). */
const running = () => chrome.eval<string[]>(`document.getAnimations().map((a) => (a.transitionProperty || a.animationName) + "@" + (a.effect.target.getAttribute?.("class") || a.effect.target.tagName?.toLowerCase()) + (a.effect.pseudoElement || ""))`);
const focusedH1 = () => chrome.eval<string | null>(`document.activeElement?.tagName === "H1" ? document.activeElement.textContent : null`);
const announced = () => text('[aria-live="polite"].sr');
/** Full page load of `p`, waits for `ready`, settles every animation and clears the probe. */
async function open(p: string, ready: string) {
await chrome.goto(`${base}${p}`, 0);
await until(() => chrome.eval<boolean>(`location.pathname === ${js(p)} && !!document.querySelector(${js(ready)})`), `${p} ${ready}`);
await frames();
await finishAll();
await frames();
await reset();
}
async function key(k: string, code: number) {
// Enter needs its text to activate a focused button or link.
await chrome.send("Input.dispatchKeyEvent", { type: "keyDown", key: k, code: k, windowsVirtualKeyCode: code, ...(k === "Enter" ? { text: "\r" } : {}) });
await chrome.send("Input.dispatchKeyEvent", { type: "keyUp", key: k, code: k, windowsVirtualKeyCode: code });
}
async function setReducedMotion(on: boolean) {
await chrome.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: on ? "reduce" : "no-preference" }] });
}
run("navigation motion (headless Chrome)", () => {
beforeAll(async () => {
const port = await freePort();
dataDir = mkdtempSync(join(tmpdir(), "stc-motion-"));
// The sample course, with lessons long enough to scroll.
const courses = join(dataDir, "courses");
cpSync(join(ROOT, "fixtures/courses/sample"), join(courses, "sample"), { recursive: true });
const filler = Array.from({ length: 40 }, (_, i) => `Paragraph ${i + 1}. ${"A line of reading text that wraps across the column. ".repeat(4)}`).join("\n\n");
for (const f of ["01-intro.md", "03-code.md"]) appendFileSync(join(courses, "sample/lessons", f), `\n\n${filler}\n`);
base = `http://localhost:${port}`;
server = Bun.spawn([process.execPath, "apps/server/src/main.ts"], {
cwd: ROOT, stdout: "pipe", stderr: "pipe",
env: {
...process.env, NODE_ENV: "production", STC_PORT: String(port), STC_SECRET: "motion-secret-0123456789abcdef0123456789",
STC_DATA_DIR: dataDir, STC_DATABASE_URL: `sqlite://${join(dataDir, "stc.db")}`, STC_COURSE_DIRS: courses,
STC_PUBLIC_URL: base,
},
});
await until(async () => (await fetch(`${base}/api/health`)).ok, "server", 30_000);
await until(async () => (await fetch(`${base}/`)).ok, "bundle", 30_000);
chrome = await Chrome.launch();
await chrome.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false });
await chrome.send("Page.addScriptToEvaluateOnNewDocument", { source: PROBE });
await setReducedMotion(false);
}, 60_000);
afterAll(async () => {
await chrome?.close();
server?.kill();
await server?.exited;
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
});
test("library to course: swaps at once, only the content region enters, focus and announcement follow", async () => {
await open("/", 'a[href="/c/sample"]');
await click('a[href="/c/sample"]');
expect(await path()).toBe("/c/sample");
await until(() => exists(".lr-main > article h1"), "course page");
await until(async () => (await m()).anims.includes("stc-page@col"), "content entrance");
await until(async () => (await focusedH1()) === "Sample course", "focus on the h1");
await until(async () => (await announced()) === "Sample course", "announcement");
const r = await m();
expect(r.vt).toBe(0);
expect(r.anims.filter((a) => /lr-side|stc-top|lr-shell/.test(a))).toEqual([]);
}, 30_000);
test("course to lesson: the sidebar is never dropped, not even for a frame", async () => {
await open("/c/sample", ".lr-main > article h1");
const side = await rect(".lr-side");
await chrome.eval(`(() => {
window.__sideGone = false;
const mo = new MutationObserver(() => { if (!document.querySelector(".lr-side")) window.__sideGone = true; });
mo.observe(document.body, { childList: true, subtree: true });
return true;
})()`);
await click('.lr-les[href="/c/sample/m1-intro"]');
await until(async () => (await title()) === "Introduction", "lesson");
await frames();
expect(await chrome.eval<boolean>("window.__sideGone")).toBe(false);
expect(await rect(".lr-side")).toBe(side);
expect((await m()).anims).toContain("stc-page@lr lr-lesson");
}, 30_000);
test("lesson to lesson: instant swap with a loading line; a second Next advances again; focus, scroll and back", async () => {
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
const side = await rect(".lr-side"), top = await rect(".stc-top");
await chrome.eval("window.scrollTo(0, 900), true");
await until(async () => (await chrome.eval<number>("scrollY")) === 900, "scrolled");
await frames(); // the scroll event (which records the position) is dispatched on the next frame
await chrome.eval("window.__gate = true");
await click(".stc-dock .stc-pill--primary");
// Swapped at once: the URL, the sidebar highlight, an empty busy column (never the old lesson), the top of the page.
expect(await path()).toBe("/c/sample/m1-video");
await frames();
expect(await title()).toBe(null);
expect(await exists('.lr-main > .col[aria-busy="true"]')).toBe(true);
expect(await text('.lr-les[aria-current="page"]')).toContain("Watch the clip");
expect(await chrome.eval<number>("scrollY")).toBe(0);
await until(() => exists(".lr-main .stc-loading"), "loading line");
// The dock is still there and acts on the lesson the URL names: a second Next goes on to the third lesson.
await click(".stc-dock .stc-pill--primary");
expect(await path()).toBe("/c/sample/m1-code");
await chrome.eval("window.__release()");
await until(async () => (await title()) === "Code along", "third lesson");
await until(async () => (await m()).anims.includes("stc-page@lr lr-lesson"), "lesson entrance");
expect(await exists(".lr-main .stc-loading")).toBe(false);
// Focus was on <body> (a scripted click does not focus the button), so the new heading takes it.
await until(async () => (await focusedH1()) === "Code along", "focus on the h1");
await until(async () => (await announced()) === "Code along", "announcement");
expect(await chrome.eval<boolean>(`getComputedStyle(document.activeElement).outlineStyle === "none"`)).toBe(true);
expect(await rect(".lr-side")).toBe(side);
expect(await rect(".stc-top")).toBe(top);
expect((await m()).vt).toBe(0);
// Back twice: the intro returns where it was left.
await chrome.eval("history.go(-2), true");
await until(async () => (await title()) === "Introduction", "intro again");
await until(async () => (await chrome.eval<number>("scrollY")) === 900, "restored scroll", 3000);
await until(async () => (await focusedH1()) === "Introduction", "back/forward focuses the h1");
}, 30_000);
test("keyboard on the dock: Enter on Next twice goes two lessons on, focus stays on Next, titles are announced", async () => {
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
const onNext = () => chrome.eval<boolean>(`document.activeElement === document.querySelector(".stc-dock .stc-pill--primary")`);
await chrome.eval(`document.querySelector(".stc-dock .stc-pill--primary").focus(), true`);
await key("Enter", 13);
await until(async () => (await title()) === "Watch the clip", "second lesson");
await until(async () => (await announced()) === "Watch the clip", "announcement");
expect(await onNext()).toBe(true);
await key("Enter", 13);
await until(async () => (await title()) === "Code along", "third lesson");
await until(async () => (await announced()) === "Code along", "announcement");
expect(await onNext()).toBe(true);
expect(await path()).toBe("/c/sample/m1-code");
// The outline keeps focus too: a sidebar link activated from the keyboard stays focused.
await chrome.eval(`document.querySelector('.lr-les[href="/c/sample/m1-intro"]').focus(), true`);
await key("Enter", 13);
await until(async () => (await title()) === "Introduction", "intro");
await until(async () => (await announced()) === "Introduction", "announcement");
expect(await chrome.eval<boolean>(`document.activeElement === document.querySelector('.lr-les[href="/c/sample/m1-intro"]')`)).toBe(true);
}, 30_000);
test("a click right after a navigation is handled", async () => {
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
await chrome.eval(`(() => {
document.querySelector(".stc-dock .stc-pill--primary").click();
setTimeout(() => document.querySelector('.lr-les[href="/c/sample/m1-code"]').click(), 0);
return true;
})()`);
await until(async () => (await path()) === "/c/sample/m1-code", "second navigation");
await until(async () => (await title()) === "Code along", "code lesson");
// A real pointer click within the entrance animation lands too.
await chrome.eval(`document.querySelector(".stc-dock .stc-pill--primary").click(), true`);
const r = JSON.parse(await rect('.lr-les[href="/c/sample/m1-intro"]')) as { x: number; y: number; width: number; height: number };
for (const type of ["mousePressed", "mouseReleased"]) await chrome.send("Input.dispatchMouseEvent", { type, x: r.x + r.width / 2, y: r.y + r.height / 2, button: "left", clickCount: 1 });
await until(async () => (await path()) === "/c/sample/m1-intro", "pointer click during the entrance");
}, 30_000);
test("a module opened by the user rises in; lists do not animate on page load or lesson change", async () => {
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
await click('.lr-les[href="/c/sample/m1-code"]');
await until(async () => (await title()) === "Code along", "code lesson");
await frames();
expect((await m()).anims.filter((a) => a.includes("lr-lessons"))).toEqual([]);
await reset();
const head = '[...document.querySelectorAll(".lr-mod-head")][1]';
expect(await chrome.eval<string>(`${head}.getAttribute("aria-expanded")`)).toBe("false");
await chrome.eval(`${head}.click(), true`);
expect(await chrome.eval<number>(`${head}.parentElement.querySelectorAll(".lr-lessons a").length`)).toBe(2);
await until(async () => (await m()).anims.includes("stc-enter@lr-lessons"), "list entrance");
}, 30_000);
test("folds open and close by animating their height", async () => {
// Chrome animates ::details-content without listing it in getAnimations(), so sample the height each frame
// until it settles at its end value.
const sample = (open: boolean) => chrome.eval<number[]>(`(() => {
const d = document.querySelector("details.fold.transcript");
d.querySelector("summary").click();
if (d.open !== ${open}) return [-1];
const h = () => parseFloat(getComputedStyle(d, "::details-content").height);
const out = [], t0 = performance.now();
return new Promise((r) => { const f = () => {
out.push(h());
const settled = out.length > 2 && out.at(-1) === out.at(-2) && (${open} ? out.at(-1) > 0 : out.at(-1) === 0);
if (settled || performance.now() - t0 > 3000) r(out); else requestAnimationFrame(f);
}; f(); });
})()`);
await open("/c/sample/m1-video", "details.fold.transcript");
const opening = await sample(true);
const full = opening.at(-1)!;
expect(full).toBeGreaterThan(20);
expect(opening.some((h) => h > 1 && h < full - 1)).toBe(true);
const closing = await sample(false);
expect(closing.some((h) => h > 1 && h < full - 1)).toBe(true);
expect(closing.at(-1)).toBe(0);
}, 30_000);
test("slides cross-fade in the direction of travel and the stage never blanks", async () => {
await open("/c/sample/m1-video", '.stage canvas[data-front][data-page="1"]');
// Sample each frame from the click until the old slide's hold-out has ended (or 5 s).
const sampled = await chrome.eval<{ minMax: number; frames: number }>(`(() => {
document.querySelector('.deckbar [aria-label="Next slide"]').click();
let minMax = 1, frames = 0;
const t0 = performance.now();
return new Promise((r) => { const f = () => {
const cs = [...document.querySelectorAll(".stage canvas")];
minMax = Math.min(minMax, Math.max(...cs.map((c) => Number(getComputedStyle(c).opacity))));
frames++;
if (window.__m.ends.some((e) => e.startsWith("stc-hold-out@")) || performance.now() - t0 > 5000) r({ minMax, frames }); else requestAnimationFrame(f);
}; f(); });
})()`);
expect(sampled.minMax).toBe(1);
const r = await m();
expect(r.anims.some((a) => a.startsWith("stc-slide-next@"))).toBe(true);
expect(r.ends.some((a) => a.startsWith("stc-hold-out@"))).toBe(true);
expect(await text(".deckbar .count")).toBe("2 / 3");
expect(await chrome.eval<number>(`document.querySelectorAll('.stage canvas[role="img"]').length`)).toBe(1);
expect(await chrome.eval<string>(`document.querySelector('.stage canvas[data-front]').getAttribute("aria-label")`)).toBe("Slide 2 of 3");
await reset();
await key("ArrowLeft", 37);
await until(() => exists('.stage canvas[data-front][data-page="1"]'), "previous slide");
await until(async () => (await m()).anims.some((a) => a.startsWith("stc-slide-prev@")), "mirrored motion");
// Two quick clicks: the second supersedes the first draw and the stage ends on the last slide.
await chrome.eval(`(() => { const b = document.querySelector('.deckbar [aria-label="Next slide"]'); b.click(); setTimeout(() => b.click(), 0); return true; })()`);
await until(() => exists('.stage canvas[data-front][data-page="3"]'), "slide 3 drawn");
expect(await text(".deckbar .count")).toBe("3 / 3");
}, 30_000);
test("progress fills, the toast and the course menu answer with motion; Escape still closes at once", async () => {
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
const status = await chrome.eval<number>(`fetch("/api/auth/sign-up/email", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" },
body: JSON.stringify({ email: "motion@example.org", name: "Motion", password: "correct horse battery" }) }).then((r) => r.status)`);
expect(status).toBe(200);
await open("/c/sample/m1-intro", ".stc-dock button[aria-pressed]:not([disabled])");
await click(".stc-dock button[aria-pressed]");
await until(async () => (await m()).trans.includes("width@i"), "progress fill transition");
await open("/c/sample/m2-ex-add", ".stc-seg button");
await chrome.eval(`[...document.querySelectorAll(".stc-seg button")].find((b) => b.textContent === "Reading").click(), true`);
await until(async () => (await m()).anims.includes("stc-enter@lr lr-toast"), "toast enter");
await until(async () => (await m()).anims.includes("stc-fade-out@lr lr-toast is-leaving"), "toast leave", 6000);
await until(async () => !(await exists(".lr-toast")), "toast removed", 3000);
await reset();
await click(".lr-switch-btn");
await until(async () => (await m()).anims.includes("stc-drop@lr-menu"), "menu enter");
await key("Escape", 27);
expect(await exists(".lr-menu")).toBe(false);
}, 40_000);
test("prefers-reduced-motion: nothing animates, and everything still works", async () => {
await setReducedMotion(true);
const still = async (what: string) => {
await frames();
const r = await m();
expect([what, r.vt, r.anims, r.trans, await running()]).toEqual([what, 0, [], [], []]);
await reset();
};
try {
await open("/", 'a[href="/c/sample"]');
await click('a[href="/c/sample"]');
await until(() => exists(".lr-main > article h1"), "course page");
await still("library to course");
await click('.lr-les[href="/c/sample/m1-intro"]');
await until(async () => (await title()) === "Introduction", "intro");
await still("course to lesson");
// Next with the lesson held back: the loading line is there, and static.
await chrome.eval("window.__gate = true");
await click(".stc-dock .stc-pill--primary");
await until(() => exists(".lr-main .stc-loading"), "loading line");
await still("loading line");
await chrome.eval("window.__release()");
await until(async () => (await title()) === "Watch the clip", "next lesson");
await until(async () => (await focusedH1()) === "Watch the clip", "focus");
await still("lesson next");
await chrome.eval(`[...document.querySelectorAll(".lr-mod-head")][1].click(), true`);
await still("module open");
const heights = await chrome.eval<number[]>(`(() => {
const d = document.querySelector("details.fold.transcript");
d.querySelector("summary").click();
const out = [];
return new Promise((r) => { const f = () => { out.push(parseFloat(getComputedStyle(d, "::details-content").height)); if (out.length >= 6) r(out); else requestAnimationFrame(f); }; f(); });
})()`);
expect(new Set(heights).size).toBe(1);
await still("fold");
await until(() => exists('.stage canvas[data-front][data-page="1"]'), "deck");
await click('.deckbar [aria-label="Next slide"]');
await until(() => exists('.stage canvas[data-front][data-page="2"]'), "slide 2");
await still("slide");
await click(".stc-dock button[aria-pressed]");
await until(() => chrome.eval<boolean>(`document.querySelector(".stc-dock button[aria-pressed]").getAttribute("aria-pressed") === "true"`), "marked done");
await still("progress");
await click(".lr-switch-btn");
await until(() => exists(".lr-menu"), "menu");
await still("menu");
await key("Escape", 27);
await open("/c/sample/m2-ex-add", ".stc-seg button");
await chrome.eval(`[...document.querySelectorAll(".stc-seg button")].find((b) => b.textContent === "Reading").click(), true`);
await until(() => exists(".lr-toast"), "toast");
await still("toast");
await until(async () => !(await exists(".lr-toast")), "toast removed", 6000);
expect((await m()).anims).toEqual([]);
} finally {
await setReducedMotion(false);
}
}, 60_000);
});
+1
View File
@@ -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);
});
+1
View File
@@ -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))];
}