diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index eb78b13..67c1fa0 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -1,10 +1,10 @@
import { createRoot } from "react-dom/client";
-import { lazy, Suspense, useEffect, useLayoutEffect, useState } from "react";
-import { createBrowserRouter, createPath, Link, NavLink, Outlet, RouterProvider, useLocation, useNavigationType, 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, installRouteScroll, reducedMotion, routeScroll } from "@stc/ui";
+import { AppShell, installRouteScroll, LoadingLine, routeScroll } from "@stc/ui";
import { webFeatures } from "./features";
-import { changesPage, visibleNav } from "./nav";
+import { visibleNav } from "./nav";
function Home() {
return
No features installed yet
;
@@ -25,18 +25,50 @@ function useSessionUser(): AuthUser | null {
return user;
}
-/** Scroll per history entry: top on a new page, the saved position on back/forward (a page may defer it). */
+/** 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(), []);
- // A layout effect, so with a view transition the scroll is part of the captured new state. Pages' layout
- // effects run before this one, so a page keeping its old content on screen can defer it first.
useLayoutEffect(() => { routeScroll().routeChanged(loc.key, type === "POP", loc.hash); }, [loc.key]);
}
+/**
+ * After a navigation, focus moves to the new page's heading (its first h1, given tabindex -1) once it renders, and
+ * its text goes to a polite live region. The first page load keeps the browser's own focus.
+ */
+function useRouteFocus(): string {
+ const loc = useLocation();
+ const [said, setSaid] = useState("");
+ const last = useRef(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("h1");
+ if (!h || h === last.current) return false;
+ last.current = h;
+ if (initial) return true;
+ if (!h.hasAttribute("tabindex")) h.setAttribute("tabindex", "-1");
+ h.focus({ preventScroll: true });
+ setSaid(h.textContent ?? "");
+ 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 = (
@@ -50,35 +82,47 @@ function Layout() {
>
);
return (
-
-
-
+ <>
+
+ }>
+
+ {said}
+ >
);
}
-const featureRoutes: RouteObject[] = webFeatures.flatMap((f) =>
- f.routes.map((r) => {
- const Element = lazy(r.element);
- return { path: r.path, 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 | 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 ;
+ }
+ 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: };
+ });
+});
const router = createBrowserRouter([
{ element: , children: [...featureRoutes, ...(featureRoutes.some((r) => r.path === "/") ? [] : [{ path: "*", element: }])] },
]);
-// Navigating to another kind of page (library, course, lesson, account, studio…) runs as a view transition: the old
-// page dissolves while the new one's content rises in (styles in @stc/ui tokens.css). Steps within a page (lesson
-// to lesson) are animated by the page itself. Reduced motion and browsers without the API navigate plainly.
-// Router updates run in a React transition (v7_startTransition), so a route whose code is still loading keeps the
-// current page up instead of falling back to nothing; the view transition captures the new page once it renders
-// (with the course sidebar still in place).
-const routePatterns = featureRoutes.map((r) => ({ path: r.path }));
-const plainNavigate = router.navigate.bind(router);
-router.navigate = ((to: Parameters[0], opts?: Parameters[1]) => {
- const animate = typeof to !== "number" && to !== null && "startViewTransition" in document && !reducedMotion()
- && changesPage(routePatterns, router.state.location.pathname, typeof to === "string" ? to : createPath(to));
- return plainNavigate(to as never, animate ? { ...opts, viewTransition: opts?.viewTransition ?? true } : opts);
-}) as typeof router.navigate;
-
-createRoot(document.getElementById("root")!).render();
+// 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();
diff --git a/apps/web/src/nav.test.ts b/apps/web/src/nav.test.ts
index 5abf80d..d5d1a3f 100644
--- a/apps/web/src/nav.test.ts
+++ b/apps/web/src/nav.test.ts
@@ -20,15 +20,3 @@ test("features.ts imports only browser entries, by package name", () => {
expect(specs.filter((s) => s.endsWith("/web")).length).toBe(4);
});
-test("changesPage: a view transition for a new kind of page, none between steps of the same page", async () => {
- const { changesPage } = await import("./nav");
- const routes = webFeatures.flatMap((f) => f.routes.map((r) => ({ path: r.path })));
- expect(changesPage(routes, "/", "/c/sample")).toBe(true);
- expect(changesPage(routes, "/c/sample", "/c/sample/m1-intro")).toBe(true);
- expect(changesPage(routes, "/c/sample/m1-intro", "/c/sample/notes")).toBe(true);
- expect(changesPage(routes, "/c/sample/m1-intro", "/c/sample/m1-code")).toBe(false);
- expect(changesPage(routes, "/c/sample/m1-intro", "/c/other/x?t=3")).toBe(false);
- expect(changesPage(routes, "/c/sample/m1-video", "/c/sample/m1-video?deck=d1&slide=2")).toBe(false);
- expect(changesPage(routes, "/c/a%20b/x", "/c/a%20b/y")).toBe(false);
- expect(changesPage(routes, "/c/sample", "/nowhere/at/all/deep")).toBe(true);
-});
diff --git a/apps/web/src/nav.ts b/apps/web/src/nav.ts
index 39343fe..6577a98 100644
--- a/apps/web/src/nav.ts
+++ b/apps/web/src/nav.ts
@@ -1,4 +1,3 @@
-import { matchRoutes } from "react-router-dom";
import type { AuthUser, Role, WebFeature, WebRoute } from "@stc/contracts";
/** Admin-area links default to the owner; header links default to everyone. `nav.roles` overrides both. */
@@ -15,14 +14,3 @@ export function visibleNav(features: WebFeature[], user: Pick
return [...all.filter((r) => r.nav!.area === "header" && allowed(r)), ...all.filter((r) => r.nav!.area === "admin" && allowed(r))];
}
-/**
- * Whether navigating from `fromPath` to `to` shows another kind of page (another route pattern), which gets a
- * view transition. Steps within one page (lesson to lesson, a query change) do not: the page animates them itself.
- */
-export function changesPage(routes: { path?: string }[], fromPath: string, to: string): boolean {
- const pattern = (path: string) => matchRoutes(routes, path)?.at(-1)?.route.path ?? null;
- let toPath: string;
- try { toPath = new URL(to, `http://x${fromPath}`).pathname; } catch { return true; }
- const a = pattern(fromPath), b = pattern(toPath);
- return a === null || b === null || a !== b;
-}
diff --git a/docs/superpowers/plans/2026-10-30-v11-motion.md b/docs/superpowers/plans/2026-10-30-v11-motion.md
index 84808b0..e0d93ad 100644
--- a/docs/superpowers/plans/2026-10-30-v11-motion.md
+++ b/docs/superpowers/plans/2026-10-30-v11-motion.md
@@ -215,3 +215,13 @@
- **`*` 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()`.
diff --git a/packages/feature-learner/src/web/course/pages.tsx b/packages/feature-learner/src/web/course/pages.tsx
index 9e2dbfb..9950738 100644
--- a/packages/feature-learner/src/web/course/pages.tsx
+++ b/packages/feature-learner/src/web/course/pages.tsx
@@ -1,7 +1,7 @@
-import { useCallback, useEffect, useLayoutEffect, useMemo, type CSSProperties } from "react";
+import { useCallback, useEffect, useMemo, type CSSProperties } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
-import { CourseCover, FloatingActionBar, Pill, ProgressBar, routeScroll, useHeldValue } 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 ;
- if (!course) return null;
+ if (!course) return ;
const { done, total } = courseDone(course);
const next = nextLesson(course);
const started = Object.keys(course.progress).length > 0;
@@ -100,74 +100,76 @@ 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(`/api/courses/${encodeURIComponent(courseId)}/lessons/${encodeURIComponent(lessonId)}`);
- const loaded = lessonApi.data && lessonApi.data.lesson.id === lessonId && lessonApi.data.courseId === courseId ? lessonApi.data : null;
- // Lesson to lesson: the current lesson stays on screen while the next loads, then the two swap in a view
- // transition, so the column never blanks. Only within one course; the sidebar highlight moves at once.
- const held = useHeldValue(loaded, (l) => l.lesson.id, (l) => l.courseId === courseId && !lessonApi.error);
- const lesson = held.value;
+ // 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 ?? []);
const q = useMemo(() => new URLSearchParams(loc.search), [loc.search]);
const initialTime = q.has("t") ? Number(q.get("t")) : null;
const initialSlide = q.has("slide") && q.has("deck") ? { deckId: q.get("deck")!, page: Number(q.get("slide")) } : null;
- const shownId = lesson?.lesson.id ?? lessonId;
- const progress = course?.progress[shownId] ?? null;
+ const progress = course?.progress[lessonId] ?? null;
const signedIn = !!user;
useEffect(() => { if (lesson) document.title = `${lesson.lesson.title.replace(/`/g, "")} – ${lesson.courseTitle}`; }, [lesson]);
- // While the old lesson is held, the shell's scroll for the new entry waits (this runs before the shell's
- // layout effect) and is applied once the new lesson is on screen, inside the same transition.
- useLayoutEffect(() => { if (held.stale) routeScroll().defer(); }, [loc.key]);
- useLayoutEffect(() => { if (!held.stale || lessonApi.error) routeScroll().settle(); }, [lesson, held.stale, lessonApi.error]);
const setState = useCallback(async (state: ProgressState, positionSeconds?: number) => {
if (!signedIn) return;
try {
- await api(`/api/courses/${encodeURIComponent(courseId)}/progress/${encodeURIComponent(shownId)}`, { method: "PUT", body: { state, positionSeconds } });
+ await api(`/api/courses/${encodeURIComponent(courseId)}/progress/${encodeURIComponent(lessonId)}`, { method: "PUT", body: { state, positionSeconds } });
if (state === "done" || progress?.state !== state) { await reload(); void refreshLibrary(); }
} catch (e) { toast(`Progress was not saved: ${(e as Error).message}`); }
- }, [courseId, shownId, signedIn, progress?.state, reload]);
+ }, [courseId, lessonId, signedIn, progress?.state, reload]);
if (courseError && !course) return ;
- if (lessonApi.error && course) {
+ if (!course) return ;
+ if (lessonApi.error) {
return (
Lesson not found
This course has no lesson “{lessonId}”.
);
}
- if (!course || !lesson) {
- return course ? : 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 (
- { void reload(); void refreshLibrary(); }}
- initialTime={initialTime} initialSlide={initialSlide}
- moduleLabel={`${/^(section|part|chapter)\b/i.test(lesson.module.title) ? "" : "Module "}${n}`.trim()}
- />
+ {lesson ? (
+ { 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()}
+ />
+ ) :
}
- lesson.prev && navigate(lessonHref(courseId, lesson.prev.id))}>
+ prev && navigate(lessonHref(courseId, prev.id))}>
Previous lesson
- void setState(isDone ? "not-started" : "done")}>
{isDone ? "Done" : "Mark done"}
- lesson.next && navigate(lessonHref(courseId, lesson.next.id))}>
+ next && navigate(lessonHref(courseId, next.id))}>
Next lesson
@@ -185,7 +187,7 @@ export function NotesPage() {
const notesApi = useApi(user ? `/api/courses/${encodeURIComponent(courseId)}/notes` : null);
useEffect(() => { if (course) document.title = `Your notes – ${course.title}`; }, [course]);
if (error && !course) return ;
- if (!course) return null;
+ if (!course) return ;
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 ?? [];
diff --git a/packages/feature-learner/src/web/lesson/DeckViewer.tsx b/packages/feature-learner/src/web/lesson/DeckViewer.tsx
index a644837..e697fe9 100644
--- a/packages/feature-learner/src/web/lesson/DeckViewer.tsx
+++ b/packages/feature-learner/src/web/lesson/DeckViewer.tsx
@@ -78,6 +78,7 @@ export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, titl
setDoc(null); setError(null); setPages(0); setView({ front: 0, page: 0, dir: null });
loadPdfjs()
.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;
@@ -146,7 +147,7 @@ export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, titl
);
})}
diff --git a/packages/feature-learner/src/web/library/LibraryPage.tsx b/packages/feature-learner/src/web/library/LibraryPage.tsx
index 3667f11..707bb8a 100644
--- a/packages/feature-learner/src/web/library/LibraryPage.tsx
+++ b/packages/feature-learner/src/web/library/LibraryPage.tsx
@@ -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 (
- {lib ?
:
}
+ {lib ?
:
}
);
}
diff --git a/packages/feature-learner/src/web/styles.ts b/packages/feature-learner/src/web/styles.ts
index d1861ca..39b17ea 100644
--- a/packages/feature-learner/src/web/styles.ts
+++ b/packages/feature-learner/src/web/styles.ts
@@ -39,9 +39,9 @@ export const learnerCss = String.raw`
/* ---------- course shell ---------- */
.stc-main:has(> .lr-shell) { padding: 0; }
.lr-shell { display: flex; align-items: flex-start; }
-.lr-side { view-transition-name: 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-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; }
@@ -332,9 +332,10 @@ export const learnerCss = String.raw`
.lr-toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 60; background: var(--raised); border: 1px solid var(--line2); border-radius: 18px; padding: 9px 16px; font-size: 13px; color: var(--text); }
/* ---------- motion ----------
- New content rises in when it arrives (a page, a lesson, the library); the header, sidebar and dock stay put.
- With view transitions the old page dissolves over it (tokens.css); without them this is the whole effect. */
-.lr-main .lr-lesson, .lr-main > article, .stc-main > .lr > article, .lr-lib { animation: stc-enter var(--t-enter) var(--ease-out) backwards; }
+ 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; }
diff --git a/packages/feature-learner/test/web-shell.test.tsx b/packages/feature-learner/test/web-shell.test.tsx
index b44945c..7e893cf 100644
--- a/packages/feature-learner/test/web-shell.test.tsx
+++ b/packages/feature-learner/test/web-shell.test.tsx
@@ -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 });
+});
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 12597e5..f310c66 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -1,4 +1,4 @@
export { AppShell } from "./AppShell";
-export { Pill, SegmentedToggle, ProgressBar, FloatingActionBar, CourseCover, coverFits, monogram } from "./primitives";
-export { reducedMotion, viewTransition, holdDecision, useHeldValue, type HoldDecision } from "./motion";
+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";
diff --git a/packages/ui/src/motion.ts b/packages/ui/src/motion.ts
index e8bae8f..2fd18da 100644
--- a/packages/ui/src/motion.ts
+++ b/packages/ui/src/motion.ts
@@ -1,55 +1,5 @@
-import { useEffect, useLayoutEffect, useState } from "react";
-import { flushSync } from "react-dom";
-
/** 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;
}
-
-type VtDocument = { startViewTransition?: (update: () => void) => unknown; visibilityState?: string };
-
-/**
- * Runs a React state update inside a view transition when the browser supports one and motion is allowed, and
- * directly otherwise. The update is synchronous (flushSync), so the new state is captured at once and nothing
- * waits on it. The look of the transition is in tokens.css.
- */
-export function viewTransition(update: () => void): void {
- const doc = (typeof document === "undefined" ? null : document) as VtDocument | null;
- if (!doc || typeof doc.startViewTransition !== "function" || doc.visibilityState === "hidden" || reducedMotion()) {
- update();
- return;
- }
- doc.startViewTransition(() => flushSync(update));
-}
-
-export type HoldDecision = "show-next" | "hold" | "swap" | "replace";
-
-/**
- * What `useHeldValue` does with the value on screen (`shown`) when the wanted one is `next` (null while loading):
- * show it, keep the old one while loading ("hold"), swap with a transition (a different key) or replace it
- * plainly (the same key reloaded, or an old value that may not be held, such as another course's lesson).
- */
-export function holdDecision(shown: T | null, next: T | null, keyOf: (v: T) => string, holdable: (prev: T) => boolean = () => true): HoldDecision {
- if (shown === null || shown === next) return "show-next";
- if (!holdable(shown)) return "replace";
- if (next === null) return "hold";
- return keyOf(shown) === keyOf(next) ? "replace" : "swap";
-}
-
-const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
-
-/**
- * Keeps the previous value on screen while the next one loads, then swaps to it inside a view transition, so a
- * page never blanks between two steps. `stale` is true while the old value is shown.
- */
-export function useHeldValue(next: T | null, keyOf: (v: T) => string, holdable?: (prev: T) => boolean): { value: T | null; stale: boolean } {
- const [shown, setShown] = useState(next);
- const decision = holdDecision(shown, next, keyOf, holdable);
- useIsoLayoutEffect(() => {
- if (decision === "swap") viewTransition(() => setShown(next));
- else if (shown !== next && decision !== "hold") setShown(next);
- }, [next, decision]);
- const value = decision === "hold" || decision === "swap" ? shown : next;
- return { value, stale: value !== next };
-}
diff --git a/packages/ui/src/primitives.tsx b/packages/ui/src/primitives.tsx
index be3f76f..9de9a27 100644
--- a/packages/ui/src/primitives.tsx
+++ b/packages/ui/src/primitives.tsx
@@ -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) {
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
);
}
+
+/**
+ * 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 ? : null;
+}
diff --git a/packages/ui/src/scroll.ts b/packages/ui/src/scroll.ts
index 4a310e2..0092a41 100644
--- a/packages/ui/src/scroll.ts
+++ b/packages/ui/src/scroll.ts
@@ -17,10 +17,8 @@ export interface ScrollKeeper {
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;
- /** A page that keeps the previous content on screen while the next loads calls this before the shell's routeChanged. */
- defer(): void;
- /** ...and this once the new content is in place; it applies the deferred route scroll. No-op otherwise. */
- settle(): 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 }
@@ -28,7 +26,10 @@ interface Route { key: string; pop: boolean; hash?: string }
const KEEP = 60;
const RETRY_MS = 1000;
-/** Scroll positions per history entry, restored on back/forward even when the page renders its content late. */
+/**
+ * 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();
try {
@@ -36,8 +37,6 @@ export function createScrollKeeper(env: ScrollEnv): ScrollKeeper {
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 deferNext = false;
- let pending: Route | null = null;
let run = 0;
let restoring = false;
let persistQueued = false;
@@ -78,23 +77,11 @@ export function createScrollKeeper(env: ScrollEnv): ScrollKeeper {
saved.set(current, env.getY());
persist();
},
- routeChanged(key, pop, hash) {
- if (deferNext) {
- deferNext = false;
- run++; // a restore still retrying for the previous entry stops
- restoring = false;
- pending = { key, pop, hash };
- return;
- }
- pending = null;
- apply({ key, pop, hash });
- },
- defer() { deferNext = true; },
- settle() {
- if (!pending) return;
- const r = pending;
- pending = null;
- apply(r);
+ routeChanged(key, pop, hash) { apply({ key, pop, hash }); },
+ userInput() {
+ if (!restoring) return;
+ run++;
+ restoring = false;
},
};
}
@@ -121,6 +108,11 @@ 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 });
- return () => window.removeEventListener("scroll", onScroll);
+ 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);
+ };
}
diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css
index 137ea82..60615b3 100644
--- a/packages/ui/src/tokens.css
+++ b/packages/ui/src/tokens.css
@@ -8,8 +8,8 @@
--mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
--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-enter: 180ms; --t-fold: 200ms; --t-bar: 220ms; --fast: 140ms;
- --ease-out: cubic-bezier(.2, .7, .3, 1); --rise: 5px;
+ --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; }
@@ -26,7 +26,7 @@ code, pre { font-family: var(--mono); }
.work { font-family: var(--sans); font-size: 15.5px; line-height: 1.6; color: var(--text); max-width: 760px; }
/* shell */
-.stc-top { view-transition-name: stc-top; position: sticky; top: 0; z-index: 30; height: var(--top); display: flex; align-items: center; gap: 14px; padding: 0 16px; background: var(--ground); border-bottom: 1px solid var(--line); }
+.stc-top { position: sticky; top: 0; z-index: 30; height: var(--top); display: flex; align-items: center; gap: 14px; padding: 0 16px; background: var(--ground); border-bottom: 1px solid var(--line); }
/* quiet pill nav, as the mockup's segmented control: muted text, the current area raised in accent */
.stc-nav { display: flex; align-items: center; gap: 2px; }
.stc-nav a { display: inline-flex; align-items: center; height: 28px; padding: 0 12px; border-radius: 16px; color: var(--muted); text-decoration: none; font-size: 13px; font-weight: 500; transition: background 120ms ease-out, color 120ms ease-out; }
@@ -36,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); }
@@ -69,7 +69,10 @@ code, pre { font-family: var(--mono); }
.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 ---------- */
+/* ---------- 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; } }
@@ -78,26 +81,20 @@ code, pre { font-family: var(--mono); }
@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-enter { animation: stc-enter var(--t-enter) var(--ease-out) both; }
+.stc-page { animation: stc-page var(--t-page) var(--ease-out) backwards; }
-/* View transitions (route changes, lesson swaps). The old page dissolves over the live new one, whose content
- plays its own enter animation; the header and the course sidebar are their own groups and do not move.
- While a transition runs the browser sends pointer input to the document, so it is kept to --t-exit: the root
- group's default 250 ms morph is off (old and new root are both the viewport). Keyboard input is unaffected. */
-::view-transition-group(root) { animation: none; }
-::view-transition-old(root), ::view-transition-new(root) { mix-blend-mode: normal; }
-::view-transition-old(root) { z-index: 1; animation: stc-fade-out var(--t-exit) var(--ease-out) both; }
-::view-transition-new(root) { animation: none; }
-::view-transition-group(stc-top), ::view-transition-old(stc-top), ::view-transition-new(stc-top),
-::view-transition-group(lr-side), ::view-transition-old(lr-side), ::view-transition-new(lr-side) { animation: none; mix-blend-mode: normal; }
-::view-transition-old(stc-top), ::view-transition-old(lr-side) { opacity: 0; }
-/* the sidebar fades in or out only when the page gains or loses it (library to course and back) */
-::view-transition-new(lr-side):only-child { animation: stc-fade-in var(--t-enter) var(--ease-out) both; }
-::view-transition-old(lr-side):only-child { opacity: 1; animation: stc-fade-out var(--t-exit) var(--ease-out) both; }
+/* 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; }
- ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
/* separate rule: browsers without ::details-content drop only this one */
*::details-content { transition: none !important; }
}
diff --git a/packages/ui/test/motion-css.test.ts b/packages/ui/test/motion-css.test.ts
index 387c3e3..86e73e3 100644
--- a/packages/ui/test/motion-css.test.ts
+++ b/packages/ui/test/motion-css.test.ts
@@ -9,6 +9,8 @@ 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 {
@@ -19,7 +21,7 @@ function tokens(): Record {
test("the motion duration tokens exist and are 120–220 ms", () => {
const t = tokens();
- for (const name of ["--t-exit", "--t-fast", "--t-enter", "--t-fold", "--t-bar"]) {
+ 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);
@@ -32,7 +34,7 @@ test("every animation and transition duration is 0 or 120–220 ms", () => {
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)) continue;
+ if (/^\s*none/.test(value) || INDICATORS.some((n) => value.includes(n))) continue;
const durations: number[] = [];
for (const d of value.matchAll(/var\((--[\w-]+)\)|(? {
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;
@@ -77,7 +80,7 @@ test("easing curves do not overshoot (no bounce)", () => {
expect(bad).toEqual([]);
});
-test("prefers-reduced-motion turns off every animation, transition and view transition", () => {
+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);
@@ -85,18 +88,18 @@ test("prefers-reduced-motion turns off every animation, transition and view tran
expect(block).toMatch(/\*,\s*\*::before,\s*\*::after/);
expect(block).toContain("animation: none !important");
expect(block).toContain("transition: none !important");
- expect(block).toMatch(/::view-transition-group\(\*\)/);
- expect(block).toMatch(/::view-transition-old\(\*\)/);
- expect(block).toMatch(/::view-transition-new\(\*\)/);
// `*` 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("view transitions are short and keep the header and sidebar still", () => {
+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);
- // the UA default (a 250 ms root morph) would outlast the 220 ms ceiling
- expect(css).toMatch(/::view-transition-group\(root\)\s*\{\s*animation:\s*none/);
- expect(css).toMatch(/\.stc-top\s*\{[^}]*view-transition-name:\s*stc-top/);
- expect(strip(learnerCss)).toMatch(/\.lr-side\s*\{[^}]*view-transition-name:\s*lr-side/);
- expect(css).toMatch(/::view-transition-group\(stc-top\)[^{]*\{[^}]*animation:\s*none/);
+ 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\)/);
});
diff --git a/packages/ui/test/motion.test.ts b/packages/ui/test/motion.test.ts
deleted file mode 100644
index a28bdd3..0000000
--- a/packages/ui/test/motion.test.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { afterEach, expect, test } from "bun:test";
-import { holdDecision, reducedMotion, viewTransition } from "../src/motion";
-
-const g = globalThis as Record;
-afterEach(() => { delete g.document; delete g.window; });
-
-function env({ reduce = false, vt = true, hidden = false } = {}) {
- const calls: string[] = [];
- g.window = { matchMedia: (q: string) => ({ matches: q.includes("prefers-reduced-motion: reduce") && reduce }) };
- g.document = {
- visibilityState: hidden ? "hidden" : "visible",
- ...(vt ? { startViewTransition: (cb: () => void) => { calls.push("vt"); cb(); return { finished: Promise.resolve() }; } } : {}),
- };
- return calls;
-}
-
-test("reducedMotion follows the media query and is false without a window", () => {
- expect(reducedMotion()).toBe(false);
- env({ reduce: true });
- expect(reducedMotion()).toBe(true);
- env({ reduce: false });
- expect(reducedMotion()).toBe(false);
-});
-
-test("viewTransition runs the update inside startViewTransition when it can", () => {
- const calls = env();
- viewTransition(() => calls.push("update"));
- expect(calls).toEqual(["vt", "update"]);
-});
-
-test("viewTransition runs the update directly with reduced motion, without support, or in a hidden tab", () => {
- for (const o of [{ reduce: true }, { vt: false }, { hidden: true }]) {
- const calls = env(o);
- viewTransition(() => calls.push("update"));
- expect([o, calls]).toEqual([o, ["update"]]);
- }
- delete g.document; delete g.window;
- const plain: string[] = [];
- viewTransition(() => plain.push("update"));
- expect(plain).toEqual(["update"]);
-});
-
-test("holdDecision: show the first value, hold while loading, swap to a new key, replace the same key", () => {
- type L = { id: string; course: string };
- const key = (l: L) => l.id;
- const sameCourse = (c: string) => (l: L) => l.course === c;
- const a = { id: "a", course: "x" }, b = { id: "b", course: "x" }, a2 = { id: "a", course: "x" };
- expect(holdDecision(null, null, key)).toBe("show-next");
- expect(holdDecision(null, a, key)).toBe("show-next");
- expect(holdDecision(a, a, key)).toBe("show-next");
- expect(holdDecision(a, null, key, sameCourse("x"))).toBe("hold");
- expect(holdDecision(a, null, key, sameCourse("y"))).toBe("replace");
- expect(holdDecision(a, b, key)).toBe("swap");
- expect(holdDecision(a, a2, key)).toBe("replace");
- expect(holdDecision(a, b, key, sameCourse("y"))).toBe("replace");
-});
diff --git a/packages/ui/test/motion.test.tsx b/packages/ui/test/motion.test.tsx
new file mode 100644
index 0000000..4f3f913
--- /dev/null
+++ b/packages/ui/test/motion.test.tsx
@@ -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;
+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()).toBe("");
+});
diff --git a/packages/ui/test/scroll.test.ts b/packages/ui/test/scroll.test.ts
index a8c94f6..fef8fdc 100644
--- a/packages/ui/test/scroll.test.ts
+++ b/packages/ui/test/scroll.test.ts
@@ -43,21 +43,20 @@ test("a hash scrolls its target into view", () => {
expect(env.y).toBe(0);
});
-test("defer() keeps the old entry until settle(), which then applies the route scroll", () => {
+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 = 500; k.onScroll();
- k.defer();
+ env.y = 2000; k.onScroll();
k.routeChanged("b", false);
- expect(env.y).toBe(500); // the old content is still on screen
- env.y = 520; k.onScroll(); // scrolling the old content still belongs to "a"
- k.settle();
- expect(env.y).toBe(0);
+ env.max = 300;
k.routeChanged("a", true);
- expect(env.y).toBe(520);
- k.settle(); // nothing pending: no-op
- expect(env.y).toBe(520);
+ 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", () => {