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

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