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 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 21:49:49 +01:00
1 parent 305c262ff1
commit b5e00af4f7
18 files changed
+231 -275

No files matched your search

+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", () => {