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"} @@ -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
); } + +/** + * 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 ?