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\)/);
});