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;
}