feat(learner): web shell, library, lesson views (LessonView), decks, code files, exercises, notes, account and admin pages

Tasks 10-12 of the WP-A plan in one commit (the route modules import each other):
- header extras portalled into the Phase 0 header: course switcher, search, Reading/Work, account
- course frame with outline sidebar (mobile overlay, Escape closes), progress, gating locks
- library: featured course, continue row, New and collection rows, course list, filters, search
- LessonView: text (track switcher, folds, ordered hint tiers), video (resume, done near the end,
  timestamp notes, transcript), companion decks rendered with pdf.js (thumbs, arrows, fullscreen,
  per-slide notes), code files (tree, tabs, copy, zip, command), exercises (Web Worker runner or
  local command with optional pasted evidence), floating action bar
- notes list, account (email/password, providers, SSO by email) and owner admin pages
- pdf.js worker served from /api/learner/pdf.worker.mjs
- browser entry @stc/feature-learner/web (the package index also carries the server half)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 04:53:36 +01:00
1 parent 99c0e4929f
commit 89c51f013d
27 files changed
+2994 -1

No files matched your search

@@ -0,0 +1,19 @@
import { expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import { SignInForms } from "../src/web/account/AccountPage";
import AdminPage from "../src/web/account/AdminPage";
test("the sign-in page has email and password fields and a create-account switch", () => {
const out = renderToString(<MemoryRouter initialEntries={["/account?next=/c/sample"]}><SignInForms /></MemoryRouter>);
expect(out).toContain('type="email"');
expect(out).toContain('type="password"');
expect(out).toContain('autoComplete="current-password"');
expect(out).toContain("Create account");
});
test("the admin page renders its frame before the session is known", () => {
const out = renderToString(<MemoryRouter><AdminPage /></MemoryRouter>);
expect(out).toContain("Users, groups and library");
expect(out).not.toContain("Users and roles"); // owner-only sections wait for the session
});
@@ -0,0 +1,11 @@
import { expect } from "bun:test";
import { forEachDb } from "./helpers";
import { makeApp } from "./app";
forEachDb("the pdf.js worker is served for client-side deck rendering", async (db) => {
const { call } = await makeApp(db);
const r = await call("GET", "/api/learner/pdf.worker.mjs");
expect(r.status).toBe(200);
expect(r.headers.get("content-type")).toContain("javascript");
expect((await r.text()).length).toBeGreaterThan(100_000);
});
@@ -0,0 +1,108 @@
import { expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import { connect } from "@stc/db";
import { LessonView } from "../src/web";
import { LessonView as FromIndex } from "../src/index";
import type { LessonPayload } from "../src/shared/types";
import { localCommandFor } from "../src/web/lesson/Exercise";
import { firstStamp } from "../src/web/lesson/notes";
import { buildTree } from "../src/web/lesson/CodeFiles";
import { runMigrations } from "../src/server/migrate";
import { makeApp } from "./app";
async function sampleLessons(ids: string[]): Promise<Record<string, LessonPayload>> {
const db = await connect("sqlite://:memory:");
await runMigrations(db);
const { call } = await makeApp(db);
const out: Record<string, LessonPayload> = {};
for (const id of ids) out[id] = (await (await call("GET", `/api/courses/sample/lessons/${id}`)).json()) as LessonPayload;
await db.close();
return out;
}
const html = (el: React.ReactElement) => renderToString(<MemoryRouter>{el}</MemoryRouter>);
test("LessonView is exported from the package index and the web entry", () => {
expect(FromIndex).toBe(LessonView);
});
test("LessonView renders every sample lesson type", async () => {
const l = await sampleLessons(["m1-intro", "m1-video", "m2-ex-add"]);
const text = html(<LessonView lesson={l["m1-intro"]!} />);
expect(text).toContain('data-type="text"');
expect(text).toContain('data-look="reading"');
expect(text).toContain("<h1 class=\"title\">Introduction</h1>");
expect(text).toContain("Welcome to the sample course");
expect(text).toContain("Files for this section"); // module m1 has companion code
expect(text).toContain("/api/courses/sample/code/m1/zip");
const video = html(<LessonView lesson={l["m1-video"]!} signedIn={false} />);
expect(video).toContain('data-type="video"');
expect(video).toMatch(/<video[^>]+src="\/api\/media\/sample\/media\/clip\.mp4"/);
expect(video).toContain('data-deck="d1"'); // companion deck
expect(video).toContain("Transcript");
expect(video).toContain("Sign in"); // notes need an account
const ex = html(<LessonView lesson={l["m2-ex-add"]!} look="reading" signedIn />);
expect(ex).toContain('data-look="work"'); // exercises always use Work
expect(ex).toContain("Run tests");
expect(ex).toContain("return a - b;"); // the starter is in the editor
expect(ex).toContain('data-tier="1"');
expect(ex).toContain('data-tiers="2"');
expect(ex).toContain("node --test");
expect(ex).not.toContain("return a + b"); // no solution for learners
});
test("preview mode renders without notes or saved-attempt UI", async () => {
const l = await sampleLessons(["m1-video"]);
const out = html(<LessonView lesson={l["m1-video"]!} preview />);
expect(out).not.toContain("Your notes");
expect(out).not.toContain("Insert time");
});
test("a locked lesson shows the lock notice and no content", () => {
const locked: LessonPayload = {
courseId: "c", courseTitle: "C", lesson: { id: "l2", title: "Later", type: "text", minutes: null },
module: { id: "m2", title: "Two", index: 1 }, locked: true, markdown: null, baseFile: null, trackMarkdown: {}, tracks: [],
video: null, transcript: null, hints: [], decks: [], code: null, attachments: [], browser: null, localCommand: null,
prev: { id: "l1", title: "Earlier" }, next: null, lessonFiles: {},
};
const out = html(<LessonView lesson={locked} signedIn />);
expect(out).toContain("This module is locked.");
expect(out).toMatch(/Back to (<!-- -->)?Earlier/);
});
test("track variants: the chosen track's markdown and code are shown", () => {
const lesson: LessonPayload = {
courseId: "c", courseTitle: "C", lesson: { id: "t", title: "Tracks `x`", type: "text", minutes: 5 },
module: { id: "m", title: "M", index: 0 }, locked: false,
markdown: "# T\n\n**JavaScript**\n\n```js\nconst a = 1\n```\n\n**Scala 3**\n\n```scala\nval a = 1\n```\n",
baseFile: "course/t.md", trackMarkdown: {}, tracks: [{ id: "js", label: "JavaScript" }, { id: "scala", label: "Scala 3" }],
video: null, transcript: null, hints: [], decks: [], code: null, attachments: [], browser: null,
localCommand: { js: "npm test", scala: "sbt test" }, prev: null, next: null, lessonFiles: {},
};
const js = html(<LessonView lesson={lesson} track="js" />);
expect(js).toContain("const a = 1");
expect(js).not.toContain("val a = 1");
expect(js).toContain("<code>x</code>"); // inline code in the title
expect(js).toContain('aria-label="Track"');
expect(js).toContain("npm test");
const scala = html(<LessonView lesson={lesson} track="scala" />);
expect(scala).toContain("val a = 1");
expect(scala).toContain("sbt test");
});
test("helpers: local command per track, note timestamps, code tree", () => {
expect(localCommandFor({ js: "a", scala: "b" }, "scala")).toBe("b");
expect(localCommandFor({ js: "a" }, "fsharp")).toBe("a");
expect(localCommandFor("x", null)).toBe("x");
expect(firstStamp("see [1:05] and [0:10]")).toBe(65);
expect(firstStamp("[1:02:03] x")).toBe(3723);
expect(firstStamp("no stamp")).toBeNull();
const t = buildTree([{ path: "a.txt", size: 1 }, { path: "src/b.ts", size: 2 }, { path: "src/x/c.ts", size: 3 }]);
expect(t.files.map((f) => f.name)).toEqual(["a.txt"]);
expect(t.dirs.get("src")!.files.map((f) => f.path)).toEqual(["src/b.ts"]);
expect(t.dirs.get("src")!.dirs.get("x")!.files[0]!.path).toBe("src/x/c.ts");
});
@@ -0,0 +1,99 @@
import { expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
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 { safeNext } from "../src/web/account/AccountPage";
import type { CourseCard, CoursePayload, LibraryPayload } from "../src/shared/types";
const card = (id: string, over: Partial<CourseCard> = {}): CourseCard => ({
id, title: id.toUpperCase(), pitch: `${id} pitch`, authors: [], coverUrl: null, accent: null, formats: ["text"], lessonCount: 3,
minutes: 30, collections: [], tracks: [], source: "authored", addedAt: "2026-10-01T00:00:00Z", status: "ok", progress: 0, doneCount: 0, started: false, ...over,
});
const lib: LibraryPayload = {
signedIn: true,
featured: card("snake", { formats: ["text", "exercises"], minutes: 900 }),
continue: [{ courseId: "snake", lessonId: "m3", lessonTitle: "Pure game rules", moduleTitle: "Pure game rules", moduleIndex: 3, moduleDone: 1, moduleTotal: 5, progress: 0.2 }],
newest: ["docker", "snake"],
collections: [{ id: "devops", title: "DevOps", courseIds: ["docker"] }],
courses: [card("docker", { formats: ["video", "slides"], minutes: 300, started: true, progress: 0.5 }), card("snake", { formats: ["text", "exercises"], minutes: 900 })],
};
test("the learner web feature registers its routes", () => {
expect(learnerWeb.routes.map((r) => r.path)).toEqual(["/", "/c/:courseId", "/c/:courseId/notes", "/c/:courseId/:lessonId", "/account", "/account/admin"]);
});
test("every route module loads and has a default component", async () => {
for (const r of learnerWeb.routes) expect(typeof (await r.element()).default).toBe("function");
});
test("library: featured course, continue row, browsable rows and the course list", () => {
const out = renderToString(<MemoryRouter><LibraryView lib={lib} /></MemoryRouter>);
expect(out).toContain("Featured");
expect(out).toContain("SNAKE");
expect(out).toContain("Next up: <!-- -->Pure game rules");
expect(out).toContain("New in the library");
expect(out).toContain("DevOps");
expect(out).toContain("All courses");
expect(out).toContain('href="/c/snake/m3"');
expect(out).not.toMatch(/price|rating|bestseller/i);
});
test("library: search and filters switch to a results list", () => {
const out = renderToString(<MemoryRouter><LibraryView lib={lib} initialQuery="dock" /></MemoryRouter>);
expect(out).toContain("Courses matching “dock”");
expect(out).not.toContain("New in the library");
});
test("library filters by format, length and in-progress", () => {
const cs = lib.courses;
expect(filterCourses(cs, { format: "video", length: "any", inProgress: false }).map((c) => c.id)).toEqual(["docker"]);
expect(filterCourses(cs, { format: "all", length: "long", inProgress: false }).map((c) => c.id)).toEqual(["snake"]);
expect(filterCourses(cs, { format: "all", length: "medium", inProgress: false }).map((c) => c.id)).toEqual(["docker"]);
expect(filterCourses(cs, { format: "all", length: "any", inProgress: true }).map((c) => c.id)).toEqual(["docker"]);
});
const course: CoursePayload = {
id: "sd", title: "Systemic Design", pitch: null, authors: [], accent: "#86c7a6", coverUrl: null,
tracks: [{ id: "rust", label: "Rust" }, { id: "ts", label: "TypeScript" }], gating: "sequential", source: "authored", status: "ok",
modules: [
{ id: "m00", title: "Intro", index: 0, locked: false, hasCode: false, lessons: [{ id: "a", title: "A", type: "text", minutes: 5, hasDeck: false, browser: false }] },
{ id: "m01", title: "Next", index: 1, locked: true, hasCode: false, lessons: [{ id: "b", title: "Exercise 1.1 — `move`", type: "exercise", minutes: 15, hasDeck: false, browser: false }] },
],
progress: { a: { state: "done", positionSeconds: null, updatedAt: "" } }, canEdit: false, signedIn: true, diagnostics: [],
};
test("course frame: outline with progress, locks, current lesson and course accent", () => {
const out = renderToString(
<MemoryRouter initialEntries={["/c/sd/a"]}>
<CourseFrame course={course} currentLessonId="a" look="reading" lookMode="toggle"><p>body</p></CourseFrame>
</MemoryRouter>,
);
expect(out).toContain("--accent:#86c7a6");
expect(out).toContain('aria-current="page"');
expect(out).toContain("lr-les done");
expect(out).toContain("(locked)");
expect(out).toContain('aria-label="Course progress"');
expect(out).toContain("Your notes");
});
test("module numbers count from 0 when the first module id ends in 0", () => {
expect(moduleNumber(course, 1)).toBe(1);
expect(moduleNumber({ modules: [{ id: "m1" }] }, 0)).toBe(1);
});
test("next lesson skips done lessons and stops at a locked module", () => {
expect(nextLesson(course)).toEqual(course.modules[0]!.lessons[0]!);
const open = { ...course, modules: course.modules.map((m) => ({ ...m, locked: false })) };
expect(nextLesson(open)!.id).toBe("b");
});
test("sign-in only returns to same-site paths", () => {
expect(safeNext("/c/x")).toBe("/c/x");
expect(safeNext("//evil.example")).toBe("/");
expect(safeNext("https://evil.example")).toBe("/");
expect(safeNext(null)).toBe("/");
});