fix(web,learner): navigation swaps at once; content-only entrance, loading line, focus (R19)

Drops document-level View Transitions and the lesson hold: they dropped clicks for 120-180 ms, froze the screen
while route code loaded, and let the dock act on a stale lesson. Navigation now swaps immediately; only the
content region enters (opacity and a 6 px rise, 160 ms); a 2 px loading line appears when code or data takes over
150 ms; the dock works from the outline and the URL, so a second Next advances again; focus moves to the new h1
and a polite live region announces it. Route chunks of a feature preload together so the sidebar never drops.
Scroll restore stops on user input; pdf.js never starts a load after cleanup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 21:49:49 +01:00
co-authored by Claude Opus 5.5
parent 305c262ff1
commit b5e00af4f7
18 changed files with 231 additions and 275 deletions
+75 -31
View File
@@ -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 <p className="work">No features installed yet</p>;
@@ -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<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;
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 (
<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 /> }])] },
]);
// 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<typeof plainNavigate>[0], opts?: Parameters<typeof plainNavigate>[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(<RouterProvider router={router} future={{ v7_startTransition: true }} />);
// 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} />);
-12
View File
@@ -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);
});
-12
View File
@@ -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<AuthUser, "roles">
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;
}
@@ -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()`.
@@ -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 <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,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<LessonPayload>(`/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 <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) {
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
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 "}${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 || held.stale} 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>
@@ -185,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 ?? [];
@@ -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
<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 ${page} of ${pages}` : undefined}
role={front ? "img" : undefined} aria-hidden={front ? undefined : true} aria-label={front ? `Slide ${view.page || page} of ${pages}` : undefined}
/>
);
})}
@@ -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>
);
}
+6 -5
View File
@@ -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; }
@@ -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 });
});
+2 -2
View File
@@ -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";
-50
View File
@@ -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<T>(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<T>(next: T | null, keyOf: (v: T) => string, holdable?: (prev: T) => boolean): { value: T | null; stale: boolean } {
const [shown, setShown] = useState<T | null>(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 };
}
+14 -1
View File
@@ -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;
}
+17 -25
View File
@@ -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<string, number>();
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);
};
}
+18 -21
View File
@@ -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; }
}
+15 -12
View File
@@ -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<string, number> {
@@ -19,7 +21,7 @@ function tokens(): Record<string, number> {
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-]+)\)|(?<![\w.-])([\d.]+)(ms|s)\b/g)) {
if (d[1]) { if (d[1] in t) durations.push(t[d[1]]!); continue; }
@@ -51,6 +53,7 @@ test("keyframes animate only opacity and short translates", () => {
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\)/);
});
-56
View File
@@ -1,56 +0,0 @@
import { afterEach, expect, test } from "bun:test";
import { holdDecision, reducedMotion, viewTransition } from "../src/motion";
const g = globalThis as Record<string, unknown>;
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<L>(null, null, key)).toBe("show-next");
expect(holdDecision<L>(null, a, key)).toBe("show-next");
expect(holdDecision<L>(a, a, key)).toBe("show-next");
expect(holdDecision<L>(a, null, key, sameCourse("x"))).toBe("hold");
expect(holdDecision<L>(a, null, key, sameCourse("y"))).toBe("replace");
expect(holdDecision<L>(a, b, key)).toBe("swap");
expect(holdDecision<L>(a, a2, key)).toBe("replace");
expect(holdDecision<L>(a, b, key, sameCourse("y"))).toBe("replace");
});
+18
View File
@@ -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("");
});
+9 -10
View File
@@ -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", () => {