diff --git a/apps/web/src/features.ts b/apps/web/src/features.ts
index 8b077c9..cdef4be 100644
--- a/apps/web/src/features.ts
+++ b/apps/web/src/features.ts
@@ -1,4 +1,5 @@
import type { WebFeature } from "@stc/contracts";
+import { learnerWeb } from "@stc/feature-learner/web";
// Phase 1 packages append one import line each and push into this list.
-export const webFeatures: WebFeature[] = [];
+export const webFeatures: WebFeature[] = [learnerWeb];
diff --git a/packages/feature-learner/package.json b/packages/feature-learner/package.json
index 5da2328..807eab9 100644
--- a/packages/feature-learner/package.json
+++ b/packages/feature-learner/package.json
@@ -9,5 +9,9 @@
"better-auth": "^1.7.14",
"marked": "^18.1.2",
"pdfjs-dist": "^6.4.299"
+ },
+ "exports": {
+ ".": "./src/index.ts",
+ "./web": "./web/src/index.ts"
}
}
diff --git a/packages/feature-learner/src/index.ts b/packages/feature-learner/src/index.ts
index 1988a66..c01208d 100644
--- a/packages/feature-learner/src/index.ts
+++ b/packages/feature-learner/src/index.ts
@@ -1,2 +1,7 @@
+// Server
export { migrationsDirs, runMigrations } from "./server/migrate";
export { learnerFeature } from "./server/feature";
+// Browser (also available without the server half as "@stc/feature-learner/web")
+export { learnerWeb, LessonView, type LessonViewProps, ensureLearnerStyles } from "./web";
+// Shared JSON shapes
+export type * from "./shared/types";
diff --git a/packages/feature-learner/src/server/assets.ts b/packages/feature-learner/src/server/assets.ts
new file mode 100644
index 0000000..8a41496
--- /dev/null
+++ b/packages/feature-learner/src/server/assets.ts
@@ -0,0 +1,20 @@
+import type { Feature } from "@stc/contracts";
+
+/** The pdf.js worker from the installed pdfjs-dist, for client-side deck rendering. */
+export function pdfWorkerPath(): string {
+ return Bun.resolveSync("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.dir);
+}
+
+export function assetRoutes(): Feature["routes"] {
+ let path: string | null = null;
+ return {
+ "/api/learner/pdf.worker.mjs": {
+ GET: async () => {
+ path ??= pdfWorkerPath();
+ return new Response(Bun.file(path), {
+ headers: { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "public, max-age=86400" },
+ });
+ },
+ },
+ };
+}
diff --git a/packages/feature-learner/src/server/feature.ts b/packages/feature-learner/src/server/feature.ts
index 513bc79..0d2c512 100644
--- a/packages/feature-learner/src/server/feature.ts
+++ b/packages/feature-learner/src/server/feature.ts
@@ -8,6 +8,7 @@ import { lessonRoutes } from "./lesson";
import { userDataRoutes } from "./userdata";
import { codeRoutes } from "./code";
import { transpileRoutes } from "./transpile";
+import { assetRoutes } from "./assets";
/**
* The learner feature: auth, library, reader, notes, progress and exercises.
@@ -32,6 +33,7 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic
...userDataRoutes(ctx),
...codeRoutes(ctx),
...transpileRoutes(),
+ ...assetRoutes(),
};
// Every handler waits for the learner migrations (a no-op once Phase 2 runs them in migrate()).
for (const methods of Object.values(routes)) {
diff --git a/packages/feature-learner/src/web/account/AccountPage.tsx b/packages/feature-learner/src/web/account/AccountPage.tsx
new file mode 100644
index 0000000..be89024
--- /dev/null
+++ b/packages/feature-learner/src/web/account/AccountPage.tsx
@@ -0,0 +1,132 @@
+import { useEffect, useState, type FormEvent } from "react";
+import { Link, useLocation, useNavigate } from "react-router-dom";
+import { Pill, SegmentedToggle } from "@stc/ui";
+import { api, clearCourseCache, ensureStyles, refreshLibrary, refreshMe, useApi, useMe } from "../api";
+import { HeaderExtras } from "../shell/HeaderExtras";
+
+export interface ProvidersPayload { emailPassword: boolean; providers: { id: string; label: string; kind: "social" | "oidc" }[]; sso: boolean }
+
+async function afterAuthChange() {
+ clearCourseCache();
+ await Promise.all([refreshMe(), refreshLibrary()]);
+}
+
+/** Only same-site paths are allowed as a return target. */
+export function safeNext(raw: string | null): string {
+ return raw && raw.startsWith("/") && !raw.startsWith("//") ? raw : "/";
+}
+
+export default function AccountPage() {
+ ensureStyles();
+ const { loaded, user } = useMe();
+ useEffect(() => { document.title = "Account – STCourses"; }, []);
+ return (
+
+
+
+ {!loaded ? null : user ? : }
+
+
+ );
+}
+
+function SignedIn() {
+ const { user } = useMe();
+ const navigate = useNavigate();
+ const [busy, setBusy] = useState(false);
+ if (!user) return null;
+ const signOut = async () => {
+ setBusy(true);
+ try { await api("/api/auth/sign-out", { method: "POST", body: {} }); } catch { /* the session may already be gone */ }
+ await afterAuthChange();
+ setBusy(false);
+ navigate("/");
+ };
+ return (
+ <>
+ Your account
+
+ Name {user.name || "—"}
+ Email {user.email}
+ Roles {user.roles.join(", ") || "learner"}
+ {user.groups.length ? <>Groups {user.groups.join(", ")} > : null}
+
+
+ {user.roles.includes("owner") ?
Users, groups and library : null}
+
Sign out
+
+ >
+ );
+}
+
+export function SignInForms() {
+ const loc = useLocation();
+ const navigate = useNavigate();
+ const next = safeNext(new URLSearchParams(loc.search).get("next"));
+ const { data: prov } = useApi("/api/learner/providers");
+ const [mode, setMode] = useState<"in" | "up">("in");
+ const [name, setName] = useState("");
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [ssoEmail, setSsoEmail] = useState("");
+ const [err, setErr] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ const submit = async (e: FormEvent) => {
+ e.preventDefault();
+ setErr(null); setBusy(true);
+ try {
+ if (mode === "in") await api("/api/auth/sign-in/email", { method: "POST", body: { email, password } });
+ else await api("/api/auth/sign-up/email", { method: "POST", body: { name: name.trim() || email.split("@")[0], email, password } });
+ await afterAuthChange();
+ navigate(next);
+ } catch (e) {
+ setErr(mode === "in" ? "That email and password do not match an account." : (e as Error).message);
+ } finally { setBusy(false); }
+ };
+ const redirect = async (path: string, body: Record) => {
+ setErr(null);
+ try {
+ const callbackURL = new URL(next, location.origin).toString();
+ const r = await api<{ url?: string }>(path, { method: "POST", body: { ...body, callbackURL } });
+ if (r?.url) location.assign(r.url); else setErr("The sign-in provider did not answer.");
+ } catch (e) { setErr((e as Error).message); }
+ };
+
+ return (
+ <>
+ {mode === "in" ? "Sign in" : "Create an account"}
+ Your progress, notes and exercise code are kept with your account.
+
+ { setMode(v as "in" | "up"); setErr(null); }}
+ options={[{ value: "in", label: "Sign in" }, { value: "up", label: "Create account" }]} />
+
+
+ {prov && (prov.providers.length || prov.sso) ? (
+ <>
+ or
+
+ {prov.providers.map((p) => (
+
void (p.kind === "oidc" ? redirect("/api/auth/sign-in/oauth2", { providerId: p.id }) : redirect("/api/auth/sign-in/social", { provider: p.id }))}>
+ Continue with {p.label}
+
+ ))}
+
+ {prov.sso ? (
+
+ ) : null}
+ >
+ ) : null}
+ >
+ );
+}
diff --git a/packages/feature-learner/src/web/account/AdminPage.tsx b/packages/feature-learner/src/web/account/AdminPage.tsx
new file mode 100644
index 0000000..632c12b
--- /dev/null
+++ b/packages/feature-learner/src/web/account/AdminPage.tsx
@@ -0,0 +1,207 @@
+import { useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import { Pill } from "@stc/ui";
+import type { Role } from "@stc/contracts";
+import { api, ensureStyles, refreshLibrary, toast, useApi, useLibrary, useMe } from "../api";
+import { HeaderExtras } from "../shell/HeaderExtras";
+
+interface AdminUser { id: string; email: string; name: string; createdAt: string; roles: Role[]; groups: string[] }
+interface AdminGroup { id: string; name: string; members: string[] }
+interface Collection { id: string; title: string; sort: number; courseIds: string[] }
+interface Access { visibility: string; acl: { principal_type: "user" | "group"; principal_id: string }[]; authors: string[] }
+
+const ROLES: Role[] = ["owner", "author", "learner"];
+
+/** Owner administration: roles, groups, course access and library curation. */
+export default function AdminPage() {
+ ensureStyles();
+ const { loaded, user } = useMe();
+ useEffect(() => { document.title = "Administration – STCourses"; }, []);
+ const owner = !!user?.roles.includes("owner");
+ return (
+
+
+
+ Account
+ Users, groups and library
+ {!loaded ? null : !owner ? Only the owner of this server can open this page.
: }
+
+
+ );
+}
+
+function AdminSections() {
+ const users = useApi("/api/admin/users");
+ const groups = useApi("/api/admin/groups");
+ return (
+ <>
+ Sign-in providers are configured on the server with STC_AUTH_* settings.
+
+ { groups.reload(); users.reload(); }} />
+
+
+ >
+ );
+}
+
+function UsersSection({ users, reload }: { users: AdminUser[]; reload: () => void }) {
+ const setRoles = async (u: AdminUser, role: Role, on: boolean) => {
+ const roles = on ? [...u.roles, role] : u.roles.filter((r) => r !== role);
+ try { await api(`/api/admin/users/${encodeURIComponent(u.id)}/roles`, { method: "PUT", body: { roles } }); reload(); }
+ catch (e) { toast((e as Error).message); }
+ };
+ return (
+
+ );
+}
+
+function GroupsSection({ groups, users, reload }: { groups: AdminGroup[]; users: AdminUser[]; reload: () => void }) {
+ const [name, setName] = useState("");
+ const create = async () => {
+ try { await api("/api/admin/groups", { method: "POST", body: { name } }); setName(""); reload(); } catch (e) { toast((e as Error).message); }
+ };
+ const setMembers = async (g: AdminGroup, userIds: string[]) => {
+ try { await api(`/api/admin/groups/${encodeURIComponent(g.id)}/members`, { method: "PUT", body: { userIds } }); reload(); } catch (e) { toast((e as Error).message); }
+ };
+ const remove = async (g: AdminGroup) => {
+ if (!confirm(`Delete the group “${g.name}”? Courses shared with it lose that access.`)) return;
+ try { await api(`/api/admin/groups/${encodeURIComponent(g.id)}`, { method: "DELETE" }); reload(); } catch (e) { toast((e as Error).message); }
+ };
+ return (
+
+ Groups
+ Groups give access to restricted courses. Groups named in OIDC or SAML claims are filled at sign-in.
+ {groups.length ? (
+
+ ) : null}
+
+
setName(e.target.value)} aria-label="New group name" />
+
Add group
+
+
+ );
+}
+
+function LibrarySection() {
+ const lib = useLibrary();
+ const cur = useApi<{ featured: string | null; collections: Collection[] }>("/api/admin/library");
+ const [featured, setFeatured] = useState("");
+ const [cols, setCols] = useState([]);
+ const [dirty, setDirty] = useState(false);
+ useEffect(() => {
+ if (!cur.data) return;
+ setFeatured(cur.data.featured ?? ""); setCols(cur.data.collections); setDirty(false);
+ }, [cur.data]);
+ const courses = lib?.courses ?? [];
+ const save = async () => {
+ try {
+ await api("/api/admin/library", { method: "PUT", body: { featured: featured || null, collections: cols.filter((c) => c.title.trim()).map((c, i) => ({ ...c, sort: i })) } });
+ cur.reload(); void refreshLibrary(); toast("Library saved");
+ } catch (e) { toast((e as Error).message); }
+ };
+ const edit = (i: number, c: Partial) => { setCols(cols.map((x, k) => (k === i ? { ...x, ...c } : x))); setDirty(true); };
+ return (
+
+ Library
+
+ Featured course
+ { setFeatured(e.target.value); setDirty(true); }}>
+ Automatic (what you were last working on, else the newest)
+ {courses.map((c) => {c.title} )}
+
+
+ Collections
+ Each collection is a row in the library. A course can be in several. Courses can also name collections in their manifest.
+
+
+
{ setCols([...cols, { id: "", title: "", sort: cols.length, courseIds: [] }]); setDirty(true); }}>Add collection
+
Save library
+
+
+ );
+}
+
+function AccessSection({ users, groups }: { users: AdminUser[]; groups: AdminGroup[] }) {
+ const lib = useLibrary();
+ const [courseId, setCourseId] = useState("");
+ const access = useApi(courseId ? `/api/admin/courses/${encodeURIComponent(courseId)}/access` : null);
+ const [draft, setDraft] = useState(null);
+ useEffect(() => { setDraft(access.data); }, [access.data]);
+ const has = (t: "user" | "group", id: string) => !!draft?.acl.some((a) => a.principal_type === t && a.principal_id === id);
+ const toggle = (t: "user" | "group", id: string, on: boolean) => draft && setDraft({
+ ...draft, acl: on ? [...draft.acl, { principal_type: t, principal_id: id }] : draft.acl.filter((a) => !(a.principal_type === t && a.principal_id === id)),
+ });
+ const save = async () => {
+ if (!draft) return;
+ try { await api(`/api/admin/courses/${encodeURIComponent(courseId)}/access`, { method: "PUT", body: { acl: draft.acl, authors: draft.authors } }); access.reload(); toast("Access saved"); }
+ catch (e) { toast((e as Error).message); }
+ };
+ return (
+
+ Course access
+ Restricted courses are visible to the users and groups listed here. Authors can edit a course in the studio.
+
+ setCourseId(e.target.value)} aria-label="Course">
+ Choose a course
+ {(lib?.courses ?? []).map((c) => {c.title} )}
+
+ {draft ? Visibility in the manifest: {draft.visibility} : null}
+
+ {draft ? (
+ <>
+
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/packages/feature-learner/src/web/api.ts b/packages/feature-learner/src/web/api.ts
new file mode 100644
index 0000000..9826e1a
--- /dev/null
+++ b/packages/feature-learner/src/web/api.ts
@@ -0,0 +1,197 @@
+import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
+import type { AuthUser } from "@stc/contracts";
+import type { CoursePayload, LibraryPayload } from "../shared/types";
+import { learnerCss } from "./styles";
+
+/** Injects the learner stylesheet once (the module graph stays importable on the server). */
+export function ensureStyles(): void {
+ if (typeof document === "undefined" || document.getElementById("stc-learner-css")) return;
+ const el = document.createElement("style");
+ el.id = "stc-learner-css";
+ el.textContent = learnerCss;
+ document.head.append(el);
+}
+export { learnerCss };
+
+export class HttpError extends Error {
+ constructor(public status: number, message: string) { super(message); }
+}
+
+export async function api(path: string, init: { method?: string; body?: unknown } = {}): Promise {
+ const r = await fetch(path, {
+ method: init.method ?? "GET",
+ credentials: "same-origin",
+ headers: init.body !== undefined ? { "content-type": "application/json" } : undefined,
+ body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
+ });
+ const text = await r.text();
+ let data: unknown = null;
+ try { data = text ? JSON.parse(text) : null; } catch { data = text; }
+ if (!r.ok) {
+ const msg = (data && typeof data === "object" && ("error" in data || "message" in data))
+ ? String((data as { error?: string; message?: string }).error ?? (data as { message?: string }).message)
+ : r.status === 404 ? "Not found" : `Request failed (${r.status})`;
+ throw new HttpError(r.status, msg);
+ }
+ return data as T;
+}
+
+/** Loads `path` (null skips). `reload()` refetches without clearing the current data. */
+export function useApi(path: string | null): { data: T | null; error: HttpError | null; loading: boolean; reload: () => void; set: (v: T) => void } {
+ const [state, setState] = useState<{ path: string | null; data: T | null; error: HttpError | null; loading: boolean }>({ path, data: null, error: null, loading: !!path });
+ const [n, setN] = useState(0);
+ const current = state.path === path ? state : { path, data: null, error: null, loading: !!path };
+ useEffect(() => {
+ if (!path) return;
+ let live = true;
+ setState((s) => (s.path === path ? { ...s, loading: true } : { path, data: null, error: null, loading: true }));
+ api(path).then(
+ (data) => live && setState({ path, data, error: null, loading: false }),
+ (e: unknown) => live && setState({ path, data: null, error: e instanceof HttpError ? e : new HttpError(0, String(e)), loading: false }),
+ );
+ return () => { live = false; };
+ }, [path, n]);
+ const reload = useCallback(() => setN((x) => x + 1), []);
+ const set = useCallback((v: T) => setState((s) => ({ ...s, data: v })), []);
+ return { data: current.data, error: current.error, loading: current.loading, reload, set };
+}
+
+/* ---------- tiny shared stores ---------- */
+
+function store(initial: T) {
+ let value = initial;
+ const subs = new Set<() => void>();
+ return {
+ get: () => value,
+ set(v: T) { if (v !== value) { value = v; subs.forEach((f) => f()); } },
+ subscribe(f: () => void) { subs.add(f); return () => { subs.delete(f); }; },
+ };
+}
+function useStore(s: ReturnType>): T {
+ return useSyncExternalStore(s.subscribe, s.get, s.get);
+}
+
+const ls = {
+ get(k: string): string | null { try { return localStorage.getItem(k); } catch { return null; } },
+ set(k: string, v: string) { try { localStorage.setItem(k, v); } catch { /* private mode */ } },
+};
+
+export type Look = "reading" | "work";
+const lookStore = store((typeof window !== "undefined" && ls.get("stc.look") === "work") ? "work" : "reading");
+export function useLook(): [Look, (l: Look) => void] {
+ const v = useStore(lookStore);
+ return [v, (l) => { ls.set("stc.look", l); lookStore.set(l); }];
+}
+
+const searchStore = store("");
+export function useSearch(): [string, (q: string) => void] {
+ return [useStore(searchStore), searchStore.set];
+}
+
+const trackStore = store(0);
+export function useTrack(courseId: string, tracks: { id: string }[]): [string | null, (t: string) => void] {
+ useStore(trackStore);
+ const saved = typeof window !== "undefined" ? ls.get(`stc.track.${courseId}`) : null;
+ const value = tracks.find((t) => t.id === saved)?.id ?? tracks[0]?.id ?? null;
+ return [value, (t) => { ls.set(`stc.track.${courseId}`, t); trackStore.set(trackStore.get() + 1); }];
+}
+
+/* ---------- session and caches ---------- */
+
+const meStore = store<{ loaded: boolean; user: AuthUser | null }>({ loaded: false, user: null });
+let meLoading: Promise | null = null;
+export function refreshMe(): Promise {
+ meLoading = api<{ user: AuthUser | null }>("/api/me").then(
+ (r) => meStore.set({ loaded: true, user: r.user }),
+ () => meStore.set({ loaded: true, user: null }),
+ );
+ return meLoading;
+}
+export function useMe(): { loaded: boolean; user: AuthUser | null } {
+ const v = useStore(meStore);
+ useEffect(() => { if (!v.loaded && !meLoading) void refreshMe(); }, [v.loaded]);
+ return v;
+}
+
+const libraryStore = store(null);
+let libraryLoading: Promise | null = null;
+export function refreshLibrary(): Promise {
+ libraryLoading = api("/api/library").then((l) => libraryStore.set(l), () => { /* keep stale */ });
+ return libraryLoading;
+}
+export function useLibrary(): LibraryPayload | null {
+ const v = useStore(libraryStore);
+ useEffect(() => { if (!libraryLoading) void refreshLibrary(); }, []);
+ return v;
+}
+
+const courseCache = new Map();
+const courseVersion = store(0);
+/** The course outline with progress. Cached across route changes; `reload` refetches. */
+export function useCourse(courseId: string): { course: CoursePayload | null; error: HttpError | null; reload: () => Promise } {
+ useStore(courseVersion);
+ const [error, setError] = useState(null);
+ const live = useRef(true);
+ const reload = useCallback(async () => {
+ try {
+ const c = await api(`/api/courses/${encodeURIComponent(courseId)}`);
+ courseCache.set(courseId, c);
+ if (live.current) setError(null);
+ courseVersion.set(courseVersion.get() + 1);
+ } catch (e) {
+ if (live.current) setError(e instanceof HttpError ? e : new HttpError(0, String(e)));
+ }
+ }, [courseId]);
+ useEffect(() => {
+ live.current = true;
+ setError(null);
+ void reload();
+ return () => { live.current = false; };
+ }, [reload]);
+ return { course: courseCache.get(courseId) ?? null, error, reload };
+}
+export function clearCourseCache(): void { courseCache.clear(); courseVersion.set(courseVersion.get() + 1); }
+
+/* ---------- toast ---------- */
+
+const toastStore = store(null);
+let toastTimer: ReturnType | undefined;
+export function toast(msg: string): void {
+ toastStore.set(msg);
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => toastStore.set(null), 2600);
+}
+export function useToast(): string | null { return useStore(toastStore); }
+
+/* ---------- formatting ---------- */
+
+export function fmtTime(s: number): string {
+ const t = Math.max(0, Math.floor(s));
+ const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), sec = t % 60;
+ return h ? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}` : `${m}:${String(sec).padStart(2, "0")}`;
+}
+export function fmtMinutes(min: number | null | undefined): string {
+ if (!min) return "";
+ if (min < 60) return `${Math.round(min)} min`;
+ const h = Math.floor(min / 60), m = Math.round(min % 60);
+ return m ? `${h} h ${m} min` : `${h} h`;
+}
+export function fmtSize(n: number | null | undefined): string {
+ if (n === null || n === undefined) return "";
+ if (n < 1024) return `${n} B`;
+ if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
+}
+export const lessonHref = (courseId: string, lessonId: string) => `/c/${encodeURIComponent(courseId)}/${encodeURIComponent(lessonId)}`;
+export const courseHref = (courseId: string) => `/c/${encodeURIComponent(courseId)}`;
+
+export async function copyText(text: string): Promise {
+ try { await navigator.clipboard.writeText(text); return true; } catch {
+ const t = document.createElement("textarea");
+ t.value = text; document.body.append(t); t.select();
+ let ok = false;
+ try { ok = document.execCommand("copy"); } catch { /* unsupported */ }
+ t.remove();
+ return ok;
+ }
+}
diff --git a/packages/feature-learner/src/web/course/pages.tsx b/packages/feature-learner/src/web/course/pages.tsx
new file mode 100644
index 0000000..39eb4c9
--- /dev/null
+++ b/packages/feature-learner/src/web/course/pages.tsx
@@ -0,0 +1,232 @@
+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 { 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";
+import { HeaderExtras } from "../shell/HeaderExtras";
+import { LessonView, TitleText } from "../lesson/LessonView";
+import { NoteItem } from "../lesson/notes";
+
+function Problem({ title, children }: { title: string; children?: React.ReactNode }) {
+ ensureStyles();
+ return (
+
+
+
+ {title}
+ {children}
+ Back to the library
+
+
+ );
+}
+
+function CourseMissing({ status, signedIn }: { status: number; signedIn: boolean }) {
+ if (status === 404) {
+ return (
+
+ This course does not exist, or you do not have access to it.{signedIn ? "" : <> Signing in may help.>}
+
+ );
+ }
+ return The course could not be loaded. Try again in a moment. ;
+}
+
+/** First lesson that is not done (in an unlocked module), or the first lesson. */
+export function nextLesson(c: CoursePayload): { id: string; title: string } | null {
+ for (const m of c.modules) {
+ if (m.locked) break;
+ for (const l of m.lessons) if (c.progress[l.id]?.state !== "done") return l;
+ }
+ return c.modules[0]?.lessons[0] ?? null;
+}
+
+/* ---------- /c/:courseId ---------- */
+
+export function CoursePage() {
+ const { courseId = "" } = useParams();
+ const { course, error } = useCourse(courseId);
+ const { user } = useMe();
+ const [look] = useLook();
+ useEffect(() => { if (course) document.title = `${course.title} – STCourses`; }, [course]);
+ if (error && !course) return ;
+ if (!course) return null;
+ const { done, total } = courseDone(course);
+ const next = nextLesson(course);
+ const started = Object.keys(course.progress).length > 0;
+ const minutes = course.modules.flatMap((m) => m.lessons).reduce((s, l) => s + (l.minutes ?? 0), 0);
+ return (
+
+
+
+
+
+
{course.title}
+ {course.authors.length ?
{course.authors.join(", ")}
: null}
+
+
+ {course.pitch ? {course.pitch}
: null}
+
+
Lessons {total}
+ {minutes ?
Time About {fmtMinutes(minutes)} : null}
+ {course.tracks.length > 1 ?
Tracks {course.tracks.map((t) => t.label).join(", ")} : null}
+ {course.gating === "sequential" ?
Order Each module opens when the one before it is done : null}
+ {course.signedIn ? Progress {done} of {total} : null}
+
+ {course.status === "error" ? This course has problems. {course.canEdit ? "The list in the sidebar names them." : "Some lessons may not open."}
: null}
+
+ {next ? {started ? "Continue" : "Start"}{started ? : {next.title.replace(/`/g, "")} : null} : null}
+ {!course.signedIn ? Sign in to keep your progress and notes. : null}
+
+ Modules
+
+ {course.modules.map((m) => {
+ const d = m.lessons.filter((l) => course.progress[l.id]?.state === "done").length;
+ return (
+
+
{moduleNumber(course, m.index)}
+
{m.title} {m.lessons.length === 1 ? "1 lesson" : `${m.lessons.length} lessons`}{m.locked ? " · locked" : ""}
+ {course.signedIn ?
:
}
+
+ );
+ })}
+
+
+
+ );
+}
+
+/* ---------- /c/:courseId/:lessonId ---------- */
+
+export function LessonPage() {
+ const { courseId = "", lessonId = "" } = useParams();
+ const loc = useLocation();
+ const navigate = useNavigate();
+ const { course, error: courseError, reload } = useCourse(courseId);
+ const lessonApi = useApi(`/api/courses/${encodeURIComponent(courseId)}/lessons/${encodeURIComponent(lessonId)}`);
+ const lesson = lessonApi.data;
+ const { user } = useMe();
+ const [look] = useLook();
+ const [track, setTrack] = useTrack(courseId, course?.tracks ?? lesson?.tracks ?? []);
+ const q = useMemo(() => new URLSearchParams(loc.search), [loc.search]);
+ const initialTime = q.has("t") ? Number(q.get("t")) : null;
+ const initialSlide = q.has("slide") && q.has("deck") ? { deckId: q.get("deck")!, page: Number(q.get("slide")) } : null;
+ const progress = course?.progress[lessonId] ?? null;
+ 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;
+ try {
+ await api(`/api/courses/${encodeURIComponent(courseId)}/progress/${encodeURIComponent(lessonId)}`, { method: "PUT", body: { state, positionSeconds } });
+ if (state === "done" || progress?.state !== state) { await reload(); void refreshLibrary(); }
+ } catch (e) { toast(`Progress was not saved: ${(e as Error).message}`); }
+ }, [courseId, lessonId, signedIn, progress?.state, reload]);
+
+ if (courseError && !course) return ;
+ if (lessonApi.error && course) {
+ return (
+
+ Lesson not found This course has no lesson “{lessonId}”.
+
+ );
+ }
+ if (!course || !lesson || lesson.lesson.id !== lessonId) {
+ return course ?
: null;
+ }
+ const isDone = progress?.state === "done";
+ const isExercise = lesson.lesson.type === "exercise";
+ const n = moduleNumber(course, lesson.module.index);
+ return (
+
+ { void reload(); void refreshLibrary(); }}
+ initialTime={initialTime} initialSlide={initialSlide}
+ moduleLabel={`${/^(section|part|chapter)\b/i.test(lesson.module.title) ? "" : "Module "}${n}`.trim()}
+ />
+
+ lesson.prev && navigate(lessonHref(courseId, lesson.prev.id))}>
+ Previous lesson
+
+ void setState(isDone ? "not-started" : "done")}>
+ {isDone ? "Done" : "Mark done"}
+
+
+ lesson.next && navigate(lessonHref(courseId, lesson.next.id))}>
+ Next lesson
+
+
+
+ );
+}
+
+/* ---------- /c/:courseId/notes ---------- */
+
+export function NotesPage() {
+ const { courseId = "" } = useParams();
+ const { course, error } = useCourse(courseId);
+ const { user, loaded } = useMe();
+ const [look] = useLook();
+ const notesApi = useApi(user ? `/api/courses/${encodeURIComponent(courseId)}/notes` : null);
+ useEffect(() => { if (course) document.title = `Your notes – ${course.title}`; }, [course]);
+ if (error && !course) return ;
+ if (!course) return null;
+ 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 ?? [];
+ const groups = new Map();
+ for (const n of [...notes].sort((a, b) => (order.get(a.lessonId) ?? 1e9) - (order.get(b.lessonId) ?? 1e9) || anchorNum(a) - anchorNum(b) || a.createdAt.localeCompare(b.createdAt))) {
+ if (!groups.has(n.lessonId)) groups.set(n.lessonId, []);
+ groups.get(n.lessonId)!.push(n);
+ }
+ const remove = async (id: string) => {
+ try {
+ await api(`/api/notes/${encodeURIComponent(id)}`, { method: "DELETE" });
+ notesApi.set(notes.filter((n) => n.id !== id));
+ } catch (e) { toast(`The note was not deleted: ${(e as Error).message}`); }
+ };
+ const update = async (id: string, body: string) => {
+ try {
+ const n = await api(`/api/notes/${encodeURIComponent(id)}`, { method: "PATCH", body: { body } });
+ notesApi.set(notes.map((x) => (x.id === id ? n : x)));
+ } catch (e) { toast(`The note was not saved: ${(e as Error).message}`); }
+ };
+ return (
+
+
+ {course.title}
+ Your notes
+ Notes on lessons, video moments and slides, in course order. Only you see them.
+ {!loaded ? null : !user ? (
+ Sign in to keep notes.
+ ) : notesApi.loading && !notesApi.data ? Loading…
+ : groups.size === 0 ? No notes yet. Add them under a video, on a slide or at the end of a lesson.
+ : [...groups].map(([lid, ns]) => (
+
+
+ {titles.get(lid) ? {titles.get(lid)!.module}
: null}
+
+ {ns.map((n) => } />)}
+
+
+ ))}
+
+
+ );
+}
+
+const anchorNum = (n: Note) => (n.anchorKind === "none" ? -1 : Number(n.anchorValue ?? 0));
+
+function AnchorLink({ note }: { note: Note }) {
+ const base = lessonHref(note.courseId, note.lessonId);
+ if (note.anchorKind === "video" && note.anchorValue !== null) return Video {fmtTime(Number(note.anchorValue))};
+ if (note.anchorKind === "slide" && note.anchorValue !== null && note.deckId) return Slide {note.anchorValue};
+ return Lesson ;
+}
+
diff --git a/packages/feature-learner/src/web/icons.tsx b/packages/feature-learner/src/web/icons.tsx
new file mode 100644
index 0000000..8553689
--- /dev/null
+++ b/packages/feature-learner/src/web/icons.tsx
@@ -0,0 +1,15 @@
+const A = { fill: "none", stroke: "currentColor", "aria-hidden": true } as const;
+
+export const Chev = () => ;
+export const Down = () => ;
+export const Check = ({ size = 12 }: { size?: number }) => ;
+export const Lock = () => ;
+export const FileI = () => ;
+export const DirI = () => ;
+export const PdfI = () => ;
+export const SearchI = () => ;
+export const MenuI = () => ;
+export const Prev = () => ;
+export const Next = () => ;
+export const Full = () => ;
+export const DownloadI = () => ;
diff --git a/packages/feature-learner/src/web/index.ts b/packages/feature-learner/src/web/index.ts
new file mode 100644
index 0000000..4fcd26f
--- /dev/null
+++ b/packages/feature-learner/src/web/index.ts
@@ -0,0 +1,17 @@
+import type { WebFeature } from "@stc/contracts";
+
+export { LessonView, type LessonViewProps } from "./lesson/LessonView";
+export { ensureStyles as ensureLearnerStyles } from "./api";
+
+/** Learner routes: library, course pages, lessons, notes and the account pages. */
+export const learnerWeb: WebFeature = {
+ name: "learner",
+ routes: [
+ { path: "/", element: () => import("./library/LibraryPage") },
+ { path: "/c/:courseId", element: () => import("./course/pages").then((m) => ({ default: m.CoursePage })) },
+ { path: "/c/:courseId/notes", element: () => import("./course/pages").then((m) => ({ default: m.NotesPage })) },
+ { path: "/c/:courseId/:lessonId", element: () => import("./course/pages").then((m) => ({ default: m.LessonPage })) },
+ { path: "/account", element: () => import("./account/AccountPage") },
+ { path: "/account/admin", element: () => import("./account/AdminPage") },
+ ],
+};
diff --git a/packages/feature-learner/src/web/lesson/CodeFiles.tsx b/packages/feature-learner/src/web/lesson/CodeFiles.tsx
new file mode 100644
index 0000000..913ff2a
--- /dev/null
+++ b/packages/feature-learner/src/web/lesson/CodeFiles.tsx
@@ -0,0 +1,155 @@
+import { useEffect, useMemo, useState } from "react";
+import { Pill } from "@stc/ui";
+import type { CodeListing, FileRef } from "../../shared/types";
+import { mediaUrl } from "../../shared/types";
+import { copyText, fmtSize, useApi } from "../api";
+import { DirI, DownloadI, FileI, PdfI } from "../icons";
+
+const MAX_VIEW = 400 * 1024;
+const BINARY = /\.(png|jpe?g|gif|webp|ico|pdf|zip|gz|tgz|jar|class|exe|dll|so|dylib|bin|mp4|mp3|wav|woff2?|ttf|otf|sqlite|db|pyc)$/i;
+
+export const zipUrl = (courseId: string, moduleId: string) => `/api/courses/${encodeURIComponent(courseId)}/code/${encodeURIComponent(moduleId)}/zip`;
+
+interface TreeNode { name: string; path: string; dirs: Map; files: { name: string; path: string; size: number }[] }
+
+export function buildTree(files: { path: string; size: number }[]): TreeNode {
+ const root: TreeNode = { name: "", path: "", dirs: new Map(), files: [] };
+ for (const f of files) {
+ const parts = f.path.split("/");
+ let node = root;
+ for (const p of parts.slice(0, -1)) {
+ if (!node.dirs.has(p)) node.dirs.set(p, { name: p, path: node.path ? `${node.path}/${p}` : p, dirs: new Map(), files: [] });
+ node = node.dirs.get(p)!;
+ }
+ node.files.push({ name: parts[parts.length - 1]!, path: f.path, size: f.size });
+ }
+ return root;
+}
+
+/** Picks the first file to show: a README or the entry point, else the first text file. */
+function firstFile(files: { path: string; size: number }[]): string | null {
+ const text = files.filter((f) => !BINARY.test(f.path) && f.size <= MAX_VIEW);
+ const pref = [/(^|\/)dockerfile[^/]*$/i, /(^|\/)(main|index|app)\.[^/]+$/i, /(^|\/)readme(\.md)?$/i];
+ for (const r of pref) { const f = text.find((x) => r.test(x.path)); if (f) return f.path; }
+ return text[0]?.path ?? null;
+}
+
+/**
+ * Companion code for a section: file tree, tabs, line numbers, copy, the zip download and the command
+ * that runs it locally. Files are read through the media route, so visibility checks apply.
+ */
+export function CodeFiles({ courseId, moduleId, dir, title = "Files for this section" }: { courseId: string; moduleId: string; dir?: string; title?: string }) {
+ const { data: listing, error } = useApi(`/api/courses/${encodeURIComponent(courseId)}/code/${encodeURIComponent(moduleId)}`);
+ const [tabs, setTabs] = useState([]);
+ const [current, setCurrent] = useState(null);
+ const [content, setContent] = useState>({});
+ useEffect(() => {
+ if (!listing) return;
+ const f = firstFile(listing.files);
+ setTabs(f ? [f] : []); setCurrent(f); setContent({});
+ }, [listing]);
+ useEffect(() => {
+ if (!listing || !current || current in content) return;
+ const f = listing.files.find((x) => x.path === current);
+ if (!f) return;
+ if (BINARY.test(f.path) || f.size > MAX_VIEW) { setContent((c) => ({ ...c, [current]: "" })); return; }
+ let live = true;
+ fetch(mediaUrl(courseId, `${listing.dir}/${f.path}`), { credentials: "same-origin" })
+ .then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
+ .then((t) => live && setContent((c) => ({ ...c, [current]: t })), () => live && setContent((c) => ({ ...c, [current]: "\u0000error" })));
+ return () => { live = false; };
+ }, [listing, current, content, courseId]);
+ const tree = useMemo(() => buildTree(listing?.files ?? []), [listing]);
+
+ const folder = (listing?.dir ?? dir ?? "").split("/").filter(Boolean).pop() ?? moduleId;
+ const head = (
+
+ );
+ if (error) return null;
+ if (!listing) return ;
+ const open = (p: string) => { setTabs((t) => (t.includes(p) ? t : [...t.slice(-5), p])); setCurrent(p); };
+ const file = listing.files.find((f) => f.path === current);
+ const text = current ? content[current] : undefined;
+ const lines = text && !text.startsWith("\u0000") ? text.replace(/\n$/, "").split("\n") : null;
+
+ return (
+
+ {head}
+ {listing.files.length === 0 ? This folder is empty.
: (
+
+
+ {folder}
+
+ {listing.truncated ? Only the first {listing.files.length} files are listed.
: null}
+
+
+
+ {tabs.map((t) => (
+ setCurrent(t)}>{t.split("/").pop()}
+ ))}
+ {lines ? : null}
+
+ {file ?
{folder}/{file.path} · {fmtSize(file.size)}
: null}
+ {!file ?
Pick a file on the left.
+ : text === undefined ?
Loading…
+ : lines ?
{lines.map((l, i) => {l || " "} )}
+ :
{text === "" ? "This file is not shown here (binary or large). It is in the zip." : "This file could not be loaded."} Download it
}
+
+
+ )}
+ {listing.command ? : null}
+
+ );
+}
+
+function TreeLevel({ node, depth, current, onOpen }: { node: TreeNode; depth: number; current: string | null; onOpen: (p: string) => void }) {
+ const pad = { paddingLeft: 12 + depth * 14 };
+ return (
+ <>
+ {[...node.dirs.values()].map((d) => (
+
+ ))}
+ {node.files.map((f) => (
+ onOpen(f.path)}> {f.name}
+ ))}
+ >
+ );
+}
+
+export function CopyButton({ text, label = "Copy" }: { text: string; label?: string }) {
+ const [done, setDone] = useState(false);
+ return (
+ { if (await copyText(text)) { setDone(true); setTimeout(() => setDone(false), 1400); } }}>
+ {done ? "Copied" : label}
+
+ );
+}
+
+export function CommandBlock({ command, intro }: { command: string; intro?: string }) {
+ return (
+
+ {intro ?
{intro}
: null}
+
+
+ );
+}
+
+export function Attachments({ files, title = "Attachments", note }: { files: FileRef[]; title?: string; note?: string }) {
+ if (!files.length) return null;
+ return (
+
+
{title} {note ? {note} : null}
+
+ {files.map((f) => (
+ {f.title} {fmtSize(f.size)}
+ ))}
+
+
+ );
+}
diff --git a/packages/feature-learner/src/web/lesson/DeckViewer.tsx b/packages/feature-learner/src/web/lesson/DeckViewer.tsx
new file mode 100644
index 0000000..11d367a
--- /dev/null
+++ b/packages/feature-learner/src/web/lesson/DeckViewer.tsx
@@ -0,0 +1,196 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Pill } from "@stc/ui";
+import type { DeckRef } from "../../shared/types";
+import { Full, Next, Prev } from "../icons";
+import type { NotesApi } from "./notes";
+
+/** The pdf.js worker is served by the learner API from the installed pdfjs-dist. */
+export const PDF_WORKER_URL = "/api/learner/pdf.worker.mjs";
+
+// biome-ignore lint/suspicious/noExplicitAny: pdf.js document proxy (types are loaded lazily with the library)
+type PdfDoc = any;
+
+let pdfjsPromise: Promise | null = null;
+function loadPdfjs() {
+ pdfjsPromise ??= import("pdfjs-dist").then((m) => { m.GlobalWorkerOptions.workerSrc = PDF_WORKER_URL; return m; });
+ 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 isTyping = (t: EventTarget | null) => !!(t as Element | null)?.closest?.("input, textarea, select, video, audio, [contenteditable], [role='slider']");
+
+/**
+ * Slide deck viewer: 16:9 stage, previous/next, page counter, thumbnails, fullscreen, arrow keys (when
+ * `keyboard` is set) and a note per slide. Pages are rendered in the browser with pdf.js.
+ */
+export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, title }: {
+ deck: DeckRef; notes: NotesApi | null; initialPage?: number; keyboard?: boolean; title?: string;
+}) {
+ const [doc, setDoc] = useState(null);
+ const [pages, setPages] = useState(0);
+ const [page, setPage] = useState(initialPage);
+ const [error, setError] = useState(null);
+ const stage = useRef(null);
+ const canvas = useRef(null);
+
+ useEffect(() => {
+ let live = true, loaded: PdfDoc | null = null;
+ setDoc(null); setError(null); setPages(0);
+ loadPdfjs()
+ .then((pdfjs) => pdfjs.getDocument({ url: deck.url, withCredentials: true }).promise)
+ .then((d: PdfDoc) => {
+ loaded = d;
+ if (!live) { void d.destroy(); 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(); };
+ }, [deck.url]);
+
+ // Render the current page to fit the stage.
+ useEffect(() => {
+ if (!doc || !canvas.current || !stage.current) return;
+ let live = true;
+ const draw = () => {
+ const r = stage.current!.getBoundingClientRect();
+ if (live) renderPage(doc, page, canvas.current!, r.width, r.height).catch(() => {});
+ };
+ draw();
+ const ro = new ResizeObserver(() => draw());
+ ro.observe(stage.current);
+ return () => { live = false; ro.disconnect(); };
+ }, [doc, page]);
+
+ const go = useCallback((p: number) => setPage((cur) => (pages ? Math.min(Math.max(1, p), pages) : cur)), [pages]);
+
+ useEffect(() => {
+ if (!keyboard) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.ctrlKey || e.metaKey || e.altKey || isTyping(e.target)) return;
+ if (e.key === "ArrowRight") { e.preventDefault(); setPage((p) => Math.min(p + 1, pages || p)); }
+ else if (e.key === "ArrowLeft") { e.preventDefault(); setPage((p) => Math.max(p - 1, 1)); }
+ };
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, [keyboard, pages]);
+
+ useEffect(() => {
+ document.querySelector(`[data-deck="${CSS.escape(deck.id)}"] .thumbs [aria-current="true"]`)?.scrollIntoView({ block: "nearest", inline: "nearest" });
+ }, [page, deck.id]);
+
+ const fullscreen = () => {
+ if (document.fullscreenElement) void document.exitFullscreen();
+ else stage.current?.requestFullscreen?.().catch(() => {});
+ };
+ const slideNotes = notes?.notes.filter((n) => n.anchorKind === "slide" && n.deckId === deck.id) ?? [];
+ const withNote = new Set(slideNotes.map((n) => Number(n.anchorValue)));
+
+ return (
+
+
+ {error ? {error} Open the PDF : null}
+ {!error && !doc ? Loading slides… : null}
+
+
+
+
go(page - 1)}>
+
{pages ? `${page} / ${pages}` : "– / –"}
+
= pages} onClick={() => go(page + 1)}>
+
+ {keyboard ?
Arrow keys change slides : null}
+
PDF
+
Fullscreen
+
+ {doc ? : null}
+ {notes ? Number(n.anchorValue) === page)} /> : null}
+
+ );
+}
+
+function Thumbs({ doc, pages, page, onPick, withNote }: { doc: PdfDoc; pages: number; page: number; onPick: (p: number) => void; withNote: Set }) {
+ const ref = useRef(null);
+ useEffect(() => {
+ const root = ref.current;
+ if (!root) return;
+ const queue: HTMLCanvasElement[] = [];
+ let running = false, live = true;
+ const pump = async () => {
+ if (running) return;
+ running = true;
+ while (live && queue.length) {
+ const c = queue.shift()!;
+ await renderPage(doc, Number(c.dataset.p), c, 104).catch(() => {});
+ }
+ running = false;
+ };
+ const io = new IntersectionObserver((entries) => {
+ for (const e of entries) {
+ if (!e.isIntersecting) continue;
+ const c = e.target as HTMLCanvasElement;
+ io.unobserve(c);
+ queue.push(c);
+ }
+ void pump();
+ }, { root, rootMargin: "0px 400px" });
+ root.querySelectorAll("canvas").forEach((c) => io.observe(c));
+ return () => { live = false; io.disconnect(); };
+ }, [doc, pages]);
+ return (
+
+ {Array.from({ length: pages }, (_, i) => (
+ onPick(i + 1)}>
+
+
+ ))}
+
+ );
+}
+
+function SlideNote({ notes, deckId, page, existing }: { notes: NotesApi; deckId: string; page: number; existing: { id: string; body: string }[] }) {
+ const first = existing[0];
+ const [text, setText] = useState(first?.body ?? "");
+ const [saved, setSaved] = useState<"idle" | "saving" | "saved">("idle");
+ const busy = useRef(false);
+ useEffect(() => { setText(first?.body ?? ""); setSaved("idle"); }, [page, deckId, first?.id, first?.body]);
+ if (!notes.enabled) return null;
+ const dirty = text.trim() !== (first?.body ?? "").trim();
+ const save = async () => {
+ if (!dirty || busy.current) return;
+ busy.current = true;
+ setSaved("saving");
+ const body = text.trim();
+ try {
+ if (first && !body) await notes.remove(first.id);
+ else if (first) await notes.update(first.id, body);
+ else if (body) await notes.create({ anchorKind: "slide", anchorValue: page, deckId, body });
+ setSaved("saved");
+ } finally { busy.current = false; }
+ };
+ return (
+
+
Your note on slide {page}
+
+ );
+}
diff --git a/packages/feature-learner/src/web/lesson/Exercise.tsx b/packages/feature-learner/src/web/lesson/Exercise.tsx
new file mode 100644
index 0000000..6a6d05a
--- /dev/null
+++ b/packages/feature-learner/src/web/lesson/Exercise.tsx
@@ -0,0 +1,178 @@
+import { useEffect, useRef, useState } from "react";
+import { Pill } from "@stc/ui";
+import type { ExerciseRunner, TestResult } from "@stc/contracts";
+import type { LessonPayload } from "../../shared/types";
+import { api, toast } from "../api";
+import { createBrowserRunner } from "../runner/runner";
+import { CommandBlock } from "./CodeFiles";
+import { Chev } from "../icons";
+
+export const RUN_TIMEOUT_MS = 3000;
+
+export function localCommandFor(cmd: LessonPayload["localCommand"], track: string | null): string | null {
+ if (!cmd) return null;
+ if (typeof cmd === "string") return cmd;
+ return (track && cmd[track]) || Object.values(cmd)[0] || null;
+}
+
+const attemptUrl = (l: LessonPayload, track: string) =>
+ `/api/courses/${encodeURIComponent(l.courseId)}/lessons/${encodeURIComponent(l.lesson.id)}/attempt?track=${encodeURIComponent(track)}`;
+
+/**
+ * The right-hand side of an exercise page. With a browser exercise: editor, Run tests, Reset and per-test
+ * results (run in a Web Worker). Without one: the local command, plus optional pasted evidence.
+ */
+export function ExercisePanel({ lesson, track, signedIn, preview, onSaved, runner }: {
+ lesson: LessonPayload;
+ track: string | null;
+ signedIn: boolean;
+ preview?: boolean;
+ onSaved?: () => void;
+ runner?: ExerciseRunner;
+}) {
+ if (lesson.browser) return ;
+ return ;
+}
+
+function BrowserExercise({ lesson, track, save, onSaved, runner }: {
+ lesson: LessonPayload; track: string | null; save: boolean; onSaved?: () => void; runner?: ExerciseRunner;
+}) {
+ const b = lesson.browser!;
+ const key = b.language;
+ const [code, setCode] = useState(b.starter);
+ const [results, setResults] = useState(null);
+ const [running, setRunning] = useState(false);
+ const ta = useRef(null);
+ const gutter = useRef(null);
+
+ useEffect(() => {
+ setCode(b.starter); setResults(null);
+ if (!save) return;
+ let live = true;
+ api<{ code: string; result: TestResult[] | null } | null>(attemptUrl(lesson, key)).then((a) => {
+ if (!live || !a) return;
+ setCode(a.code);
+ if (Array.isArray(a.result)) setResults(a.result);
+ }, () => {});
+ return () => { live = false; };
+ }, [lesson.courseId, lesson.lesson.id, b.starter, key, save]);
+
+ const run = async () => {
+ setRunning(true);
+ try {
+ const r = runner ?? createBrowserRunner(b.language);
+ const res = await r.run(code, b.tests, RUN_TIMEOUT_MS);
+ setResults(res);
+ const passed = res.length > 0 && res.every((t) => t.passed);
+ if (save) {
+ try {
+ await api(attemptUrl(lesson, key).split("?")[0]!, { method: "PUT", body: { track: key, code, result: res, passed } });
+ onSaved?.();
+ } catch (e) { toast(`Your code was not saved: ${(e as Error).message}`); }
+ }
+ } finally { setRunning(false); }
+ };
+ const reset = () => {
+ if (code !== b.starter && !confirm("Reset the editor to the starting code? Your changes are lost.")) return;
+ setCode(b.starter); setResults(null); toast("Code reset to the starting code");
+ };
+ const lines = code.split("\n").length;
+ const n = results?.filter((t) => t.passed).length ?? 0;
+ const all = !!results && results.length > 0 && n === results.length;
+
+ return (
+
+
+
{b.starterFile}
+
Reset
+
{running ? "Running…" : "Run tests"}
+
+
+
{Array.from({ length: lines }, (_, i) => i + 1).join("\n")}
+
+
+ {!results ? (
+
Not run yet. The tests run in your browser, in a sandboxed worker.
+ ) : (
+ <>
+
{n} of {results.length} passed{all ? ". This exercise is done." : ""}
+
+ >
+ )}
+
+ {b.solution ? (
+
+ Solution (only authors see this)
+
+
+ ) : null}
+
+ );
+}
+
+function LocalExercise({ lesson, track, save, onSaved }: { lesson: LessonPayload; track: string | null; save: boolean; onSaved?: () => void }) {
+ const cmd = localCommandFor(lesson.localCommand, track);
+ const key = track ?? "default";
+ const [evidence, setEvidence] = useState("");
+ const [saved, setSaved] = useState(false);
+ useEffect(() => {
+ setEvidence(""); setSaved(false);
+ if (!save) return;
+ let live = true;
+ api<{ code: string } | null>(attemptUrl(lesson, key)).then((a) => { if (live && a) { setEvidence(a.code); setSaved(true); } }, () => {});
+ return () => { live = false; };
+ }, [lesson.courseId, lesson.lesson.id, key, save]);
+ const submit = async () => {
+ try {
+ // Pasted passing output counts as a pass: the lesson is marked done.
+ await api(attemptUrl(lesson, key).split("?")[0]!, { method: "PUT", body: { track: key, code: evidence, result: { kind: "evidence" }, passed: true } });
+ setSaved(true);
+ onSaved?.();
+ toast("Output saved. The exercise is marked done.");
+ } catch (e) { toast(`Not saved: ${(e as Error).message}`); }
+ };
+ return (
+
+
Run it on your machine
+
This exercise runs locally, in the course’s own project. Run the tests from the course folder; when they pass, mark the exercise done.
+ {cmd ?
:
The course does not name a command for this exercise. Its instructions on the left say how to check your work.
}
+ {save ? (
+
+
Passing output (optional). Paste it here to keep it with your progress.
+
+ ) : null}
+
+ );
+}
diff --git a/packages/feature-learner/src/web/lesson/LessonView.tsx b/packages/feature-learner/src/web/lesson/LessonView.tsx
new file mode 100644
index 0000000..2e4735b
--- /dev/null
+++ b/packages/feature-learner/src/web/lesson/LessonView.tsx
@@ -0,0 +1,260 @@
+import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import { Link } from "react-router-dom";
+import { SegmentedToggle } from "@stc/ui";
+import type { ExerciseRunner } from "@stc/contracts";
+import type { LessonPayload, LessonProgress, ProgressState } from "../../shared/types";
+import { ensureStyles, fmtMinutes, fmtTime, type Look } from "../api";
+import { renderHintsHtml, renderLessonHtml } from "../markdown";
+import { Attachments, CodeFiles, CommandBlock, zipUrl } from "./CodeFiles";
+import { DeckViewer } from "./DeckViewer";
+import { ExercisePanel, localCommandFor } from "./Exercise";
+import { NotesPanel, useLessonNotes, type NotesApi } from "./notes";
+import { Prose } from "./Prose";
+import { DownloadI } from "../icons";
+import { TitleText } from "./TitleText";
+
+export interface LessonViewProps {
+ lesson: LessonPayload;
+ /** Reading or Work. Exercise pages always use Work. */
+ look?: Look;
+ track?: string | null;
+ onTrackChange?: (track: string) => void;
+ progress?: LessonProgress | null;
+ /** Signed-in learners get notes and saved attempts. */
+ signedIn?: boolean;
+ /** Studio preview: no notes, no saved attempts, no progress reports. */
+ preview?: boolean;
+ onProgress?: (state: ProgressState, positionSeconds?: number) => void;
+ /** An exercise attempt was saved (the server already updated progress). */
+ onSaved?: () => void;
+ /** Start the video here (seconds), e.g. from a timestamp note. */
+ initialTime?: number | null;
+ /** Open a deck at this page, e.g. from a slide note. */
+ initialSlide?: { deckId: string; page: number } | null;
+ /** Replaces the Web Worker runner (tests). */
+ runner?: ExerciseRunner;
+ /** Label for the module line above the title, e.g. "Module 3". */
+ moduleLabel?: string;
+}
+
+export { TitleText };
+
+/**
+ * Renders one lesson of any type from its `LessonPayload`: text (track switcher, folds, hint tiers),
+ * video (player, notes with timestamps, transcript, companion deck), exercise (instructions, hints,
+ * browser runner or local command), plus decks, code files and attachments.
+ * Presentational: it fetches only notes, saved attempts and code listings. The studio uses it for previews.
+ */
+export function LessonView(props: LessonViewProps) {
+ ensureStyles();
+ const { lesson, preview = false, signedIn = false } = props;
+ const notes = useLessonNotes(lesson.courseId, lesson.lesson.id, signedIn && !preview && !lesson.locked);
+ const look: Look = lesson.lesson.type === "exercise" ? "work" : props.look ?? "reading";
+ const track = props.track ?? lesson.tracks[0]?.id ?? null;
+ const chap = props.moduleLabel ? `${props.moduleLabel}: ${lesson.module.title}` : lesson.module.title;
+
+ let body: ReactNode;
+ if (lesson.locked) body = ;
+ else if (lesson.lesson.type === "video") body = ;
+ else if (lesson.lesson.type === "exercise") body = ;
+ else body = ;
+
+ return {body}
;
+}
+
+function Locked({ lesson, chap }: { lesson: LessonPayload; chap: string }) {
+ return (
+
+ {chap}
+
+
+
This module is locked. It opens when every lesson in the modules before it is done.
+ {lesson.prev ?
Back to {lesson.prev.title}
: null}
+
+
+ );
+}
+
+function TrackSwitch({ lesson, track, onChange }: { lesson: LessonPayload; track: string | null; onChange?: (t: string) => void }) {
+ if (lesson.tracks.length < 2 || !track) return null;
+ return (
+
+
Track
+ ({ value: t.id, label: t.label }))} onChange={(v) => onChange?.(v)} />
+
+ );
+}
+
+function useLessonHtml(lesson: LessonPayload, track: string | null): string | null {
+ return useMemo(() => {
+ const md = (track && lesson.trackMarkdown[track]) || lesson.markdown;
+ if (!md) return null;
+ return renderLessonHtml(md, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles, stripFirstHeading: true });
+ }, [lesson, track]);
+}
+
+function Decks({ lesson, notes, initialSlide, keyboard }: { lesson: LessonPayload; notes: NotesApi | null; initialSlide?: { deckId: string; page: number } | null; keyboard: boolean }) {
+ if (!lesson.decks.length) return null;
+ return (
+ <>
+ {lesson.decks.map((d, i) => (
+
+ {lesson.decks.length > 1 || lesson.lesson.type !== "text" ?
{d.title} Slides : null}
+
+
+ ))}
+ >
+ );
+}
+
+function TextLesson(p: LessonViewProps & { chap: string; track: string | null; notes: NotesApi }) {
+ const { lesson, track } = p;
+ const html = useLessonHtml(lesson, track);
+ const cmd = localCommandFor(lesson.localCommand, track);
+ const minutes = lesson.lesson.minutes;
+ const hasMeta = !!minutes || lesson.tracks.length > 1;
+ const decks = ;
+ return (
+
+
+
{p.chap}
+
+ {hasMeta ? (
+
+ {minutes ?
Time About {fmtMinutes(minutes)} : null}
+
+
+ ) : null}
+ {cmd ?
: null}
+
+ {!html ? decks : null}
+ {html ? : null}
+ {!html && !lesson.decks.length ? This lesson has no text yet.
: null}
+ {html ? decks : null}
+ {lesson.code ? : null}
+
+
+ {!p.preview ?
: null}
+
+
+ );
+}
+
+function VideoLesson(p: LessonViewProps & { chap: string; notes: NotesApi }) {
+ const { lesson } = p;
+ const v = useRef(null);
+ const [browse, setBrowse] = useState(false);
+ const transcriptHtml = useMemo(() => (lesson.transcript
+ ? renderLessonHtml(lesson.transcript, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, lessonFiles: lesson.lessonFiles, stripFirstHeading: true })
+ : null), [lesson]);
+ const control = useMemo(() => ({
+ currentTime: () => v.current?.currentTime ?? 0,
+ seek: (s: number) => { const el = v.current; if (el) { el.currentTime = s; void el.play().catch(() => {}); el.scrollIntoView({ block: "nearest", behavior: "smooth" }); } },
+ }), []);
+ useVideoProgress(v, p);
+ const dur = lesson.video?.duration ?? (lesson.lesson.minutes ? lesson.lesson.minutes * 60 : null);
+ const folder = lesson.code?.dir.split("/").filter(Boolean).pop();
+ return (
+
+ {p.chap}
+
+ Video {dur ? `, ${fmtTime(dur)}` : ""}.{p.progress?.positionSeconds && p.progress.state !== "done" ? ` You stopped at ${fmtTime(p.progress.positionSeconds)}.` : ""}
+ {lesson.video ? (
+
+ ) : The video file for this lesson is missing.
}
+
+
+
+ {!p.preview ?
: null}
+ {transcriptHtml ? (
+
+ Transcript
+
+
+ ) : null}
+
+
+
Files for this section {lesson.module.title}
+ {lesson.attachments.length || lesson.code ? (
+
+ {lesson.code ? (
+ {folder}.zip setBrowse(!browse)} aria-expanded={browse}>{browse ? "Hide" : "Browse"}
+ ) : null}
+ {lesson.attachments.map((f) => (
+ {f.title} {f.size ? `${Math.max(1, Math.round(f.size / 1024))} KB` : ""}
+ ))}
+
+ ) : This section has no files.
}
+
+
+ {browse && lesson.code ? : null}
+
+ );
+}
+
+/** Resume position, periodic position reports, and "done" near the end (spec 4.4). */
+function useVideoProgress(v: React.RefObject, p: LessonViewProps) {
+ const report = useRef(p.onProgress);
+ report.current = p.onProgress;
+ const doneRef = useRef(p.progress?.state === "done");
+ doneRef.current = p.progress?.state === "done";
+ const start = p.initialTime ?? (p.progress && p.progress.state !== "done" ? p.progress.positionSeconds : null);
+ useEffect(() => {
+ const el = v.current;
+ if (!el || p.preview) return;
+ let lastSent = -1;
+ const onMeta = () => { if (start && start > 0 && (!el.duration || start < el.duration - 2)) el.currentTime = start; };
+ const send = () => {
+ if (!el.duration || doneRef.current) return;
+ const t = el.currentTime;
+ if (t >= el.duration * 0.95 || el.duration - t < 10) { doneRef.current = true; report.current?.("done", t); return; }
+ if (Math.abs(t - lastSent) >= 5) { lastSent = t; report.current?.("in-progress", t); }
+ };
+ let tick = 0;
+ const onTime = () => { if (++tick % 40 === 0 || (el.duration && el.duration - el.currentTime < 10)) send(); };
+ if (el.readyState >= 1) onMeta(); else el.addEventListener("loadedmetadata", onMeta, { once: true });
+ el.addEventListener("pause", send);
+ el.addEventListener("ended", send);
+ el.addEventListener("timeupdate", onTime);
+ return () => {
+ el.removeEventListener("loadedmetadata", onMeta);
+ el.removeEventListener("pause", send);
+ el.removeEventListener("ended", send);
+ el.removeEventListener("timeupdate", onTime);
+ if (el.currentTime > 0 && !el.ended) send();
+ };
+ }, [p.lesson.courseId, p.lesson.lesson.id, p.preview, start, v]);
+}
+
+function ExerciseLesson(p: LessonViewProps & { chap: string; track: string | null }) {
+ const { lesson, track } = p;
+ const html = useLessonHtml(lesson, track);
+ const hintsHtml = useMemo(() => (lesson.hints.length
+ ? renderHintsHtml(lesson.hints, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles })
+ : null), [lesson, track]);
+ const cmd = localCommandFor(lesson.localCommand, track);
+ return (
+
+
+
+
{p.chap}
+
+ {lesson.tracks.length > 1 && !lesson.browser ?
: null}
+ {html ?
:
The instructions for this exercise are missing.
}
+ {hintsHtml ? (
+
+ ) : null}
+ {lesson.browser && cmd ? (
+
Runs in your browser; the full project runs on your machine. To run the whole module locally:{cmd}
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/packages/feature-learner/src/web/lesson/Prose.tsx b/packages/feature-learner/src/web/lesson/Prose.tsx
new file mode 100644
index 0000000..9c75ad7
--- /dev/null
+++ b/packages/feature-learner/src/web/lesson/Prose.tsx
@@ -0,0 +1,72 @@
+import { useEffect, useRef, type CSSProperties } from "react";
+import { useInRouterContext, useNavigate } from "react-router-dom";
+
+/** Router navigation when a router is present (the studio may embed LessonView without one). */
+export function useSafeNavigate(): ((to: string) => void) | null {
+ const inRouter = useInRouterContext();
+ // The router context of a mounted tree never changes, so the hook order is stable.
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ return inRouter ? useNavigate() : null;
+}
+
+/**
+ * Sets up hint tiers inside `root`: later tiers of a group wait (`data-wait`) until the previous tier was opened.
+ * Returns a cleanup function.
+ */
+export function wireHintTiers(root: HTMLElement): () => void {
+ const tiers = (group: string) => Array.from(root.querySelectorAll(`details.fold[data-group="${CSS.escape(group)}"]`));
+ for (const d of root.querySelectorAll("details.fold[data-tier]")) {
+ if (Number(d.dataset.tier) > 1 && !d.dataset.seen) {
+ d.dataset.wait = "1";
+ const sum = d.querySelector("summary");
+ sum?.setAttribute("title", `Open hint ${Number(d.dataset.tier) - 1} first`);
+ sum?.setAttribute("aria-disabled", "true");
+ }
+ }
+ const onClick = (e: MouseEvent) => {
+ const sum = (e.target as Element).closest?.("summary");
+ const d = sum?.parentElement as HTMLDetailsElement | null;
+ if (!d || !root.contains(d) || !d.matches("details.fold[data-wait]")) return;
+ e.preventDefault();
+ };
+ const onToggle = (e: Event) => {
+ const d = e.target as HTMLDetailsElement;
+ if (!d.matches?.("details.fold[data-group]") || !d.open) return;
+ if (d.dataset.wait) { d.open = false; return; }
+ d.dataset.seen = "1";
+ const next = tiers(d.dataset.group!).find((x) => Number(x.dataset.tier) === Number(d.dataset.tier) + 1);
+ if (next && next.dataset.wait) {
+ delete next.dataset.wait;
+ const sum = next.querySelector("summary");
+ sum?.removeAttribute("title");
+ sum?.removeAttribute("aria-disabled");
+ }
+ };
+ root.addEventListener("click", onClick);
+ root.addEventListener("toggle", onToggle, true);
+ return () => { root.removeEventListener("click", onClick); root.removeEventListener("toggle", onToggle, true); };
+}
+
+/** Sanitized lesson HTML (from `renderLessonHtml`) with hint tiers and in-app links. */
+export function Prose({ html, className = "prose", style }: { html: string; className?: string; style?: CSSProperties }) {
+ const ref = useRef(null);
+ const navigate = useSafeNavigate();
+ useEffect(() => {
+ const root = ref.current;
+ if (!root) return;
+ const off = wireHintTiers(root);
+ const onLink = (e: MouseEvent) => {
+ const a = (e.target as Element).closest?.("a");
+ if (!a || !navigate || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
+ const href = a.getAttribute("href") ?? "";
+ if (href.startsWith("/c/")) { e.preventDefault(); navigate(href); }
+ else if (href.startsWith("#")) {
+ const el = document.getElementById(decodeURIComponent(href.slice(1)));
+ if (el) { e.preventDefault(); el.scrollIntoView({ behavior: "smooth", block: "start" }); }
+ }
+ };
+ root.addEventListener("click", onLink);
+ return () => { off(); root.removeEventListener("click", onLink); };
+ }, [html, navigate]);
+ return
;
+}
diff --git a/packages/feature-learner/src/web/lesson/TitleText.tsx b/packages/feature-learner/src/web/lesson/TitleText.tsx
new file mode 100644
index 0000000..ca98413
--- /dev/null
+++ b/packages/feature-learner/src/web/lesson/TitleText.tsx
@@ -0,0 +1,7 @@
+/** Inline `code` spans in titles such as "Exercise 1.1 — `range`". */
+export function TitleText({ text }: { text: string }) {
+ const parts = text.split(/(`[^`]+`)/g);
+ return <>{parts.map((p, i) => (p.startsWith("`") && p.endsWith("`") && p.length > 2 ? {p.slice(1, -1)} : p))}>;
+}
+
+export const plainTitle = (t: string) => t.replace(/`/g, "");
diff --git a/packages/feature-learner/src/web/lesson/notes.tsx b/packages/feature-learner/src/web/lesson/notes.tsx
new file mode 100644
index 0000000..a6d6697
--- /dev/null
+++ b/packages/feature-learner/src/web/lesson/notes.tsx
@@ -0,0 +1,171 @@
+import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
+import { Link } from "react-router-dom";
+import { Pill } from "@stc/ui";
+import type { Note } from "../../shared/types";
+import { api, fmtTime, lessonHref, toast } from "../api";
+
+export interface NotesApi {
+ enabled: boolean;
+ notes: Note[];
+ create(n: { anchorKind: Note["anchorKind"]; anchorValue?: number | null; deckId?: string | null; body: string }): Promise;
+ update(id: string, body: string): Promise;
+ remove(id: string): Promise;
+}
+
+/** The signed-in user's notes for one lesson. Disabled (no requests) when signed out or in a preview. */
+export function useLessonNotes(courseId: string, lessonId: string, enabled: boolean): NotesApi {
+ const [notes, setNotes] = useState([]);
+ useEffect(() => {
+ setNotes([]);
+ if (!enabled) return;
+ let live = true;
+ api(`/api/courses/${encodeURIComponent(courseId)}/notes?lesson=${encodeURIComponent(lessonId)}`).then((n) => live && setNotes(n), () => {});
+ return () => { live = false; };
+ }, [courseId, lessonId, enabled]);
+ const create = useCallback(async (n) => {
+ try {
+ const note = await api(`/api/courses/${encodeURIComponent(courseId)}/notes`, { method: "POST", body: { lessonId, ...n } });
+ setNotes((xs) => [...xs, note]);
+ return note;
+ } catch (e) { toast(`The note was not saved: ${(e as Error).message}`); return null; }
+ }, [courseId, lessonId]);
+ const update = useCallback(async (id, body) => {
+ try {
+ const note = await api(`/api/notes/${encodeURIComponent(id)}`, { method: "PATCH", body: { body } });
+ setNotes((xs) => xs.map((x) => (x.id === id ? note : x)));
+ } catch (e) { toast(`The note was not saved: ${(e as Error).message}`); }
+ }, []);
+ const remove = useCallback(async (id) => {
+ try {
+ await api(`/api/notes/${encodeURIComponent(id)}`, { method: "DELETE" });
+ setNotes((xs) => xs.filter((x) => x.id !== id));
+ } catch (e) { toast(`The note was not deleted: ${(e as Error).message}`); }
+ }, []);
+ return { enabled, notes, create, update, remove };
+}
+
+const STAMP = /\[(?:(\d+):)?(\d{1,2}):(\d{2})\]/g;
+
+export function parseStamp(h: string | undefined, m: string, s: string): number {
+ return (h ? Number(h) * 3600 : 0) + Number(m) * 60 + Number(s);
+}
+
+/** First "[m:ss]" timestamp in a note body, in seconds. */
+export function firstStamp(body: string): number | null {
+ const m = new RegExp(STAMP.source).exec(body);
+ return m ? parseStamp(m[1], m[2]!, m[3]!) : null;
+}
+
+/** Note text with "[m:ss]" timestamps turned into seek buttons (or links to the lesson at that time). */
+export function NoteBody({ body, onSeek, courseId, lessonId }: { body: string; onSeek?: (s: number) => void; courseId?: string; lessonId?: string }) {
+ const parts: ReactNode[] = [];
+ let last = 0;
+ for (const m of body.matchAll(STAMP)) {
+ const t = parseStamp(m[1], m[2]!, m[3]!);
+ parts.push(body.slice(last, m.index));
+ parts.push(onSeek
+ ? onSeek(t)} aria-label={`Play from ${fmtTime(t)}`}>{m[0]}
+ : courseId && lessonId
+ ? {m[0]}
+ : m[0]);
+ last = m.index! + m[0].length;
+ }
+ parts.push(body.slice(last));
+ return {parts}
;
+}
+
+/** Your notes for a lesson: a composer (with "Insert time" for video) and the list of lesson and video notes. */
+export function NotesPanel({ notes, video, label = "Your notes" }: {
+ notes: NotesApi;
+ video?: { currentTime: () => number; seek: (s: number) => void } | null;
+ label?: string;
+}) {
+ const [draft, setDraft] = useState("");
+ const [now, setNow] = useState(0);
+ const [busy, setBusy] = useState(false);
+ const ta = useRef(null);
+ useEffect(() => {
+ if (!video) return;
+ const t = setInterval(() => setNow(video.currentTime()), 500);
+ return () => clearInterval(t);
+ }, [video]);
+
+ if (!notes.enabled) {
+ return (
+
+
{label}
+ Sign in to keep notes on this lesson. Only you see them.
+
+ );
+ }
+
+ const insertTime = () => {
+ const el = ta.current;
+ const ins = `[${fmtTime(video!.currentTime())}] `;
+ const p = el?.selectionStart ?? draft.length, e = el?.selectionEnd ?? draft.length;
+ const pre = draft.slice(0, p) + (p && draft[p - 1] !== "\n" ? "\n" : "");
+ const next = pre + ins + draft.slice(e);
+ setDraft(next);
+ requestAnimationFrame(() => { el?.focus(); const c = pre.length + ins.length; el?.setSelectionRange(c, c); });
+ };
+ const save = async () => {
+ const body = draft.trim();
+ if (!body) return;
+ setBusy(true);
+ const t = video ? firstStamp(body) : null;
+ const ok = await notes.create(t !== null ? { anchorKind: "video", anchorValue: t, body } : { anchorKind: "none", body });
+ setBusy(false);
+ if (ok) setDraft("");
+ };
+ const list = notes.notes.filter((n) => n.anchorKind !== "slide");
+
+ return (
+
+
+
{label}
+ {video ?
Insert time {fmtTime(now)} : null}
+
+
+ );
+}
+
+export function NoteItem({ note, notes, onSeek, courseId, extra }: { note: Note; notes: Pick; onSeek?: (s: number) => void; courseId?: string; extra?: ReactNode }) {
+ const [editing, setEditing] = useState(false);
+ const [text, setText] = useState(note.body);
+ useEffect(() => { setText(note.body); }, [note.body]);
+ if (editing) {
+ return (
+
+
+ );
+ }
+ return (
+
+
+
+ {extra}
+ {new Date(note.updatedAt).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" })}
+ setEditing(true)}>Edit
+ { if (confirm("Delete this note?")) void notes.remove(note.id); }}>Delete
+
+
+ );
+}
diff --git a/packages/feature-learner/src/web/library/LibraryPage.tsx b/packages/feature-learner/src/web/library/LibraryPage.tsx
new file mode 100644
index 0000000..3b9131a
--- /dev/null
+++ b/packages/feature-learner/src/web/library/LibraryPage.tsx
@@ -0,0 +1,218 @@
+import { useEffect, useMemo, useState, type CSSProperties } from "react";
+import { Link } from "react-router-dom";
+import { CourseCover, ProgressBar, SegmentedToggle } 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";
+import { accentStyle } from "../shell/CourseFrame";
+
+const FORMAT_LABEL: Record = { text: "Text", video: "Video", slides: "Slides", exercises: "Exercises" };
+type LengthFilter = "any" | "short" | "medium" | "long";
+const LENGTHS: { value: LengthFilter; label: string; test: (m: number) => boolean }[] = [
+ { value: "any", label: "Any length", test: () => true },
+ { value: "short", label: "Under 2 h", test: (m) => m < 120 },
+ { value: "medium", label: "2–10 h", test: (m) => m >= 120 && m <= 600 },
+ { value: "long", label: "Over 10 h", test: (m) => m > 600 },
+];
+
+export interface LibraryFilters { format: Format | "all"; length: LengthFilter; inProgress: boolean }
+
+export function filterCourses(courses: CourseCard[], f: LibraryFilters): CourseCard[] {
+ const len = LENGTHS.find((l) => l.value === f.length)!;
+ return courses.filter((c) => (f.format === "all" || c.formats.includes(f.format)) && len.test(c.minutes) && (!f.inProgress || (c.started && c.progress < 1)));
+}
+
+export default function LibraryPage() {
+ ensureStyles();
+ const lib = useLibrary();
+ useEffect(() => { void refreshLibrary(); }, []);
+ useEffect(() => { document.title = "Library – STCourses"; }, []);
+ return (
+
+ );
+}
+
+export function LibraryView({ lib, initialQuery }: { lib: LibraryPayload; initialQuery?: string }) {
+ const [storeQ] = useSearch();
+ const q = (initialQuery ?? storeQ).trim();
+ const [filters, setFilters] = useState({ format: "all", length: "any", inProgress: false });
+ const [hits, setHits] = useState(null);
+ useEffect(() => {
+ if (!q) { setHits(null); return; }
+ let live = true;
+ const t = setTimeout(() => {
+ api(`/api/library/search?q=${encodeURIComponent(q)}`).then((h) => live && setHits(h), () => live && setHits([]));
+ }, 180);
+ return () => { live = false; clearTimeout(t); };
+ }, [q]);
+
+ const byId = useMemo(() => new Map(lib.courses.map((c) => [c.id, c])), [lib]);
+ const filtering = filters.format !== "all" || filters.length !== "any" || filters.inProgress;
+ const browsing = !q && !filtering;
+ const formats = useMemo(() => (["text", "video", "slides", "exercises"] as Format[]).filter((f) => lib.courses.some((c) => c.formats.includes(f))), [lib]);
+ const n = lib.courses.length;
+
+ let results: CourseCard[] = filterCourses(lib.courses, filters);
+ if (q && hits) {
+ const matched = new Set(hits.filter((h) => h.course).map((h) => h.courseId));
+ results = results.filter((c) => matched.has(c.id));
+ }
+ const lessonHits = (hits ?? []).filter((h) => h.lessons.length && byId.has(h.courseId) && filterCourses([byId.get(h.courseId)!], filters).length);
+
+ return (
+
+
Library
+
{n === 0 ? "No courses you can open yet." : n === 1 ? "One course on this server." : `${n} courses on this server.`}{!lib.signedIn && n > 0 ? " Sign in to keep your progress and notes." : ""}
+
+ {browsing && lib.featured ?
x.courseId === lib.featured!.id) ?? null} /> : null}
+
+ {browsing && lib.continue.length ? (
+
+ Continue
+
+ {lib.continue.slice(0, 3).map((c) => )}
+
+
+ ) : null}
+
+ {n > 0 ? (
+
+ {formats.length > 1 ? (
+ setFilters({ ...filters, format: v as LibraryFilters["format"] })}
+ options={[{ value: "all", label: "All formats" }, ...formats.map((f) => ({ value: f, label: FORMAT_LABEL[f] }))]} />
+ ) : null}
+ setFilters({ ...filters, length: v as LengthFilter })}
+ options={LENGTHS.map((l) => ({ value: l.value, label: l.label }))} />
+ {lib.signedIn ? (
+ setFilters({ ...filters, inProgress: v === "in" })}
+ options={[{ value: "all", label: "Everything" }, { value: "in", label: "In progress" }]} />
+ ) : null}
+
+ ) : null}
+
+ {browsing ? (
+ <>
+ byId.get(id)!).filter(Boolean).slice(0, 12)} signedIn={lib.signedIn} />
+ {lib.collections.map((col) => (
+ byId.get(id)!).filter(Boolean)} signedIn={lib.signedIn} />
+ ))}
+ All courses
+
+ >
+ ) : (
+ <>
+ {q ? `Courses matching “${q}”` : "Courses"}
+ {q && hits === null ? Searching…
: results.length ? : No courses match.
}
+ {lessonHits.length ? (
+ <>
+ Lessons
+
+ {lessonHits.flatMap((h) => h.lessons.map((l) => (
+
+
+ {l.title} {h.courseTitle} · {l.moduleTitle}
+
+
+ )))}
+
+ >
+ ) : null}
+ >
+ )}
+
+ );
+}
+
+function facts(c: CourseCard): string[] {
+ const out = [c.lessonCount === 1 ? "1 lesson" : `${c.lessonCount} lessons`];
+ if (c.minutes) out.push(`about ${fmtMinutes(c.minutes)}`);
+ if (c.tracks.length > 1) out.push(`${c.tracks.length} tracks`);
+ return out;
+}
+
+function Featured({ c, cont }: { c: CourseCard; cont: ContinueItem | null }) {
+ const target = cont ? lessonHref(c.id, cont.lessonId) : courseHref(c.id);
+ return (
+
+
+
+
+
+
Featured
+
{c.title}
+ {c.pitch ?
{c.pitch}
: null}
+
{facts(c).map((f) => {f} )}{c.formats.map((f) => {FORMAT_LABEL[f]} )}
+
+
{cont ? "Continue" : c.started ? "Open" : "Start"}
+ {c.started ?
{Math.round(c.progress * 100)}% : null}
+
+
+
+ );
+}
+
+function ContinueRow({ item, course }: { item: ContinueItem; course: CourseCard | undefined }) {
+ const title = course?.title ?? item.courseId;
+ return (
+
+
+
+
{title}
+
{item.moduleTitle}. Next up: {item.lessonTitle}
+
{item.moduleDone} of {item.moduleTotal} in this module
+
+
Continue
+
+ );
+}
+
+function Strip({ title, courses, signedIn }: { title: string; courses: CourseCard[]; signedIn: boolean }) {
+ if (!courses.length) return null;
+ return (
+
+ {title}
+
+ {courses.map((c) => (
+
+
+
{c.title}
+
{facts(c).slice(0, 2).join(", ")}
+ {signedIn && c.started ?
: null}
+
+ ))}
+
+
+ );
+}
+
+function CourseTable({ courses, signedIn }: { courses: CourseCard[]; signedIn: boolean }) {
+ return (
+ <>
+ Course Lessons Contains {signedIn ? "Progress" : "Length"}
+
+ {courses.map((c) => (
+
+
+
+
+ {c.title}
+ {c.pitch ?? c.authors.join(", ")}
+
+
+
{c.lessonCount}
+
+ {c.status === "error" ? Has problems : null}
+ {c.formats.map((f) => {FORMAT_LABEL[f]} )}
+
+ {signedIn
+ ?
{Math.round(c.progress * 100)}%
+ :
{c.minutes ? fmtMinutes(c.minutes) : ""} }
+
+ ))}
+
+ >
+ );
+}
diff --git a/packages/feature-learner/src/web/shell/CourseFrame.tsx b/packages/feature-learner/src/web/shell/CourseFrame.tsx
new file mode 100644
index 0000000..c3e472a
--- /dev/null
+++ b/packages/feature-learner/src/web/shell/CourseFrame.tsx
@@ -0,0 +1,152 @@
+import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
+import { Link, useLocation } from "react-router-dom";
+import { CourseCover, ProgressBar } from "@stc/ui";
+import type { CoursePayload, OutlineLesson, OutlineModule } from "../../shared/types";
+import { courseHref, ensureStyles, fmtMinutes, fmtTime, lessonHref, useSearch, type Look } from "../api";
+import { Check, Chev, Lock } from "../icons";
+import { HeaderExtras } from "./HeaderExtras";
+import { TitleText } from "../lesson/TitleText";
+
+const fold = (s: string) => s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase();
+
+export function lessonKind(l: OutlineLesson): string {
+ if (l.type === "video") return l.minutes ? (l.minutes < 60 ? fmtTime(l.minutes * 60) : fmtMinutes(l.minutes)) : "Video";
+ if (l.type === "exercise") return "Exercise";
+ return l.hasDeck ? "Slides" : "Text";
+}
+
+export function courseDone(c: CoursePayload): { done: number; total: number } {
+ const all = c.modules.flatMap((m) => m.lessons);
+ return { done: all.filter((l) => c.progress[l.id]?.state === "done").length, total: all.length };
+}
+
+/** Display number of a module: courses whose first module id ends in 0 ("m00") count from 0. */
+export function moduleNumber(course: { modules: { id: string }[] }, index: number): number {
+ return /(^|\D)0+$/.test(course.modules[0]?.id ?? "") ? index : index + 1;
+}
+
+export function accentStyle(accent: string | null | undefined): CSSProperties | undefined {
+ return accent ? ({ "--accent": accent } as CSSProperties) : undefined;
+}
+
+/** The course page frame: outline sidebar (an overlay on narrow screens) and the main column. */
+export function CourseFrame({ course, currentLessonId, look, lookMode, children }: {
+ course: CoursePayload;
+ currentLessonId: string | null;
+ look: Look;
+ lookMode: "toggle" | "work" | null;
+ children: ReactNode;
+}) {
+ ensureStyles();
+ const [open, setOpen] = useState(false);
+ const loc = useLocation();
+ useEffect(() => { setOpen(false); }, [loc.pathname]);
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, [open]);
+ return (
+
+
setOpen(!open) }}
+ />
+
+ setOpen(false)} />
+ {children}
+
+ );
+}
+
+export function Outline({ course, currentLessonId }: { course: CoursePayload; currentLessonId: string | null }) {
+ const [q] = useSearch();
+ const query = fold(q.trim());
+ const current = course.modules.find((m) => m.lessons.some((l) => l.id === currentLessonId));
+ const [openMods, setOpenMods] = useState>(() => new Set(current ? [current.id] : course.modules[0] ? [course.modules[0].id] : []));
+ useEffect(() => { if (current) setOpenMods((s) => (s.has(current.id) ? s : new Set([...s, current.id]))); }, [current?.id]);
+ const loc = useLocation();
+ useEffect(() => {
+ document.querySelector(".lr-les[aria-current='page']")?.scrollIntoView({ block: "nearest" });
+ }, [currentLessonId]);
+ const { done, total } = courseDone(course);
+ const pct = total ? done / total : 0;
+ const notesPath = `${courseHref(course.id)}/notes`;
+
+ const filtered = useMemo(() => course.modules
+ .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; });
+ 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");
+
+ return (
+ <>
+
+
+
+
{course.title}
+ {sourceLine ?
{sourceLine}
: null}
+ {course.signedIn ? (
+
+ ) : null}
+
+ Overview
+ {course.signedIn ? Your notes : null}
+
+
+
+ {errors.length ? (
+
+
{errors.length === 1 ? "1 problem" : `${errors.length} problems`} in this course (only authors see this)
+
{errors.slice(0, 6).map((d, i) => {d.lessonId ? `${d.lessonId}: ` : ""}{d.message}{d.path ? ` (${d.path})` : ""} )}
+
+ ) : null}
+ {filtered.map(({ m, lessons }) => (
+ toggle(m.id)} />
+ ))}
+ {query && filtered.length === 0 ? No lessons match “{q.trim()}”.
: 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;
+}) {
+ const d = m.lessons.filter((l) => course.progress[l.id]?.state === "done").length;
+ const pct = m.lessons.length ? d / m.lessons.length : 0;
+ const number = String(moduleNumber(course, m.index));
+ return (
+
+
+ {m.locked ? : number}
+ {m.title}{m.locked ? (locked) : null}
+ {course.signedIn ? `${d}/${m.lessons.length}` : m.lessons.length}
+
+
+ {course.signedIn ?
:
}
+ {open ? (
+
+ {lessons.map((l) => {
+ const isDone = course.progress[l.id]?.state === "done";
+ const cls = ["lr-les", isDone && "done", m.locked && "locked"].filter(Boolean).join(" ");
+ return (
+
+
+ {isDone ? : m.locked ? : null}
+ {isDone ? ", done" : l.id === currentLessonId ? ", current" : ""}
+ {lessonKind(l)}
+
+
+ );
+ })}
+
+ ) : null}
+
+ );
+}
diff --git a/packages/feature-learner/src/web/shell/HeaderExtras.tsx b/packages/feature-learner/src/web/shell/HeaderExtras.tsx
new file mode 100644
index 0000000..f0cdc9a
--- /dev/null
+++ b/packages/feature-learner/src/web/shell/HeaderExtras.tsx
@@ -0,0 +1,138 @@
+import { useEffect, useLayoutEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { Link, useLocation } from "react-router-dom";
+import { CourseCover, SegmentedToggle } from "@stc/ui";
+import { courseHref, toast, useLibrary, useLook, useMe, useSearch, useToast } from "../api";
+import { DirI, Down, MenuI, SearchI } from "../icons";
+
+export interface HeaderCourse { id: string; title: string; coverUrl: string | null; accent: string | null }
+
+const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
+
+/**
+ * Course switcher, search, Reading/Work and the account link, portalled into the Phase 0 header (`.stc-top`).
+ * Rendered only after mount, so server-side rendering of a page skips it.
+ */
+export function HeaderExtras({ course, lookMode, outline }: {
+ course: HeaderCourse | null;
+ /** "toggle": show Reading/Work; "work": shown but fixed to Work (exercise pages); null: hidden. */
+ lookMode: "toggle" | "work" | null;
+ outline?: { open: boolean; toggle: () => void } | null;
+}) {
+ const [host, setHost] = useState(null);
+ useIsoLayoutEffect(() => { setHost(document.querySelector(".stc-top")); }, []);
+ const msg = useToast();
+ if (!host) return null;
+ return (
+ <>
+ {createPortal(
+
+ {outline ? (
+
+ ) : null}
+
+
+ {lookMode ?
: null}
+
+
,
+ host,
+ )}
+ {msg ? {msg}
: null}
+ >
+ );
+}
+
+function LookToggle({ mode }: { mode: "toggle" | "work" }) {
+ const [look, setLook] = useLook();
+ const value = mode === "work" ? "work" : look;
+ return (
+ {
+ setLook(v as "reading" | "work");
+ if (mode === "work" && v === "reading") toast("Exercises always use Work. Reading applies to text lessons.");
+ }}
+ />
+ );
+}
+
+function SearchBox({ placeholder }: { placeholder: string }) {
+ const [q, setQ] = useSearch();
+ const loc = useLocation();
+ const path = useRef(loc.pathname.split("/").slice(0, 3).join("/"));
+ // Clear the query when moving to another course or to the library.
+ useEffect(() => {
+ const p = loc.pathname.split("/").slice(0, 3).join("/");
+ if (p !== path.current) { path.current = p; setQ(""); }
+ }, [loc.pathname, setQ]);
+ return (
+
+
+ Search
+ setQ(e.target.value)}
+ onKeyDown={(e) => { if (e.key === "Escape") { setQ(""); (e.target as HTMLInputElement).blur(); } }}
+ />
+
+ );
+}
+
+function CourseSwitcher({ course }: { course: HeaderCourse | null }) {
+ const [open, setOpen] = useState(false);
+ const lib = useLibrary();
+ const ref = useRef(null);
+ const loc = useLocation();
+ useEffect(() => { setOpen(false); }, [loc.pathname]);
+ useEffect(() => {
+ if (!open) return;
+ const onDoc = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); };
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") { setOpen(false); ref.current?.querySelector("button")?.focus(); }
+ };
+ document.addEventListener("mousedown", onDoc);
+ document.addEventListener("keydown", onKey);
+ ref.current?.querySelector(".lr-menu a")?.focus();
+ return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
+ }, [open]);
+ const n = lib?.courses.length ?? 0;
+ return (
+
+
setOpen(!open)}>
+ {course
+ ?
+ : }
+ {course ? course.title : "All courses"}
+
+
+ {open ? (
+
+
+
+
+ Library {n === 1 ? "1 course" : `${n} courses`}
+
+
+ {(lib?.courses ?? []).map((c) => (
+
+
+
+ {c.title} {c.authors.join(", ") || (c.source === "authored" ? "Authored course" : c.source ? `Imported (${c.source})` : "")}
+
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+function AccountLink() {
+ const { loaded, user } = useMe();
+ if (!loaded) return null;
+ return user
+ ? {user.name || user.email}
+ : Sign in;
+}
diff --git a/packages/feature-learner/src/web/styles.ts b/packages/feature-learner/src/web/styles.ts
new file mode 100644
index 0000000..65e7288
--- /dev/null
+++ b/packages/feature-learner/src/web/styles.ts
@@ -0,0 +1,373 @@
+// The learner stylesheet, injected once by ensureStyles(). Kept as a string (not a .css text import) because
+// Bun's dev server loses text imports on hot rebuilds. Scoped under .lr; no colours beyond the @stc/ui tokens.
+export const learnerCss = String.raw`
+/* Learner app. Scoped under .lr so the studio can embed LessonView without clashes.
+ Tokens come from @stc/ui (tokens.css); this file adds no colours of its own. */
+
+.lr { --accent-dim: color-mix(in srgb, var(--accent) 55%, var(--ground)); }
+.lr[data-look="reading"], .lr [data-look="reading"] { --pf: var(--serif); --ps: 19px; --pl: 1.75; --pc: var(--read); --col: 660px; --wide: 900px; --tf: var(--serif); --h1: 40px; }
+.lr[data-look="work"], .lr [data-look="work"] { --pf: var(--sans); --ps: 15.5px; --pl: 1.6; --pc: var(--text); --col: 760px; --wide: 1180px; --tf: var(--sans); --h1: 28px; }
+.lr [hidden] { display: none !important; }
+.lr .tnum { font-variant-numeric: tabular-nums; }
+
+/* ---------- header extras (portalled into .stc-top) ---------- */
+.lr-h { display: contents; }
+.stc-top > a:first-child { flex: 0 0 calc(var(--sidew) - 30px); }
+.lr-icon { display: none; width: 34px; height: 34px; border-radius: 18px; border: 1px solid var(--line2); background: none; place-items: center; color: var(--muted); flex: none; }
+.lr-icon:hover { background: var(--raised); }
+.lr-switch { position: relative; flex: none; }
+.lr-switch-btn { display: flex; align-items: center; gap: 10px; height: 34px; padding: 0 12px 0 5px; border: 1px solid var(--line2); border-radius: 18px; background: none; font-weight: 500; font-size: 13px; max-width: 300px; }
+.lr-switch-btn:hover { background: var(--raised); }
+.lr-switch-btn .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.lr-switch-btn svg { color: var(--faint); flex: none; }
+.lr-menu { position: absolute; top: 42px; left: 0; width: 330px; max-height: calc(100vh - 80px); overflow-y: auto; margin: 0; padding: 6px; list-style: none; background: var(--side); border: 1px solid var(--line2); border-radius: 14px; z-index: 50; }
+.lr-menu a { display: flex; gap: 12px; align-items: center; padding: 8px 10px; border-radius: 10px; text-decoration: none; }
+.lr-menu a:hover, .lr-menu a:focus-visible { background: var(--raised); }
+.lr-menu a[aria-current] .mt { color: var(--head); }
+.lr-menu .mt { font-weight: 500; font-size: 13.5px; display: block; }
+.lr-menu .ms { display: block; font-size: 12px; color: var(--faint); }
+.lr-tile { width: 24px; display: grid; place-items: center; color: var(--faint); flex: none; }
+.lr-search { flex: 1; max-width: 380px; display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px; border: 1px solid var(--line2); border-radius: 18px; color: var(--faint); margin-left: auto; }
+.lr-search:focus-within { border-color: var(--faint); }
+.lr-search input { flex: 1; min-width: 0; border: 0; background: none; outline: 0; color: var(--text); font-size: 13px; }
+.lr-search input::placeholder { color: var(--faint); }
+.lr-search input::-webkit-search-cancel-button { filter: grayscale(1); }
+.lr-acct { flex: none; }
+.lr-cover-tiny { --cw: 24px; }
+.lr-cover-tiny .stc-cover { border-radius: 4px; font-size: 9px; }
+
+/* ---------- course shell ---------- */
+.stc-main:has(> .lr-shell) { padding: 0; }
+.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-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; }
+.lr-side-head .src { font-size: 12px; color: var(--faint); margin-top: 2px; }
+.lr-side-links { display: flex; gap: 14px; margin-top: 8px; font-size: 12px; }
+.lr-side-links a { color: var(--muted); text-decoration: none; }
+.lr-side-links a:hover, .lr-side-links a[aria-current] { color: var(--head); }
+.lr-prog { display: flex; align-items: center; gap: 10px; margin-top: 10px; font-size: 12px; color: var(--muted); }
+.lr-mod { border-bottom: 1px solid var(--line); }
+.lr-mod-head { width: 100%; display: grid; grid-template-columns: 22px 1fr auto 12px; gap: 6px; align-items: baseline; padding: 11px 16px 7px; border: 0; background: none; text-align: left; font-size: 13.5px; color: var(--text); }
+.lr-mod-head:hover { color: var(--head); }
+.lr-mod-head .n, .lr-mod-head .c { color: var(--faint); font-size: 12px; }
+.lr-mod-head svg { color: var(--faint); align-self: center; }
+.lr-mod-head > svg { transition: transform var(--fast); }
+.lr-mod-head[aria-expanded="true"] > svg { transform: rotate(90deg); }
+.lr-mod.is-current .lr-mod-head { color: var(--head); font-weight: 500; }
+.lr-mod > .stc-bar { margin: 0 16px 11px 44px; flex: none; }
+.lr-lessons { list-style: none; margin: -3px 0 0; padding: 0 0 8px; }
+.lr-les { display: grid; grid-template-columns: 22px 1fr auto; gap: 6px; align-items: center; padding: 6px 16px; font-size: 13px; line-height: 1.35; color: var(--muted); text-decoration: none; }
+.lr-les:hover { background: color-mix(in srgb, var(--raised) 60%, transparent); color: var(--text); }
+.lr-les .m { width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid var(--line2); justify-self: center; display: grid; place-items: center; }
+.lr-les.done .m { border: 0; width: 12px; height: 12px; color: var(--faint); }
+.lr-les.locked { color: var(--faint); }
+.lr-les.locked .m { border: 0; width: 12px; height: 12px; color: var(--faint); }
+.lr-les .d { font-size: 12px; color: var(--faint); }
+.lr-les code, .lr-menu code { font-size: .92em; }
+.lr-les[aria-current="page"] { background: var(--raised); color: var(--head); box-shadow: inset 2px 0 0 var(--accent); }
+.lr-les[aria-current="page"] .m { border: 0; background: var(--accent); width: 8px; height: 8px; }
+.lr-les[aria-current="page"] .m svg { display: none; }
+.lr-les[aria-current="page"].done .m svg { display: block; }
+.lr-les[aria-current="page"].done .m { background: none; color: var(--accent); width: 12px; height: 12px; }
+.lr-nohit { padding: 16px; color: var(--faint); font-size: 13px; }
+.lr-diag { margin: 12px 16px; padding: 10px 12px; border: 1px solid var(--line2); border-radius: 10px; font-size: 12px; color: var(--muted); }
+.lr-diag b { color: var(--bad); font-weight: 500; }
+.lr-diag ul { margin: 6px 0 0; padding-left: 16px; }
+
+/* ---------- columns and headings ---------- */
+.lr .col { max-width: var(--col); margin: 0 auto; }
+.lr .wide { max-width: var(--wide); margin: 0 auto; }
+.lr .chap { font-size: 13px; font-weight: 500; color: var(--muted); margin: 0 0 10px; }
+.lr h1.title { font: 600 var(--h1)/1.12 var(--tf); color: var(--head); margin: 0; letter-spacing: -.012em; text-wrap: balance; }
+.lr h1.title code { font-size: .86em; }
+.lr .meta { display: flex; flex-wrap: wrap; gap: 14px 30px; margin: 22px 0 0; padding-bottom: 22px; border-bottom: 1px solid var(--line); align-items: end; }
+.lr .meta div { min-width: 0; }
+.lr .meta dt { font-size: 12px; color: var(--faint); margin-bottom: 2px; }
+.lr .meta dd { margin: 0; font-size: 13.5px; color: var(--text); }
+.lr .meta dd code { font-size: 12.5px; }
+.lr .source { font-size: 12.5px; color: var(--faint); margin: 14px 0 0; }
+.lr .source strong { font-weight: 500; color: var(--muted); }
+.lr .blockhead { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 0 0 10px; flex-wrap: wrap; }
+.lr .blockhead h2 { margin: 0; font: 600 15px/1.3 var(--sans); color: var(--head); }
+.lr .blockhead > span { font-size: 12px; color: var(--faint); }
+.lr .sec-h { font: 600 14px var(--sans); color: var(--muted); margin: 0 0 10px; }
+.lr .muted { color: var(--faint); }
+.lr .lr-block { margin-top: 40px; }
+
+/* ---------- prose ---------- */
+.lr .prose { font-family: var(--pf); font-size: var(--ps); line-height: var(--pl); color: var(--pc); overflow-wrap: break-word; }
+.lr .prose p, .lr .prose ul, .lr .prose ol, .lr .prose dl, .lr .prose table { margin: 0 0 1em; }
+.lr .prose > :first-child { margin-top: 0; }
+.lr .prose h2 { font: 600 1.32em/1.25 var(--tf); color: var(--head); margin: 2em 0 .55em; letter-spacing: -.005em; }
+.lr .prose h3 { font: 600 1.08em/1.3 var(--tf); color: var(--head); margin: 1.7em 0 .45em; }
+.lr .prose h4, .lr .prose h5, .lr .prose h6 { font: 600 1em/1.35 var(--tf); color: var(--head); margin: 1.4em 0 .4em; }
+.lr .prose strong { font-weight: 600; color: var(--head); }
+.lr .prose li { margin-bottom: .35em; padding-left: .2em; }
+.lr .prose a { color: var(--head); text-decoration-color: var(--line2); text-underline-offset: 3px; }
+.lr .prose a:hover { text-decoration-color: var(--accent); }
+.lr .prose img { max-width: 100%; height: auto; border-radius: 8px; }
+.lr .prose blockquote { border-left: 2px solid var(--line2); padding-left: 18px; color: var(--muted); font-size: .92em; margin: 0 0 1.2em; }
+.lr .prose blockquote strong { color: var(--text); }
+.lr .prose hr { border: 0; border-top: 1px solid var(--line); margin: 2em 0; }
+.lr .prose table { border-collapse: collapse; font-family: var(--sans); font-size: 14px; display: block; overflow-x: auto; }
+.lr .prose th, .lr .prose td { border-bottom: 1px solid var(--line); padding: 6px 12px 6px 0; text-align: left; vertical-align: top; }
+.lr .prose th { color: var(--muted); font-weight: 500; }
+.lr .prose :not(pre) > code { font-size: .78em; padding: .08em .32em; background: color-mix(in srgb, var(--raised) 75%, transparent); border-radius: 4px; color: var(--text); }
+.lr pre.code { font-size: 13.5px; line-height: 1.6; background: var(--side); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; margin: 0 0 1.2em; overflow-x: auto; color: var(--text); tab-size: 2; }
+.lr pre.code code { font-size: inherit; padding: 0; background: none; }
+.lr .prose kbd { font: 500 .7em/1 var(--mono); padding: 2px 5px; border: 1px solid var(--line2); border-radius: 4px; }
+
+/* folds and hint tiers */
+.lr details.fold { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); margin: 0 0 1.4em; }
+.lr details.fold + details.fold { margin-top: -1.4em; border-top: 0; }
+.lr details.fold > summary { list-style: none; display: flex; align-items: center; gap: 10px; padding: 12px 2px; font: 500 14px/1.4 var(--sans); color: var(--text); cursor: pointer; }
+.lr details.fold > summary::-webkit-details-marker { display: none; }
+.lr details.fold > summary svg { color: var(--faint); transition: transform var(--fast); flex: none; }
+.lr details.fold[open] > summary svg { transform: rotate(90deg); }
+.lr details.fold > summary:hover { color: var(--head); }
+.lr details.fold > summary .tier { margin-left: auto; font-size: 12px; color: var(--faint); font-weight: 400; white-space: nowrap; }
+.lr details.fold[data-wait] > summary { color: var(--faint); cursor: default; }
+.lr details.fold > .in { padding: 2px 0 14px 24px; }
+.lr .hints .fold > .in { font-size: .95em; }
+
+/* ---------- pills ---------- */
+.lr .stc-pill.sm { height: 28px; padding: 0 11px; font-size: 12.5px; }
+.lr a.stc-pill { color: inherit; }
+.lr a.stc-pill--primary { color: var(--on-accent); }
+
+/* ---------- floating action bar ---------- */
+.lr .stc-dock [aria-pressed="true"] { color: var(--accent); }
+.lr .stc-dock .sep { width: 1px; background: var(--line2); margin: 7px 2px; }
+.lr .stc-dock a.stc-pill[aria-disabled="true"] { opacity: .45; pointer-events: none; }
+
+/* ---------- video ---------- */
+.lr .player { aspect-ratio: 16 / 9; background: #000; border-radius: 10px; overflow: hidden; margin-top: 22px; }
+.lr .player video { display: block; width: 100%; height: 100%; }
+.lr .split { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 40px; margin-top: 28px; }
+.lr .note-ta { width: 100%; min-height: 110px; resize: vertical; background: var(--side); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; color: var(--text); font-size: 14px; line-height: 1.55; display: block; }
+.lr .note-ta:focus { outline: 0; border-color: var(--faint); }
+.lr .note-actions { display: flex; gap: 6px; justify-content: flex-end; margin-top: 8px; align-items: center; }
+.lr .note-actions .msg { margin-right: auto; font-size: 12px; color: var(--faint); }
+.lr .note-list { list-style: none; margin: 18px 0 0; padding: 0; border-top: 1px solid var(--line); }
+.lr .note-list > li { padding: 12px 2px; border-bottom: 1px solid var(--line); font-size: 14px; line-height: 1.55; }
+.lr .note-list .nb { white-space: pre-wrap; overflow-wrap: anywhere; }
+.lr .note-list .nm { display: flex; gap: 10px; align-items: center; margin-top: 6px; font-size: 12px; color: var(--faint); }
+.lr .note-list .nm button { border: 0; background: none; padding: 0; color: var(--faint); font-size: 12px; }
+.lr .note-list .nm button:hover { color: var(--text); }
+.lr .stamp { border: 0; background: none; padding: 0 2px; color: var(--accent); font: inherit; font-variant-numeric: tabular-nums; text-decoration: underline; text-decoration-color: var(--accent-dim); text-underline-offset: 3px; }
+.lr .transcript { margin-top: 22px; }
+
+/* rows (attachments) */
+.lr .rows { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
+.lr .rows li { display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 2px; border-bottom: 1px solid var(--line); font-size: 13.5px; }
+.lr .rows li svg { color: var(--faint); }
+.lr .rows a { overflow-wrap: anywhere; text-decoration: none; }
+.lr .rows a:hover { text-decoration: underline; text-underline-offset: 3px; }
+.lr .rows .sz { white-space: nowrap; font-size: 12px; color: var(--faint); }
+
+/* ---------- slides ---------- */
+.lr .deck { margin-top: 28px; }
+.lr .stage { position: relative; aspect-ratio: 16 / 9; background: var(--side); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; display: grid; place-items: center; margin: 0; }
+.lr .stage canvas { display: block; max-width: 100%; max-height: 100%; }
+.lr .stage .msg { color: var(--faint); font-size: 13px; padding: 20px; text-align: center; }
+.lr .stage:fullscreen { border-radius: 0; background: #000; border: 0; }
+.lr .deckbar { display: flex; align-items: center; gap: 8px; margin: 12px 0 0; flex-wrap: wrap; }
+.lr .deckbar .count { font-size: 13px; color: var(--muted); margin: 0 6px; min-width: 58px; text-align: center; }
+.lr .deckbar .gap { flex: 1; }
+.lr .deckbar .hint { font-size: 12.5px; color: var(--faint); }
+.lr .thumbs { display: flex; gap: 8px; margin: 16px 0 0; overflow-x: auto; padding: 3px 3px 6px; }
+.lr .thumbs button { flex: none; width: 104px; aspect-ratio: 16 / 9; padding: 0; border: 1px solid var(--line2); border-radius: 5px; overflow: hidden; background: var(--side); opacity: .6; transition: opacity var(--fast); display: grid; place-items: center; color: var(--faint); font-size: 11px; position: relative; }
+.lr .thumbs button:hover { opacity: .9; }
+.lr .thumbs button[aria-current="true"] { opacity: 1; outline: 2px solid var(--accent); outline-offset: 2px; }
+.lr .thumbs canvas { width: 100%; height: 100%; object-fit: contain; display: block; }
+.lr .thumbs .has-note::after { content: ""; position: absolute; top: 4px; right: 4px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
+.lr .slide-note { margin-top: 16px; }
+.lr .slide-note label { display: block; font-size: 12px; color: var(--faint); margin-bottom: 6px; }
+.lr .slide-note .note-ta { min-height: 72px; }
+
+/* ---------- code files ---------- */
+.lr .files { display: grid; grid-template-columns: 220px minmax(0, 1fr); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; margin: 0 0 1.4em; font-family: var(--sans); }
+.lr .tree { background: var(--side); border-right: 1px solid var(--line); padding: 8px 0; font-size: 13px; max-height: 520px; overflow: auto; }
+.lr .tree .dir { padding: 5px 12px; color: var(--muted); display: flex; gap: 7px; align-items: center; white-space: nowrap; }
+.lr .tree button { display: flex; gap: 7px; align-items: center; width: 100%; border: 0; background: none; text-align: left; padding: 5px 12px; color: var(--muted); font-size: 13px; white-space: nowrap; }
+.lr .tree button:hover { color: var(--text); }
+.lr .tree button[aria-current="true"] { background: var(--raised); color: var(--head); }
+.lr .tree svg { flex: none; color: var(--faint); }
+.lr .viewer { min-width: 0; display: flex; flex-direction: column; background: var(--ground); }
+.lr .tabs { display: flex; align-items: center; border-bottom: 1px solid var(--line); padding: 0 6px 0 0; overflow-x: auto; min-height: 40px; }
+.lr .tabs [role="tab"] { border: 0; background: none; padding: 10px 14px; font-size: 12.5px; color: var(--faint); border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; font-family: var(--mono); }
+.lr .tabs [role="tab"][aria-selected="true"] { color: var(--head); border-bottom-color: var(--accent); }
+.lr .tabs .stc-pill { margin-left: auto; }
+.lr .path { font-size: 12px; color: var(--faint); padding: 8px 16px 0; overflow-wrap: anywhere; }
+.lr .viewer pre.code { border: 0; border-radius: 0; background: none; margin: 0; counter-reset: ln; max-height: 520px; overflow: auto; }
+.lr .ln { display: block; counter-increment: ln; }
+.lr .ln::before { content: counter(ln); display: inline-block; width: 2.2em; margin-right: 1em; text-align: right; color: var(--faint); opacity: .6; }
+.lr .cmd { position: relative; }
+.lr .cmd pre.code { padding-right: 80px; white-space: pre-wrap; overflow-wrap: anywhere; }
+.lr .cmd .stc-pill { position: absolute; top: 8px; right: 8px; }
+
+/* ---------- exercise ---------- */
+.lr .ex { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 36px; align-items: start; }
+.lr .ex .prose { font-size: 15px; }
+.lr .edit { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; position: sticky; top: calc(var(--top) + 20px); }
+.lr .edit-bar { display: flex; align-items: center; gap: 6px; padding: 6px 6px 6px 14px; background: var(--side); border-bottom: 1px solid var(--line); }
+.lr .edit-bar .f { font: 500 12.5px var(--mono); color: var(--muted); margin-right: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.lr .editor { display: grid; grid-template-columns: 44px 1fr; background: var(--ground); height: 300px; }
+.lr .gutter { margin: 0; padding: 12px 10px 12px 0; text-align: right; color: var(--faint); opacity: .6; font: 13.5px/1.6 var(--mono); overflow: hidden; user-select: none; }
+.lr .editor textarea { border: 0; resize: none; outline: 0; background: none; padding: 12px 14px 12px 6px; font: 13.5px/1.6 var(--mono); color: var(--text); white-space: pre; overflow: auto; tab-size: 2; }
+.lr .editor:focus-within { box-shadow: inset 0 0 0 1px var(--faint); }
+.lr .results { border-top: 1px solid var(--line); background: var(--side); }
+.lr .res-sum { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; font-size: 13px; color: var(--muted); }
+.lr .res-sum b { font-weight: 600; color: var(--head); }
+.lr .res-list { list-style: none; margin: 0; padding: 0; max-height: 300px; overflow: auto; border-top: 1px solid var(--line); }
+.lr .res-list li { padding: 8px 14px; border-bottom: 1px solid var(--line); font-size: 13px; }
+.lr .res-list li:last-child { border-bottom: 0; }
+.lr .res-row { display: grid; grid-template-columns: 40px 1fr; gap: 8px; }
+.lr .res-row .s { font-weight: 600; font-size: 12px; }
+.lr .pass .s { color: var(--ok); }
+.lr .fail .s { color: var(--bad); }
+.lr .res-row code { font-size: 12.5px; color: var(--text); }
+.lr .diff { display: grid; grid-template-columns: 64px 1fr; gap: 2px 8px; margin: 6px 0 2px 48px; font-size: 12.5px; }
+.lr .diff dt { color: var(--faint); }
+.lr .diff dd { margin: 0; font-family: var(--mono); color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
+.lr .local { margin: 20px 0 0; padding: 12px 0 0; border-top: 1px solid var(--line); font-size: 13px; color: var(--muted); }
+.lr .local strong { color: var(--text); font-weight: 500; }
+.lr .local code { display: block; margin-top: 6px; font-size: 12.5px; color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
+.lr .local-panel { border: 1px solid var(--line); border-radius: 10px; padding: 16px; background: var(--side); }
+.lr .local-panel h2 { margin: 0 0 6px; font: 600 15px/1.3 var(--sans); color: var(--head); }
+.lr .local-panel p { margin: 0 0 12px; font-size: 13.5px; color: var(--muted); }
+.lr .local-panel .cmd pre.code { background: var(--ground); }
+.lr .local-panel textarea { min-height: 90px; font-family: var(--mono); font-size: 12.5px; }
+
+/* ---------- lock, empty, error ---------- */
+.lr .notice { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 18px 2px; margin-top: 28px; color: var(--muted); font-size: 14px; }
+.lr .notice strong { color: var(--head); font-weight: 500; }
+.lr .notice .stc-pill { margin-top: 12px; }
+
+/* ---------- library ---------- */
+.lr-lib { max-width: 1080px; margin: 0 auto; padding-bottom: 60px; }
+.lr .lib-h { font: 600 24px/1.2 var(--sans); color: var(--head); margin: 0 0 4px; }
+.lr .lib-sub { color: var(--faint); margin: 0 0 32px; font-size: 13.5px; }
+.lr .feat { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 28px; align-items: end; padding: 0 0 32px; border-bottom: 1px solid var(--line); margin-bottom: 36px; }
+.lr a:has(> .stc-cover), .lr a .stc-cover { text-decoration: none; }
+.lr .feat .k { font-size: 12.5px; color: var(--faint); margin: 0 0 8px; }
+.lr .feat h2 { margin: 0; font: 600 30px/1.15 var(--sans); color: var(--head); letter-spacing: -.01em; text-wrap: balance; }
+.lr .feat h2 a { text-decoration: none; }
+.lr .feat p.pitch { margin: 10px 0 0; font-size: 15px; color: var(--muted); max-width: 560px; }
+.lr .feat .facts { display: flex; flex-wrap: wrap; gap: 6px 18px; margin: 14px 0 18px; font-size: 12.5px; color: var(--faint); }
+.lr .feat .actions { display: flex; gap: 8px; align-items: center; }
+.lr .continue { display: grid; grid-template-columns: auto 1fr auto; gap: 20px; align-items: center; padding: 18px 0; border-bottom: 1px solid var(--line); }
+.lr .continue-list { border-top: 1px solid var(--line); margin-bottom: 44px; }
+.lr .continue .t { font-size: 16px; font-weight: 600; color: var(--head); }
+.lr .continue .s { font-size: 13.5px; color: var(--muted); margin: 2px 0 10px; }
+.lr .continue .lr-prog { max-width: 360px; margin: 0; }
+.lr .filters { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; margin: 0 0 30px; }
+.lr .filters .lbl { font-size: 12px; color: var(--faint); margin-right: -6px; }
+.lr .lib-row-h { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
+.lr .strip-wrap { margin-bottom: 40px; }
+.lr .strip { display: flex; gap: 22px; overflow-x: auto; padding: 4px 2px 12px; scroll-snap-type: x proximity; }
+.lr .tile { flex: none; width: 150px; text-decoration: none; scroll-snap-align: start; }
+.lr .tile .stc-cover { --cw: 150px; }
+.lr .tile .tt { display: block; margin-top: 10px; font-size: 13.5px; font-weight: 600; color: var(--text); line-height: 1.3; }
+.lr .tile:hover .tt { color: var(--head); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--line2); }
+.lr .tile .ts { display: block; font-size: 12px; color: var(--faint); margin-top: 2px; }
+.lr .tile .stc-bar { margin-top: 8px; }
+.lr .lib { border-top: 1px solid var(--line); }
+.lr .lib-row, .lr .lib-cols { display: grid; grid-template-columns: minmax(0, 1fr) 90px 210px 180px; gap: 24px; align-items: center; }
+.lr .lib-cols { padding: 0 0 8px; font-size: 12px; color: var(--faint); }
+.lr .lib-row { padding: 16px 0; border-bottom: 1px solid var(--line); text-decoration: none; }
+.lr .lib-row:hover .lt { color: var(--head); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--line2); }
+.lr .lib-main { display: flex; gap: 16px; align-items: center; min-width: 0; }
+.lr .lt { font-size: 15px; font-weight: 600; color: var(--text); }
+.lr .ls { display: block; font-size: 13px; color: var(--faint); margin-top: 2px; }
+.lr .lnum { font-size: 13.5px; color: var(--muted); }
+.lr .tags { display: flex; flex-wrap: wrap; gap: 5px; }
+.lr .tag { font-size: 11.5px; color: var(--muted); border: 1px solid var(--line2); border-radius: 10px; padding: 1px 8px; }
+.lr .tag.bad { color: var(--bad); }
+.lr .lib-row .lr-prog { margin: 0; }
+.lr .hits { list-style: none; margin: 0 0 36px; padding: 0; border-top: 1px solid var(--line); }
+.lr .hits li a { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 10px 2px; border-bottom: 1px solid var(--line); text-decoration: none; font-size: 13.5px; }
+.lr .hits li a:hover .h1 { color: var(--head); text-decoration: underline; text-underline-offset: 3px; }
+.lr .hits .h2 { font-size: 12px; color: var(--faint); }
+
+/* ---------- notes page, account ---------- */
+.lr .notes-group { margin-top: 30px; }
+.lr .notes-group h2 { font: 600 15px/1.3 var(--sans); margin: 0 0 6px; }
+.lr .notes-group h2 a { color: var(--head); text-decoration: none; }
+.lr .notes-group h2 a:hover { text-decoration: underline; text-underline-offset: 3px; }
+.lr .notes-group .note-list { margin-top: 8px; }
+.lr .anchor { font-size: 12px; color: var(--muted); text-decoration: none; border: 1px solid var(--line2); border-radius: 10px; padding: 1px 8px; }
+.lr .anchor:hover { color: var(--head); }
+
+.lr .form { display: grid; gap: 14px; max-width: 380px; }
+.lr .form label { display: grid; gap: 5px; font-size: 12.5px; color: var(--muted); }
+.lr .form input, .lr .field { height: 38px; padding: 0 12px; border: 1px solid var(--line2); border-radius: 10px; background: var(--side); color: var(--text); font-size: 14px; }
+.lr .form input:focus, .lr .field:focus { outline: 0; border-color: var(--faint); }
+.lr select.field { padding-right: 8px; }
+.lr .form .err, .lr .err { color: var(--bad); font-size: 13px; margin: 0; }
+.lr .ok-msg { color: var(--ok); font-size: 13px; margin: 0; }
+.lr .or { display: flex; align-items: center; gap: 12px; color: var(--faint); font-size: 12px; margin: 26px 0 16px; max-width: 380px; }
+.lr .or::before, .lr .or::after { content: ""; flex: 1; border-top: 1px solid var(--line); }
+.lr .prov { display: flex; flex-wrap: wrap; gap: 8px; max-width: 380px; }
+.lr .acct-dl { display: grid; grid-template-columns: 90px 1fr; gap: 8px 16px; margin: 22px 0; font-size: 14px; }
+.lr .acct-dl dt { color: var(--faint); }
+.lr .acct-dl dd { margin: 0; }
+.lr .admin-sec { margin-top: 44px; }
+.lr .admin-sec > h2 { font: 600 16px/1.3 var(--sans); color: var(--head); margin: 0 0 12px; }
+.lr .table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
+.lr .table th { text-align: left; font-weight: 500; font-size: 12px; color: var(--faint); padding: 0 12px 8px 0; border-bottom: 1px solid var(--line); }
+.lr .table td { padding: 10px 12px 10px 0; border-bottom: 1px solid var(--line); vertical-align: middle; }
+.lr .table td .ls { margin: 0; }
+.lr .checks { display: flex; flex-wrap: wrap; gap: 6px 16px; }
+.lr .checks label { display: inline-flex; gap: 6px; align-items: center; font-size: 13px; color: var(--muted); }
+.lr input[type="checkbox"] { accent-color: var(--accent); }
+.lr .row-form { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 12px 0 0; }
+
+.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); }
+
+/* ---------- responsive ---------- */
+@media (max-width: 1100px) {
+ .lr .ex { grid-template-columns: minmax(0, 1fr); }
+ .lr .edit { position: static; }
+ .lr .split { grid-template-columns: 1fr; gap: 28px; }
+ .lr .lib-row, .lr .lib-cols { grid-template-columns: minmax(0, 1fr) 70px 150px; }
+ .lr .lib-row > .tags, .lr .lib-cols > :nth-child(3) { display: none; }
+}
+@media (max-width: 960px) {
+ .stc-top > a:first-child { flex: none; }
+ .lr-icon { display: inline-grid; }
+ .lr-side { position: fixed; top: var(--top); left: 0; bottom: 0; height: auto; z-index: 40; transform: translateX(-100%); transition: transform 160ms ease-out; }
+ .lr-shell.side-open .lr-side { transform: none; }
+ .lr-shell.side-open .lr-scrim { display: block; position: fixed; inset: var(--top) 0 0 0; z-index: 35; background: rgb(10 11 15 / .6); }
+ .lr-main { padding: 32px 24px 0; }
+}
+@media (max-width: 680px) {
+ .stc-top { gap: 8px; padding: 0 10px; }
+ .stc-top > a:first-child { display: none; }
+ .lr-search { display: none; }
+ .lr-switch-btn { max-width: 170px; }
+ .lr-switch-btn .t { font-size: 12.5px; }
+ .lr .stc-seg button { padding: 0 10px; }
+ .lr-main { padding: 26px 18px 0; }
+ .lr[data-look="reading"], .lr [data-look="reading"] { --ps: 18px; --h1: 31px; }
+ .lr .meta { gap: 12px 22px; }
+ .lr .files { grid-template-columns: 1fr; }
+ .lr .tree { border-right: 0; border-bottom: 1px solid var(--line); max-height: 200px; }
+ .lr .continue { grid-template-columns: auto 1fr; }
+ .lr .continue .stc-pill { grid-column: 1 / -1; justify-self: start; }
+ .lr .feat { grid-template-columns: 1fr; }
+ .lr .lib-row, .lr .lib-cols { grid-template-columns: minmax(0, 1fr) 92px; gap: 14px; }
+ .lr .lib-row > .lnum, .lr .lib-cols > :nth-child(2) { display: none; }
+ .lr-menu { width: min(330px, calc(100vw - 20px)); left: -48px; }
+ .lr .stc-dock { bottom: 12px; }
+ .lr .stc-dock .lbl-long { display: none; }
+ .lr .thumbs button { width: 84px; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .lr *, .lr *::before, .lr *::after, .lr-side { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
+}
+`;
diff --git a/packages/feature-learner/test/account.test.tsx b/packages/feature-learner/test/account.test.tsx
new file mode 100644
index 0000000..82273a5
--- /dev/null
+++ b/packages/feature-learner/test/account.test.tsx
@@ -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( );
+ 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( );
+ expect(out).toContain("Users, groups and library");
+ expect(out).not.toContain("Users and roles"); // owner-only sections wait for the session
+});
diff --git a/packages/feature-learner/test/assets.test.ts b/packages/feature-learner/test/assets.test.ts
new file mode 100644
index 0000000..4f30eb1
--- /dev/null
+++ b/packages/feature-learner/test/assets.test.ts
@@ -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);
+});
diff --git a/packages/feature-learner/test/lesson-view.test.tsx b/packages/feature-learner/test/lesson-view.test.tsx
new file mode 100644
index 0000000..60f8478
--- /dev/null
+++ b/packages/feature-learner/test/lesson-view.test.tsx
@@ -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> {
+ const db = await connect("sqlite://:memory:");
+ await runMigrations(db);
+ const { call } = await makeApp(db);
+ const out: Record = {};
+ 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({el} );
+
+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( );
+ expect(text).toContain('data-type="text"');
+ expect(text).toContain('data-look="reading"');
+ expect(text).toContain("Introduction ");
+ 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( );
+ expect(video).toContain('data-type="video"');
+ expect(video).toMatch(/]+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( );
+ 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( );
+ 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( );
+ 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( );
+ expect(js).toContain("const a = 1");
+ expect(js).not.toContain("val a = 1");
+ expect(js).toContain("x"); // inline code in the title
+ expect(js).toContain('aria-label="Track"');
+ expect(js).toContain("npm test");
+ const scala = html( );
+ 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");
+});
diff --git a/packages/feature-learner/test/web-shell.test.tsx b/packages/feature-learner/test/web-shell.test.tsx
new file mode 100644
index 0000000..b44945c
--- /dev/null
+++ b/packages/feature-learner/test/web-shell.test.tsx
@@ -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 => ({
+ 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( );
+ 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( );
+ 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(
+
+ body
+ ,
+ );
+ 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("/");
+});
diff --git a/packages/feature-learner/web/src/index.ts b/packages/feature-learner/web/src/index.ts
new file mode 100644
index 0000000..4c833d1
--- /dev/null
+++ b/packages/feature-learner/web/src/index.ts
@@ -0,0 +1,4 @@
+// Browser entry "@stc/feature-learner/web". The repo maps "@stc/*" to "packages/*/src", so this subpath
+// resolves here (tsc and Bun alike). It re-exports the web half only: the package index also carries the
+// server (Better Auth, SSO), which cannot be bundled for the browser.
+export * from "../../src/web";