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:
1 parent
305c262ff1
commit
b5e00af4f7
18 files changed
+231
-275
No files matched your search
@@ -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\)/);
|
||||
});
|
||||
Reference in new issue
Block a user