merge: v1.1 navigation motion
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
6f0a297345
22 files changed
+1401
-80
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import type { AuthUser } from "@stc/contracts";
|
||||
import { reducedMotion } from "@stc/ui";
|
||||
import type { CoursePayload, LibraryPayload } from "../shared/types";
|
||||
import { learnerCss } from "./styles";
|
||||
|
||||
@@ -154,14 +155,22 @@ export function clearCourseCache(): void { courseCache.clear(); courseVersion.se
|
||||
|
||||
/* ---------- toast ---------- */
|
||||
|
||||
const toastStore = store<string | null>(null);
|
||||
export interface ToastState { text: string; leaving: boolean }
|
||||
const toastStore = store<ToastState | null>(null);
|
||||
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
/** How long the toast takes to fade out (--t-exit). */
|
||||
const TOAST_EXIT_MS = 120;
|
||||
export function toast(msg: string): void {
|
||||
toastStore.set(msg);
|
||||
toastStore.set({ text: msg, leaving: false });
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => toastStore.set(null), 2600);
|
||||
toastTimer = setTimeout(() => {
|
||||
const t = toastStore.get();
|
||||
if (!t || reducedMotion()) { toastStore.set(null); return; }
|
||||
toastStore.set({ ...t, leaving: true });
|
||||
toastTimer = setTimeout(() => toastStore.set(null), TOAST_EXIT_MS);
|
||||
}, 2600);
|
||||
}
|
||||
export function useToast(): string | null { return useStore(toastStore); }
|
||||
export function useToast(): ToastState | null { return useStore(toastStore); }
|
||||
|
||||
/* ---------- formatting ---------- */
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, type CSSProperties } from "react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { CourseCover, FloatingActionBar, Pill, ProgressBar } from "@stc/ui";
|
||||
import type { CoursePayload, LessonPayload, Note, ProgressState } from "../../shared/types";
|
||||
import { CourseCover, FloatingActionBar, LoadingLine, Pill, ProgressBar } from "@stc/ui";
|
||||
import type { CoursePayload, LessonPayload, Note, OutlineLesson, ProgressState } from "../../shared/types";
|
||||
import { api, ensureStyles, fmtMinutes, fmtTime, lessonHref, refreshLibrary, toast, useApi, useCourse, useLook, useMe, useTrack } from "../api";
|
||||
import { Check, Next, Prev } from "../icons";
|
||||
import { CourseFrame, courseDone, moduleNumber } from "../shell/CourseFrame";
|
||||
@@ -52,7 +52,7 @@ export function CoursePage() {
|
||||
const [look] = useLook();
|
||||
useEffect(() => { if (course) document.title = `${course.title} – STCourses`; }, [course]);
|
||||
if (error && !course) return <CourseMissing status={error.status} signedIn={!!user} />;
|
||||
if (!course) return null;
|
||||
if (!course) return <LoadingLine />;
|
||||
const { done, total } = courseDone(course);
|
||||
const next = nextLesson(course);
|
||||
const started = Object.keys(course.progress).length > 0;
|
||||
@@ -100,13 +100,22 @@ export function CoursePage() {
|
||||
|
||||
/* ---------- /c/:courseId/:lessonId ---------- */
|
||||
|
||||
/** The lessons before and after `lessonId` in course order (the same flat order the lesson API uses). */
|
||||
export function lessonNeighbours(c: CoursePayload, lessonId: string): { current: OutlineLesson | null; prev: OutlineLesson | null; next: OutlineLesson | null } {
|
||||
const flat = c.modules.flatMap((m) => m.lessons);
|
||||
const i = flat.findIndex((l) => l.id === lessonId);
|
||||
return i < 0 ? { current: null, prev: null, next: null } : { current: flat[i]!, prev: flat[i - 1] ?? null, next: flat[i + 1] ?? null };
|
||||
}
|
||||
|
||||
export function LessonPage() {
|
||||
const { courseId = "", lessonId = "" } = useParams();
|
||||
const loc = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { course, error: courseError, reload } = useCourse(courseId);
|
||||
const lessonApi = useApi<LessonPayload>(`/api/courses/${encodeURIComponent(courseId)}/lessons/${encodeURIComponent(lessonId)}`);
|
||||
const lesson = lessonApi.data;
|
||||
// Only the lesson the URL names is ever shown: while it loads the column is empty (a loading line after 150 ms),
|
||||
// never the previous lesson.
|
||||
const lesson = lessonApi.data && lessonApi.data.lesson.id === lessonId && lessonApi.data.courseId === courseId ? lessonApi.data : null;
|
||||
const { user } = useMe();
|
||||
const [look] = useLook();
|
||||
const [track, setTrack] = useTrack(courseId, course?.tracks ?? lesson?.tracks ?? []);
|
||||
@@ -117,7 +126,6 @@ export function LessonPage() {
|
||||
const signedIn = !!user;
|
||||
|
||||
useEffect(() => { if (lesson) document.title = `${lesson.lesson.title.replace(/`/g, "")} – ${lesson.courseTitle}`; }, [lesson]);
|
||||
useEffect(() => { window.scrollTo(0, 0); }, [lessonId]);
|
||||
|
||||
const setState = useCallback(async (state: ProgressState, positionSeconds?: number) => {
|
||||
if (!signedIn) return;
|
||||
@@ -128,37 +136,40 @@ export function LessonPage() {
|
||||
}, [courseId, lessonId, signedIn, progress?.state, reload]);
|
||||
|
||||
if (courseError && !course) return <CourseMissing status={courseError.status} signedIn={signedIn} />;
|
||||
if (lessonApi.error && course) {
|
||||
if (!course) return <LoadingLine />;
|
||||
if (lessonApi.error) {
|
||||
return (
|
||||
<CourseFrame course={course} currentLessonId={lessonId} look={look} lookMode={null}>
|
||||
<article className="col"><h1 className="title">Lesson not found</h1><p className="source">This course has no lesson “{lessonId}”.</p></article>
|
||||
</CourseFrame>
|
||||
);
|
||||
}
|
||||
if (!course || !lesson || lesson.lesson.id !== lessonId) {
|
||||
return course ? <CourseFrame course={course} currentLessonId={lessonId} look={look} lookMode={null}><div className="col" /></CourseFrame> : null;
|
||||
}
|
||||
// The dock works from the course outline and the URL, so it is there (and right) while the lesson loads:
|
||||
// a second Next goes on from the lesson the URL names.
|
||||
const { current, prev, next } = lessonNeighbours(course, lessonId);
|
||||
const isDone = progress?.state === "done";
|
||||
const isExercise = lesson.lesson.type === "exercise";
|
||||
const n = moduleNumber(course, lesson.module.index);
|
||||
const isExercise = (lesson?.lesson.type ?? current?.type) === "exercise";
|
||||
return (
|
||||
<CourseFrame course={course} currentLessonId={lessonId} look={look} lookMode={isExercise ? "work" : "toggle"}>
|
||||
<LessonView
|
||||
lesson={lesson} look={look} track={track} onTrackChange={setTrack} progress={progress} signedIn={signedIn}
|
||||
onProgress={setState} onSaved={() => { void reload(); void refreshLibrary(); }}
|
||||
initialTime={initialTime} initialSlide={initialSlide}
|
||||
moduleLabel={`${/^(section|part|chapter)\b/i.test(lesson.module.title) ? "" : "Module "}${n}`.trim()}
|
||||
/>
|
||||
{lesson ? (
|
||||
<LessonView
|
||||
key={lesson.lesson.id}
|
||||
lesson={lesson} look={look} track={track} onTrackChange={setTrack} progress={progress} signedIn={signedIn}
|
||||
onProgress={setState} onSaved={() => { void reload(); void refreshLibrary(); }}
|
||||
initialTime={initialTime} initialSlide={initialSlide}
|
||||
moduleLabel={`${/^(section|part|chapter)\b/i.test(lesson.module.title) ? "" : "Module "}${moduleNumber(course, lesson.module.index)}`.trim()}
|
||||
/>
|
||||
) : <div className="col" aria-busy="true"><LoadingLine /></div>}
|
||||
<FloatingActionBar>
|
||||
<Pill variant="ghost" disabled={!lesson.prev} title={lesson.prev ? `Previous: ${lesson.prev.title}` : "First lesson"} onClick={() => lesson.prev && navigate(lessonHref(courseId, lesson.prev.id))}>
|
||||
<Pill variant="ghost" disabled={!prev} title={prev ? `Previous: ${prev.title}` : "First lesson"} onClick={() => prev && navigate(lessonHref(courseId, prev.id))}>
|
||||
<Prev /><span className="sr">Previous lesson</span>
|
||||
</Pill>
|
||||
<Pill variant="ghost" aria-pressed={isDone} disabled={!signedIn || lesson.locked} title={signedIn ? undefined : "Sign in to keep your progress"}
|
||||
<Pill variant="ghost" aria-pressed={isDone} disabled={!signedIn || !lesson || lesson.locked} title={signedIn ? undefined : "Sign in to keep your progress"}
|
||||
onClick={() => void setState(isDone ? "not-started" : "done")}>
|
||||
<Check size={14} />{isDone ? "Done" : "Mark done"}
|
||||
</Pill>
|
||||
<span className="sep" aria-hidden="true" />
|
||||
<Pill variant="primary" disabled={!lesson.next} title={lesson.next ? `Next: ${lesson.next.title}` : "Last lesson"} onClick={() => lesson.next && navigate(lessonHref(courseId, lesson.next.id))}>
|
||||
<Pill variant="primary" disabled={!next} title={next ? `Next: ${next.title}` : "Last lesson"} onClick={() => next && navigate(lessonHref(courseId, next.id))}>
|
||||
<span>Next<span className="lbl-long"> lesson</span></span><Next />
|
||||
</Pill>
|
||||
</FloatingActionBar>
|
||||
@@ -176,7 +187,7 @@ export function NotesPage() {
|
||||
const notesApi = useApi<Note[]>(user ? `/api/courses/${encodeURIComponent(courseId)}/notes` : null);
|
||||
useEffect(() => { if (course) document.title = `Your notes – ${course.title}`; }, [course]);
|
||||
if (error && !course) return <CourseMissing status={error.status} signedIn={!!user} />;
|
||||
if (!course) return null;
|
||||
if (!course) return <LoadingLine />;
|
||||
const titles = new Map(course.modules.flatMap((m) => m.lessons.map((l) => [l.id, { title: l.title, module: m.title }] as const)));
|
||||
const order = new Map(course.modules.flatMap((m) => m.lessons).map((l, i) => [l.id, i]));
|
||||
const notes = notesApi.data ?? [];
|
||||
|
||||
@@ -16,20 +16,38 @@ function loadPdfjs() {
|
||||
return pdfjsPromise;
|
||||
}
|
||||
|
||||
/** Renders `page` of `doc` into `canvas` at `cssWidth` pixels wide (scaled for the device pixel ratio). */
|
||||
async function renderPage(doc: PdfDoc, page: number, canvas: HTMLCanvasElement, cssWidth: number, cssMaxHeight?: number) {
|
||||
const p = await doc.getPage(page);
|
||||
const base = p.getViewport({ scale: 1 });
|
||||
let scale = cssWidth / base.width;
|
||||
if (cssMaxHeight && base.height * scale > cssMaxHeight) scale = cssMaxHeight / base.height;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const vp = p.getViewport({ scale: scale * dpr });
|
||||
canvas.width = Math.floor(vp.width);
|
||||
canvas.height = Math.floor(vp.height);
|
||||
canvas.style.width = `${Math.floor(vp.width / dpr)}px`;
|
||||
canvas.style.height = `${Math.floor(vp.height / dpr)}px`;
|
||||
const task = p.render({ canvasContext: canvas.getContext("2d")!, viewport: vp, canvas });
|
||||
await task.promise;
|
||||
const jobs = new WeakMap<HTMLCanvasElement, { task?: { cancel(): void }; done: Promise<unknown> }>();
|
||||
const superseded = () => new Error("superseded");
|
||||
|
||||
/**
|
||||
* Renders `page` of `doc` into `canvas` at `cssWidth` pixels wide (scaled for the device pixel ratio). A newer
|
||||
* render into the same canvas cancels this one (its promise rejects), so pdf.js never draws one canvas twice at once.
|
||||
*/
|
||||
function renderPage(doc: PdfDoc, page: number, canvas: HTMLCanvasElement, cssWidth: number, cssMaxHeight?: number): Promise<void> {
|
||||
const prev = jobs.get(canvas);
|
||||
prev?.task?.cancel();
|
||||
const job: { task?: { cancel(): void }; done: Promise<unknown> } = { done: Promise.resolve() };
|
||||
const run = (async () => {
|
||||
await prev?.done;
|
||||
if (jobs.get(canvas) !== job) throw superseded();
|
||||
const p = await doc.getPage(page);
|
||||
if (jobs.get(canvas) !== job) throw superseded();
|
||||
const base = p.getViewport({ scale: 1 });
|
||||
let scale = cssWidth / base.width;
|
||||
if (cssMaxHeight && base.height * scale > cssMaxHeight) scale = cssMaxHeight / base.height;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const vp = p.getViewport({ scale: scale * dpr });
|
||||
canvas.width = Math.floor(vp.width);
|
||||
canvas.height = Math.floor(vp.height);
|
||||
canvas.style.width = `${Math.floor(vp.width / dpr)}px`;
|
||||
canvas.style.height = `${Math.floor(vp.height / dpr)}px`;
|
||||
const task = p.render({ canvasContext: canvas.getContext("2d")!, viewport: vp, canvas });
|
||||
job.task = task;
|
||||
await task.promise;
|
||||
})();
|
||||
job.done = run.catch(() => {});
|
||||
jobs.set(canvas, job);
|
||||
return run;
|
||||
}
|
||||
|
||||
const isTyping = (t: EventTarget | null) => !!(t as Element | null)?.closest?.("input, textarea, select, video, audio, [contenteditable], [role='slider']");
|
||||
@@ -46,32 +64,50 @@ export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, titl
|
||||
const [page, setPage] = useState(initialPage);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const stage = useRef<HTMLElement>(null);
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
// Two stacked canvases: the next slide is drawn into the hidden one, which then fades in over the current one
|
||||
// (shifted 4 px from the direction of travel), so the stage never blanks while pdf.js draws.
|
||||
const canvasA = useRef<HTMLCanvasElement>(null);
|
||||
const canvasB = useRef<HTMLCanvasElement>(null);
|
||||
const [view, setView] = useState<{ front: 0 | 1; page: number; dir: "next" | "prev" | null }>({ front: 0, page: 0, dir: null });
|
||||
const viewRef = useRef(view);
|
||||
viewRef.current = view;
|
||||
|
||||
useEffect(() => {
|
||||
let live = true, loaded: PdfDoc | null = null;
|
||||
setDoc(null); setError(null); setPages(0);
|
||||
// pdf.js 6 documents have no destroy(); the loading task owns the document and the worker port.
|
||||
let live = true, task: { destroy(): Promise<void> } | null = null;
|
||||
setDoc(null); setError(null); setPages(0); setView({ front: 0, page: 0, dir: null });
|
||||
loadPdfjs()
|
||||
.then((pdfjs) => pdfjs.getDocument({ url: deck.url, withCredentials: true }).promise)
|
||||
.then((pdfjs) => {
|
||||
if (!live) throw new Error("unmounted"); // never start a load (and its worker) after cleanup
|
||||
const t = pdfjs.getDocument({ url: deck.url, withCredentials: true });
|
||||
task = t;
|
||||
return t.promise;
|
||||
})
|
||||
.then((d: PdfDoc) => {
|
||||
loaded = d;
|
||||
if (!live) { void d.destroy(); return; }
|
||||
if (!live) return;
|
||||
setDoc(d); setPages(d.numPages); setPage((p) => Math.min(Math.max(1, p), d.numPages));
|
||||
})
|
||||
.catch((e: unknown) => live && setError(`This deck could not be opened. ${(e as Error).message ?? ""}`.trim()));
|
||||
return () => { live = false; if (loaded) void loaded.destroy(); };
|
||||
return () => { live = false; void task?.destroy().catch(() => {}); };
|
||||
}, [deck.url]);
|
||||
|
||||
// Render the current page to fit the stage.
|
||||
// Draw the current page to fit the stage: first into the front canvas, later pages into the back one, then flip.
|
||||
useEffect(() => {
|
||||
if (!doc || !canvas.current || !stage.current) return;
|
||||
if (!doc || !canvasA.current || !canvasB.current || !stage.current) return;
|
||||
let live = true;
|
||||
const draw = () => {
|
||||
const canvases = [canvasA.current, canvasB.current] as const;
|
||||
const draw = (into: 0 | 1, then?: () => void) => {
|
||||
const r = stage.current!.getBoundingClientRect();
|
||||
if (live) renderPage(doc, page, canvas.current!, r.width, r.height).catch(() => {});
|
||||
renderPage(doc, page, canvases[into], r.width, r.height).then(() => { if (live) then?.(); }, () => {});
|
||||
};
|
||||
draw();
|
||||
const ro = new ResizeObserver(() => draw());
|
||||
const cur = viewRef.current;
|
||||
if (cur.page === 0 || cur.page === page) draw(cur.front, () => setView((v) => ({ ...v, page })));
|
||||
else {
|
||||
const back = (1 - cur.front) as 0 | 1;
|
||||
draw(back, () => setView({ front: back, page, dir: page > cur.page ? "next" : "prev" }));
|
||||
}
|
||||
let first = true; // ResizeObserver reports once on observe; that size is already drawn
|
||||
const ro = new ResizeObserver(() => { if (first) { first = false; return; } draw(viewRef.current.front); });
|
||||
ro.observe(stage.current);
|
||||
return () => { live = false; ro.disconnect(); };
|
||||
}, [doc, page]);
|
||||
@@ -105,7 +141,16 @@ export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, titl
|
||||
<figure className="stage" ref={stage}>
|
||||
{error ? <span className="msg">{error} <a href={deck.url} target="_blank" rel="noopener">Open the PDF</a></span> : null}
|
||||
{!error && !doc ? <span className="msg">Loading slides…</span> : null}
|
||||
<canvas ref={canvas} hidden={!doc || !!error} role="img" aria-label={`Slide ${page} of ${pages}`} />
|
||||
{([0, 1] as const).map((i) => {
|
||||
const front = view.front === i;
|
||||
return (
|
||||
<canvas
|
||||
key={i} ref={i ? canvasB : canvasA} hidden={!doc || !!error}
|
||||
data-front={front ? "" : undefined} data-page={front && view.page ? view.page : undefined} data-leaving={!front && view.dir ? "" : undefined} data-dir={view.dir ?? undefined}
|
||||
role={front ? "img" : undefined} aria-hidden={front ? undefined : true} aria-label={front ? `Slide ${view.page || page} of ${pages}` : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</figure>
|
||||
<div className="deckbar">
|
||||
<Pill className="sm" aria-label="Previous slide" disabled={page <= 1} onClick={() => go(page - 1)}><Prev /></Pill>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CourseCover, ProgressBar, SegmentedToggle } from "@stc/ui";
|
||||
import { CourseCover, ProgressBar, SegmentedToggle, LoadingLine } from "@stc/ui";
|
||||
import type { CourseCard, ContinueItem, Format, LibraryPayload, SearchHit } from "../../shared/types";
|
||||
import { api, courseHref, ensureStyles, fmtMinutes, lessonHref, refreshLibrary, useLibrary, useSearch } from "../api";
|
||||
import { HeaderExtras } from "../shell/HeaderExtras";
|
||||
@@ -31,7 +31,7 @@ export default function LibraryPage() {
|
||||
return (
|
||||
<div className="lr" data-look="work">
|
||||
<HeaderExtras course={null} lookMode={null} />
|
||||
{lib ? <LibraryView lib={lib} /> : <div className="lr-lib"><p className="lib-sub">Loading the library…</p></div>}
|
||||
{lib ? <LibraryView lib={lib} /> : <div aria-busy="true"><LoadingLine /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,12 @@ export function Outline({ course, currentLessonId }: { course: CoursePayload; cu
|
||||
.map((m) => ({ m, lessons: query ? m.lessons.filter((l) => fold(l.title).includes(query)) : m.lessons }))
|
||||
.filter(({ m, lessons }) => !query || lessons.length || fold(m.title).includes(query)), [course, query]);
|
||||
|
||||
const toggle = (id: string) => setOpenMods((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });
|
||||
// Only a list the user opens rises in; lists open on page load or after a lesson change stay still.
|
||||
const [opened, setOpened] = useState<string | null>(null);
|
||||
const toggle = (id: string) => {
|
||||
if (!openMods.has(id)) setOpened(id);
|
||||
setOpenMods((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });
|
||||
};
|
||||
const sourceLine = course.authors.length ? course.authors.join(", ") : course.tracks.length > 1 ? `Tracks: ${course.tracks.map((t) => t.label).join(", ")}` : course.pitch ?? "";
|
||||
const errors = course.diagnostics.filter((d) => d.level === "error");
|
||||
|
||||
@@ -108,15 +113,15 @@ export function Outline({ course, currentLessonId }: { course: CoursePayload; cu
|
||||
</div>
|
||||
) : null}
|
||||
{filtered.map(({ m, lessons }) => (
|
||||
<ModuleBlock key={m.id} course={course} m={m} lessons={lessons} open={!!query || openMods.has(m.id)} current={m.id === current?.id} currentLessonId={currentLessonId} onToggle={() => toggle(m.id)} />
|
||||
<ModuleBlock key={m.id} course={course} m={m} lessons={lessons} open={!!query || openMods.has(m.id)} enter={opened === m.id} current={m.id === current?.id} currentLessonId={currentLessonId} onToggle={() => toggle(m.id)} />
|
||||
))}
|
||||
{query && filtered.length === 0 ? <p className="lr-nohit">No lessons match “{q.trim()}”.</p> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleBlock({ course, m, lessons, open, current, currentLessonId, onToggle }: {
|
||||
course: CoursePayload; m: OutlineModule; lessons: OutlineLesson[]; open: boolean; current: boolean; currentLessonId: string | null; onToggle: () => void;
|
||||
function ModuleBlock({ course, m, lessons, open, enter, current, currentLessonId, onToggle }: {
|
||||
course: CoursePayload; m: OutlineModule; lessons: OutlineLesson[]; open: boolean; enter: boolean; current: boolean; currentLessonId: string | null; onToggle: () => void;
|
||||
}) {
|
||||
const d = m.lessons.filter((l) => course.progress[l.id]?.state === "done").length;
|
||||
const pct = m.lessons.length ? d / m.lessons.length : 0;
|
||||
@@ -131,7 +136,7 @@ function ModuleBlock({ course, m, lessons, open, current, currentLessonId, onTog
|
||||
</button>
|
||||
{course.signedIn ? <ProgressBar value={pct} dim={pct === 1 || !current} label={`${m.title} progress`} /> : <span style={{ display: "block", height: 4 }} />}
|
||||
{open ? (
|
||||
<ol className="lr-lessons">
|
||||
<ol className="lr-lessons" data-enter={enter ? "" : undefined}>
|
||||
{lessons.map((l) => {
|
||||
const isDone = course.progress[l.id]?.state === "done";
|
||||
const cls = ["lr-les", isDone && "done", m.locked && "locked"].filter(Boolean).join(" ");
|
||||
|
||||
@@ -37,7 +37,7 @@ export function HeaderExtras({ course, lookMode, outline }: {
|
||||
</div>,
|
||||
host,
|
||||
)}
|
||||
{msg ? <div className="lr lr-toast" role="status">{msg}</div> : null}
|
||||
{msg ? <div className={msg.leaving ? "lr lr-toast is-leaving" : "lr lr-toast"} role="status">{msg.text}</div> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export const learnerCss = String.raw`
|
||||
.lr-shell { display: flex; align-items: flex-start; }
|
||||
.lr-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); overscroll-behavior: contain; }
|
||||
.lr-scrim { display: none; }
|
||||
.lr-main { flex: 1; min-width: 0; padding: 44px 40px 0; }
|
||||
.lr-main { position: relative; flex: 1; min-width: 0; padding: 44px 40px 0; }
|
||||
.lr-side-head { display: flex; gap: 12px; padding: 18px 16px 16px; border-bottom: 1px solid var(--line); }
|
||||
.lr-side-head h2 { margin: 0; font-size: 14.5px; font-weight: 600; color: var(--head); line-height: 1.3; }
|
||||
.lr-side-head h2 a { text-decoration: none; }
|
||||
@@ -331,6 +331,32 @@ export const learnerCss = String.raw`
|
||||
|
||||
.lr-toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 60; background: var(--raised); border: 1px solid var(--line2); border-radius: 18px; padding: 9px 16px; font-size: 13px; color: var(--text); }
|
||||
|
||||
/* ---------- motion ----------
|
||||
Navigation swaps at once; only the new content region enters (fade and 6 px rise). The header, sidebar and dock
|
||||
stay put and nothing cross-fades. While a lesson loads its column keeps the dock at the bottom of the screen. */
|
||||
.lr-main .lr-lesson, .lr-main > article, .stc-main > .lr > article, .lr-lib { animation: stc-page var(--t-page) var(--ease-out) backwards; }
|
||||
.lr-main > .col[aria-busy="true"] { min-height: calc(100vh - var(--top) - 150px); }
|
||||
/* deck slides: the new slide fades in over the old one, 4 px from the side it comes from */
|
||||
.lr .stage canvas { grid-area: 1 / 1; }
|
||||
.lr .stage canvas:not([data-front]) { opacity: 0; }
|
||||
.lr .stage canvas[data-front] { position: relative; z-index: 1; }
|
||||
.lr .stage canvas[data-front][data-dir="next"] { animation: stc-slide-next var(--t-enter) var(--ease-out) backwards; }
|
||||
.lr .stage canvas[data-front][data-dir="prev"] { animation: stc-slide-prev var(--t-enter) var(--ease-out) backwards; }
|
||||
.lr .stage canvas[data-leaving] { animation: stc-hold-out var(--t-enter) linear both; }
|
||||
/* overlays answer the action that opened them; closing is instant except for the toast's short fade */
|
||||
.lr-menu { animation: stc-drop var(--t-fast) var(--ease-out) backwards; }
|
||||
.lr-shell.side-open .lr-scrim { animation: stc-fade-in var(--t-fast) var(--ease-out) backwards; }
|
||||
.lr-toast { animation: stc-enter var(--t-enter) var(--ease-out) backwards; }
|
||||
.lr-toast.is-leaving { animation: stc-fade-out var(--t-exit) var(--ease-out) both; }
|
||||
/* a module list the user opens */
|
||||
.lr-lessons[data-enter] { animation: stc-enter var(--t-enter) var(--ease-out) backwards; }
|
||||
/* folds and hint tiers grow to their height where the browser can animate it, and open instantly elsewhere */
|
||||
@supports selector(::details-content) {
|
||||
.lr details.fold { interpolate-size: allow-keywords; }
|
||||
.lr details.fold::details-content { height: 0; opacity: 0; overflow: clip; transition: height var(--t-fold) var(--ease-out), opacity var(--t-fold) var(--ease-out), content-visibility var(--t-fold) allow-discrete; }
|
||||
.lr details.fold[open]::details-content { height: auto; opacity: 1; }
|
||||
}
|
||||
|
||||
/* ---------- responsive ---------- */
|
||||
@media (max-width: 1100px) {
|
||||
.lr .ex { grid-template-columns: minmax(0, 1fr); }
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MemoryRouter } from "react-router-dom";
|
||||
import { learnerWeb } from "../src/web";
|
||||
import { filterCourses, LibraryView } from "../src/web/library/LibraryPage";
|
||||
import { CourseFrame, moduleNumber } from "../src/web/shell/CourseFrame";
|
||||
import { nextLesson } from "../src/web/course/pages";
|
||||
import { lessonNeighbours, nextLesson } from "../src/web/course/pages";
|
||||
import { safeNext } from "../src/web/account/AccountPage";
|
||||
import type { CourseCard, CoursePayload, LibraryPayload } from "../src/shared/types";
|
||||
|
||||
@@ -97,3 +97,9 @@ test("sign-in only returns to same-site paths", () => {
|
||||
expect(safeNext("https://evil.example")).toBe("/");
|
||||
expect(safeNext(null)).toBe("/");
|
||||
});
|
||||
|
||||
test("lessonNeighbours: the dock's previous and next come from the outline in flat course order", () => {
|
||||
expect(lessonNeighbours(course, "a")).toMatchObject({ current: { id: "a" }, prev: null, next: { id: "b" } });
|
||||
expect(lessonNeighbours(course, "b")).toMatchObject({ current: { id: "b" }, prev: { id: "a" }, next: null });
|
||||
expect(lessonNeighbours(course, "zz")).toEqual({ current: null, prev: null, next: null });
|
||||
});
|
||||
@@ -1,2 +1,4 @@
|
||||
export { AppShell } from "./AppShell";
|
||||
export { Pill, SegmentedToggle, ProgressBar, FloatingActionBar, CourseCover, coverFits, monogram } from "./primitives";
|
||||
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";
|
||||
@@ -0,0 +1,5 @@
|
||||
/** 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/** What the scroll keeper needs from the browser (a fake in tests). */
|
||||
export interface ScrollEnv {
|
||||
getY(): number;
|
||||
scrollTo(y: number): void;
|
||||
maxY(): number;
|
||||
/** Scrolls the element with this id into view; false when there is none. */
|
||||
scrollToId(id: string): boolean;
|
||||
/** Runs `cb` on the next frame. */
|
||||
later(cb: () => void): void;
|
||||
now(): number;
|
||||
load(): string | null;
|
||||
save(s: string): void;
|
||||
}
|
||||
|
||||
export interface ScrollKeeper {
|
||||
/** Records the position for the history entry on screen. */
|
||||
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;
|
||||
/** 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 }
|
||||
|
||||
const KEEP = 60;
|
||||
const RETRY_MS = 1000;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const raw = JSON.parse(env.load() ?? "[]") as unknown;
|
||||
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 run = 0;
|
||||
let restoring = false;
|
||||
let persistQueued = false;
|
||||
|
||||
const persist = () => {
|
||||
if (persistQueued) return;
|
||||
persistQueued = true;
|
||||
env.later(() => {
|
||||
persistQueued = false;
|
||||
try { env.save(JSON.stringify([...saved].slice(-KEEP))); } catch { /* storage full or blocked */ }
|
||||
});
|
||||
};
|
||||
|
||||
const apply = (r: Route) => {
|
||||
current = r.key;
|
||||
const id = ++run;
|
||||
restoring = false;
|
||||
if (r.hash && r.hash.length > 1) {
|
||||
let target = r.hash.slice(1);
|
||||
try { target = decodeURIComponent(target); } catch { /* keep raw */ }
|
||||
if (env.scrollToId(target)) return;
|
||||
}
|
||||
const y = r.pop ? saved.get(r.key) ?? 0 : 0;
|
||||
const start = env.now();
|
||||
const step = () => {
|
||||
if (id !== run) return;
|
||||
env.scrollTo(Math.min(y, env.maxY()));
|
||||
restoring = env.maxY() < y && env.now() - start < RETRY_MS;
|
||||
if (restoring) env.later(step);
|
||||
};
|
||||
step();
|
||||
};
|
||||
|
||||
return {
|
||||
onScroll() {
|
||||
if (current === null || restoring) return;
|
||||
saved.delete(current);
|
||||
saved.set(current, env.getY());
|
||||
persist();
|
||||
},
|
||||
routeChanged(key, pop, hash) { apply({ key, pop, hash }); },
|
||||
userInput() {
|
||||
if (!restoring) return;
|
||||
run++;
|
||||
restoring = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let instance: ScrollKeeper | null = null;
|
||||
|
||||
/** The window-bound keeper shared by the shell and the pages. */
|
||||
export function routeScroll(): ScrollKeeper {
|
||||
instance ??= createScrollKeeper({
|
||||
getY: () => window.scrollY,
|
||||
scrollTo: (y) => window.scrollTo(0, y),
|
||||
maxY: () => Math.max(0, document.documentElement.scrollHeight - window.innerHeight),
|
||||
scrollToId: (id) => { const el = document.getElementById(id); el?.scrollIntoView({ block: "start" }); return !!el; },
|
||||
later: (cb) => { requestAnimationFrame(() => cb()); },
|
||||
now: () => performance.now(),
|
||||
load: () => { try { return sessionStorage.getItem("stc.scroll"); } catch { return null; } },
|
||||
save: (s) => { try { sessionStorage.setItem("stc.scroll", s); } catch { /* private mode */ } },
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** Takes scroll restoration over from the browser and records positions. Returns a cleanup function. */
|
||||
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 });
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,10 @@
|
||||
--sans: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--serif: "Literata", Georgia, "Times New Roman", serif;
|
||||
--mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
|
||||
--top: 52px; --sidew: 296px; --fast: 140ms;
|
||||
--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-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; }
|
||||
@@ -33,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); }
|
||||
@@ -52,7 +55,7 @@ code, pre { font-family: var(--mono); }
|
||||
|
||||
/* progress */
|
||||
.stc-bar { display: block; flex: 1; height: 4px; border-radius: 2px; background: var(--line); overflow: hidden; }
|
||||
.stc-bar > i { display: block; height: 100%; background: var(--accent); border-radius: 2px; }
|
||||
.stc-bar > i { display: block; height: 100%; background: var(--accent); border-radius: 2px; transition: width var(--t-bar) var(--ease-out); }
|
||||
.stc-bar--dim > i { background: var(--accent-dim); }
|
||||
|
||||
/* floating action bar */
|
||||
@@ -65,3 +68,33 @@ code, pre { font-family: var(--mono); }
|
||||
.stc-cover { flex: none; position: relative; width: var(--cw, 44px); aspect-ratio: 3 / 4; border-radius: 6px; background: var(--cbg, var(--raised)); color: var(--cfg, var(--accent)); border: 1px solid color-mix(in srgb, var(--cfg, var(--accent)) 18%, transparent); overflow: hidden; font: 600 calc(var(--cw, 44px) * .34) / 1 var(--sans); letter-spacing: -.03em; display: flex; align-items: flex-end; padding: calc(var(--cw, 44px) * .12); }
|
||||
.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 ----------
|
||||
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; } }
|
||||
@keyframes stc-drop { from { opacity: 0; translate: 0 -4px; } }
|
||||
/* the outgoing slide stays fully visible under the incoming one, then goes */
|
||||
@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-page { animation: stc-page var(--t-page) var(--ease-out) backwards; }
|
||||
|
||||
/* 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; }
|
||||
/* separate rule: browsers without ::details-content drop only this one */
|
||||
*::details-content { transition: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Static motion lint (v1.1 package M): every animation and transition in the shared and learner stylesheets is
|
||||
// light (120–220 ms, ease-out without overshoot), animates only opacity and short translates, and is switched off
|
||||
// by prefers-reduced-motion.
|
||||
import { expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
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> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const m of strip(tokensCss).matchAll(/(--t-[\w-]+|--fast)\s*:\s*([\d.]+)ms/g)) out[m[1]!] = Number(m[2]);
|
||||
return out;
|
||||
}
|
||||
|
||||
test("the motion duration tokens exist and are 120–220 ms", () => {
|
||||
const t = tokens();
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
test("every animation and transition duration is 0 or 120–220 ms", () => {
|
||||
const t = tokens();
|
||||
const bad: string[] = [];
|
||||
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) || 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; }
|
||||
durations.push(Number(d[2]) * (d[3] === "s" ? 1000 : 1));
|
||||
}
|
||||
// The first time in a declaration is the duration; a second one is a delay, which must be 0 or within range too.
|
||||
for (const ms of durations) if (ms !== 0 && (ms < 120 || ms > 220)) bad.push(`${name}: ${m[0].trim()}`);
|
||||
}
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
test("keyframes animate only opacity and short translates", () => {
|
||||
const bad: string[] = [];
|
||||
let count = 0;
|
||||
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;
|
||||
if (prop === "translate") {
|
||||
const px = [...value.matchAll(/(-?[\d.]+)px/g)].map((x) => Math.abs(Number(x[1])));
|
||||
if (px.every((n) => n <= 8) && !/%|em|vw|vh/.test(value)) continue;
|
||||
}
|
||||
bad.push(`${name} @keyframes ${k[1]}: ${prop}: ${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(count).toBeGreaterThan(0);
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
test("easing curves do not overshoot (no bounce)", () => {
|
||||
const bad: string[] = [];
|
||||
for (const [name, css] of sheets) {
|
||||
for (const m of css.matchAll(/cubic-bezier\(([^)]*)\)/g)) {
|
||||
const [, y1, , y2] = m[1]!.split(",").map(Number);
|
||||
if (!(y1! >= 0 && y1! <= 1 && y2! >= 0 && y2! <= 1)) bad.push(`${name}: ${m[0]}`);
|
||||
}
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
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);
|
||||
const block = css.slice(at, css.indexOf("\n}", at));
|
||||
expect(block).toMatch(/\*,\s*\*::before,\s*\*::after/);
|
||||
expect(block).toContain("animation: none !important");
|
||||
expect(block).toContain("transition: none !important");
|
||||
// `*` 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("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);
|
||||
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\)/);
|
||||
});
|
||||
@@ -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("");
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createScrollKeeper } from "../src/scroll";
|
||||
|
||||
function fakeEnv(opts: { max?: number; stored?: string | null; ids?: string[] } = {}) {
|
||||
const e = {
|
||||
y: 0, max: opts.max ?? 10_000, t: 0, stored: opts.stored ?? null as string | null, queue: [] as (() => void)[], intoView: [] as string[],
|
||||
getY: () => e.y,
|
||||
scrollTo: (y: number) => { e.y = Math.min(y, e.max); },
|
||||
maxY: () => e.max,
|
||||
scrollToId: (id: string) => { if (!(opts.ids ?? []).includes(id)) return false; e.intoView.push(id); return true; },
|
||||
later: (cb: () => void) => { e.queue.push(cb); },
|
||||
now: () => e.t,
|
||||
load: () => e.stored,
|
||||
save: (s: string) => { e.stored = s; },
|
||||
flush() { const q = e.queue.splice(0); q.forEach((f) => f()); },
|
||||
};
|
||||
return e;
|
||||
}
|
||||
|
||||
test("PUSH goes to the top, POP restores the saved position of that entry", () => {
|
||||
const env = fakeEnv();
|
||||
const k = createScrollKeeper(env);
|
||||
k.routeChanged("a", false);
|
||||
env.y = 640; k.onScroll();
|
||||
k.routeChanged("b", false);
|
||||
expect(env.y).toBe(0);
|
||||
env.y = 120; k.onScroll();
|
||||
k.routeChanged("a", true);
|
||||
expect(env.y).toBe(640);
|
||||
k.routeChanged("b", true);
|
||||
expect(env.y).toBe(120);
|
||||
k.routeChanged("c", true); // never seen: top
|
||||
expect(env.y).toBe(0);
|
||||
});
|
||||
|
||||
test("a hash scrolls its target into view", () => {
|
||||
const env = fakeEnv({ ids: ["part 2"] });
|
||||
const k = createScrollKeeper(env);
|
||||
env.y = 300;
|
||||
k.routeChanged("a", false, "#part%202");
|
||||
expect(env.intoView).toEqual(["part 2"]);
|
||||
k.routeChanged("b", false, "#missing");
|
||||
expect(env.y).toBe(0);
|
||||
});
|
||||
|
||||
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 = 2000; k.onScroll();
|
||||
k.routeChanged("b", false);
|
||||
env.max = 300;
|
||||
k.routeChanged("a", true);
|
||||
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", () => {
|
||||
const env = fakeEnv();
|
||||
const k = createScrollKeeper(env);
|
||||
k.routeChanged("a", false);
|
||||
env.y = 2000; k.onScroll();
|
||||
k.routeChanged("b", false);
|
||||
env.max = 300; // the old page is still loading
|
||||
k.routeChanged("a", true);
|
||||
expect(env.y).toBe(300);
|
||||
env.flush();
|
||||
expect(env.y).toBe(300);
|
||||
env.max = 5000; // content arrived
|
||||
env.flush();
|
||||
expect(env.y).toBe(2000);
|
||||
expect(env.queue.length).toBe(0);
|
||||
});
|
||||
|
||||
test("the retry gives up after a second, and a newer route change cancels it", () => {
|
||||
const env = fakeEnv();
|
||||
const k = createScrollKeeper(env);
|
||||
k.routeChanged("a", false);
|
||||
env.y = 2000; k.onScroll();
|
||||
k.routeChanged("b", false);
|
||||
env.max = 300;
|
||||
k.routeChanged("a", true);
|
||||
k.onScroll(); // our own clamped scroll must not overwrite the saved 2000
|
||||
env.t = 1200; env.flush();
|
||||
expect(env.queue.length).toBe(0);
|
||||
env.max = 5000;
|
||||
k.routeChanged("a", true);
|
||||
expect(env.y).toBe(2000);
|
||||
|
||||
env.max = 300;
|
||||
k.routeChanged("c", false);
|
||||
k.routeChanged("a", true);
|
||||
k.routeChanged("d", false); // cancels the restore of "a"
|
||||
env.max = 5000; env.flush();
|
||||
expect(env.y).toBe(0);
|
||||
});
|
||||
|
||||
test("positions survive a reload through storage", () => {
|
||||
const env = fakeEnv();
|
||||
const k = createScrollKeeper(env);
|
||||
k.routeChanged("a", false);
|
||||
env.y = 777; k.onScroll(); env.flush();
|
||||
const env2 = fakeEnv({ stored: env.stored });
|
||||
createScrollKeeper(env2).routeChanged("a", true);
|
||||
expect(env2.y).toBe(777);
|
||||
const env3 = fakeEnv({ stored: "{not json" });
|
||||
createScrollKeeper(env3).routeChanged("a", true);
|
||||
expect(env3.y).toBe(0);
|
||||
});
|
||||
Reference in new issue
Block a user