feat(learner): search results show lesson matches cleanly; contract notes for the browser entry and LessonView; plan marked done
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
89c51f013d
commit
061ae7a2d9
@@ -19,4 +19,12 @@ and let `startServer` take `Array<Feature | FeatureFactory>`.
|
||||
|
||||
## 3. Package entry for the browser
|
||||
|
||||
`@stc/feature-learner`'s index exports both `learnerFeature` (server) and `learnerWeb`/`LessonView` (browser). Bun's browser bundler tree-shakes the server half (checked), so one entry works. The web shell imports `learnerWeb` from `@stc/feature-learner`; the studio can import `LessonView` the same way.
|
||||
**Problem.** The package index exports both the server (`learnerFeature`, which pulls in Better Auth and `@better-auth/sso`) and the browser half (`learnerWeb`, `LessonView`). A browser bundle of the index fails: `@better-auth/sso` imports `X509Certificate` from `node:crypto`, which has no browser polyfill. The root `tsconfig.json` maps `@stc/*` to `packages/*/src`, so package.json `exports` subpaths are not used either.
|
||||
|
||||
**Workaround in `feat/learner` (applied).** `LessonView` and `learnerWeb` are exported from the index (for server-side and type use) and from a browser-only entry, `@stc/feature-learner/web`. That subpath resolves through the existing mapping to `packages/feature-learner/web/src/index.ts`, a one-line re-export of `src/web`. `apps/web/src/features.ts` imports `learnerWeb` from it, and the studio should import `LessonView` from it too.
|
||||
|
||||
**Proposal.** Add `"@stc/*/web": ["packages/*/src/web"]` ahead of the general mapping in `tsconfig.json` (Bun honours tsconfig paths), and let every feature package expose `src/web/index.ts`. The shim directory can then be deleted. This also settles the importer's relative import in `features.ts`.
|
||||
|
||||
## 4. LessonView for the studio preview
|
||||
|
||||
`import { LessonView } from "@stc/feature-learner/web"`. Props: `lesson: LessonPayload` (the JSON from `GET /api/courses/:id/lessons/:lessonId`), plus optional `look`, `track`, `onTrackChange`, `preview`. With `preview` set, it makes no notes, attempt or progress requests; it still loads code listings and decks through the normal media routes. It injects its own stylesheet (scoped under `.lr`) and works with or without a router.
|
||||
|
||||
@@ -43,16 +43,16 @@ Inherited from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun only, plain
|
||||
- Tables: `auth_user`, `auth_session`, `auth_account`, `auth_verification`, `auth_sso_provider` (Better Auth, snake_case columns, ISO text dates, integer booleans), `learner_meta (key PK, value)`, `learner_group_claims (user_id, group_id)`.
|
||||
- test helper `forEachDb(name, fn)` = `@stc/db`'s helper plus the learner migrations.
|
||||
|
||||
- [ ] Step 1: failing test: after `runMigrations` the tables exist on both dialects and a second run applies nothing.
|
||||
- [ ] Step 2: implement; Step 3: green on SQLite and Postgres; commit.
|
||||
- [x] Step 1: failing test: after `runMigrations` the tables exist on both dialects and a second run applies nothing.
|
||||
- [x] Step 2: implement; Step 3: green on SQLite and Postgres; commit.
|
||||
|
||||
### Task 2: Better Auth adapter over Bun.SQL
|
||||
|
||||
**Files:** `src/server/auth/adapter.ts`, `test/adapter.test.ts`
|
||||
|
||||
**Interfaces:** `bunSqlAdapter(db: Db)` → Better Auth `DBAdapterInstance`. Model `x` → table `auth_<snake(x)>`, field `camelCase` → `snake_case`. `supportsDates/Booleans/JSON: false`, no transactions (SQLite has one connection; a nested query in a transaction would deadlock).
|
||||
- [ ] Failing tests (both dialects): create/findOne/findMany (eq, ne, in, not_in, contains, starts_with, ends_with, lt/gt, insensitive mode, OR grouping, sort, limit, offset), update, updateMany, delete, deleteMany, count, consumeOne, incrementOne.
|
||||
- [ ] Implement, green, commit.
|
||||
- [x] Failing tests (both dialects): create/findOne/findMany (eq, ne, in, not_in, contains, starts_with, ends_with, lt/gt, insensitive mode, OR grouping, sort, limit, offset), update, updateMany, delete, deleteMany, count, consumeOne, incrementOne.
|
||||
- [x] Implement, green, commit.
|
||||
|
||||
### Task 3: AuthService, owner bootstrap, claim groups, feature wiring
|
||||
|
||||
@@ -64,15 +64,15 @@ Inherited from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun only, plain
|
||||
- First user to sign up becomes `owner` (claimed via `INSERT` into `learner_meta` key `owner`, PK conflict loses); others get `learner`.
|
||||
- `syncClaimGroups(db, userId, names)` creates groups by name and replaces the user's claim-sourced memberships; manual memberships are untouched.
|
||||
- `learnerFeature(ctx)` returns `Feature & { auth }`; `startServer` accepts feature factories `(ctx) => Feature` and installs a feature's `auth` (contract request filed; two-line change in `main.ts`).
|
||||
- [ ] Failing tests: sign-up → session cookie → `userFromRequest` returns roles `[owner]`; second user `[learner]`; concurrent first sign-ups give one owner; sign-out clears; claim sync keeps manual memberships.
|
||||
- [ ] Implement, green, commit.
|
||||
- [x] Failing tests: sign-up → session cookie → `userFromRequest` returns roles `[owner]`; second user `[learner]`; concurrent first sign-ups give one owner; sign-out clears; claim sync keeps manual memberships.
|
||||
- [x] Implement, green, commit.
|
||||
|
||||
### Task 4: Admin API (roles, groups, course ACL, library curation)
|
||||
|
||||
**Files:** `src/server/admin.ts`, `test/admin.test.ts`
|
||||
- Routes (owner only, 403 otherwise): `GET /api/admin/users`, `PUT /api/admin/users/:id/roles`, `GET|POST /api/admin/groups`, `DELETE /api/admin/groups/:id`, `PUT /api/admin/groups/:id/members`, `GET|PUT /api/admin/courses/:id/acl`, `GET|PUT /api/admin/library` (featured + collections).
|
||||
- The last owner cannot drop their own owner role.
|
||||
- [ ] Tests first; implement; commit.
|
||||
- [x] Tests first; implement; commit.
|
||||
|
||||
### Task 5: Library, outline and lesson API
|
||||
|
||||
@@ -82,13 +82,13 @@ Inherited from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun only, plain
|
||||
- `GET /api/courses/:id` → outline, tracks, gating, `locked` per module, progress, diagnostics (errors only for owner/authors).
|
||||
- `GET /api/courses/:id/lessons/:lessonId` → `LessonPayload` (markdown, hints, decks, video, transcript, code, attachments, browser exercise, local command, prev/next) or `{ locked: true }`.
|
||||
- Gating: module *i* locked when any lesson of an earlier module is not done; owners and course authors bypass.
|
||||
- [ ] Tests against the sample fixture plus temp courses; implement; commit.
|
||||
- [x] Tests against the sample fixture plus temp courses; implement; commit.
|
||||
|
||||
### Task 6: Progress, notes, exercise attempts API
|
||||
|
||||
**Files:** `src/server/userdata.ts`, `test/userdata.test.ts`
|
||||
- `PUT /api/courses/:id/progress/:lessonId` `{state, positionSeconds?}`; notes CRUD (`GET/POST /api/courses/:id/notes`, `PATCH/DELETE /api/notes/:id`); attempts `GET/PUT /api/courses/:id/lessons/:lessonId/attempt?track=`. A passing attempt marks the lesson done. Anonymous → 401. Hidden course → 404.
|
||||
- [ ] Tests on both dialects (acceptance: notes persist on SQLite and Postgres); implement; commit.
|
||||
- [x] Tests on both dialects (acceptance: notes persist on SQLite and Postgres); implement; commit.
|
||||
|
||||
### Task 7: Code files and zip download
|
||||
|
||||
@@ -96,41 +96,52 @@ Inherited from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun only, plain
|
||||
- `GET /api/courses/:id/code/:moduleId` → `{ dir, command, files: {path,size}[] }` (skips `.git`, `node_modules`, symlinks escaping root; caps 2000 files).
|
||||
- `GET /api/courses/:id/code/:moduleId/zip` → `application/zip`, `Content-Disposition` via `contentDisposition("<dir name>.zip")`.
|
||||
- `zip(entries)`: store/deflate, CRC32, UTF-8 name flag.
|
||||
- [ ] Tests: zip round-trip via `unzip -l`, non-ASCII names, symlink escape skipped; commit.
|
||||
- [x] Tests: zip round-trip via `unzip -l`, non-ASCII names, symlink escape skipped; commit.
|
||||
|
||||
### Task 8: Browser exercise runner
|
||||
|
||||
**Files:** `src/web/runner/{assert.ts,worker-source.ts,runner.ts}`, `src/server/transpile.ts`, `test/runner.test.ts`
|
||||
- `createBrowserRunner(language, { transpile })`: `ExerciseRunner`. Worker disables `fetch`, `XMLHttpRequest`, `WebSocket`, `importScripts` before importing code. Results carry expected/actual for failed asserts.
|
||||
- [ ] Tests (Bun supports module workers and blob URLs): starter fails, solution passes, a syntax error is reported, `while(true)` times out, TS is transpiled.
|
||||
- [ ] commit.
|
||||
- [x] Tests (Bun supports module workers and blob URLs): starter fails, solution passes, a syntax error is reported, `while(true)` times out, TS is transpiled.
|
||||
- [x] commit.
|
||||
|
||||
### Task 9: Markdown pipeline
|
||||
|
||||
**Files:** `src/web/markdown.ts`, `test/markdown.test.ts`
|
||||
- `renderLessonHtml(md, { courseId, baseFile, tracks, lessonFiles })` → sanitized HTML: heading ids, code blocks tagged with their track (`data-t`), bold track labels tagged, `<details>` turned into folds; consecutive hint folds become a tier group (`data-tier`, `data-tiers`), relative images/links rewritten to `/api/media/...` or lesson routes.
|
||||
- [ ] Tests with Pure Snake-style input; commit.
|
||||
- [x] Tests with Pure Snake-style input; commit.
|
||||
|
||||
### Task 10: Web shell and library
|
||||
|
||||
**Files:** `src/web/{routes.tsx,api.ts,styles.css,shell/*,library/*}`
|
||||
- Header extras (course switcher, search, Reading/Work, account) portalled into the Phase 0 header; course outline sidebar (mobile overlay, Escape closes); library: featured hero, continue row, New row, collection rows of covers, format/length/in-progress filters, search.
|
||||
- [ ] Component smoke tests with `react-dom/server` render; screenshot check later; commit.
|
||||
- [x] Component smoke tests with `react-dom/server` render; screenshot check later; commit.
|
||||
|
||||
### Task 11: Lesson views and `LessonView`
|
||||
|
||||
**Files:** `src/web/lesson/*`
|
||||
- Text (track switcher, Reading/Work, folds, hint tiers), video (range-served `<video>`, resume, done near end, timestamp notes), deck viewer (pdfjs, thumbs, arrows, fullscreen, per-slide note), code files (tree, tabs, copy, zip), exercise page (editor, run, reset, per-test results, hints, local command; local-only: command + "Mark done" + optional pasted evidence), notes list, floating action bar.
|
||||
- [ ] Render tests for `LessonView` with each lesson type; commit.
|
||||
- [x] Render tests for `LessonView` with each lesson type; commit.
|
||||
|
||||
### Task 12: Account and admin pages
|
||||
|
||||
**Files:** `src/web/account/*`
|
||||
- Sign in / create account (email+password), configured provider buttons, SSO by email, sign out; owner admin page for users/roles, groups, featured course and collections.
|
||||
- [ ] Render tests; commit.
|
||||
- [x] Render tests; commit.
|
||||
|
||||
### Task 13: Wiring, verification, report
|
||||
|
||||
- [ ] Append `learnerWeb` to `apps/web/src/features.ts`; `learnerFeature` in `main.ts`.
|
||||
- [ ] `bun test`, `bun run typecheck`, Postgres run; dev server on 3461 with `STC_COURSE_DIRS=./fixtures/courses:./overlays`; headless Chrome screenshots of library, text, video, deck, code, exercise, account for all three courses; fix what looks wrong.
|
||||
- [ ] Write `/home/user/STCourses/.superpowers/sdd/2026-10-30-stcourses/wp-learner-report.md`.
|
||||
- [x] Append `learnerWeb` to `apps/web/src/features.ts`; `learnerFeature` in `main.ts`.
|
||||
- [x] `bun test`, `bun run typecheck`, Postgres run; dev server on 3461 with `STC_COURSE_DIRS=./fixtures/courses:./overlays`; headless Chrome screenshots of library, text, video, deck, code, exercise, account for all three courses; fix what looks wrong.
|
||||
- [x] Write `/home/user/STCourses/.superpowers/sdd/2026-10-30-stcourses/wp-learner-report.md`.
|
||||
|
||||
---
|
||||
|
||||
## Execution notes (added when the plan was finished)
|
||||
|
||||
- Tasks 1–9 were done by the first implementer (0b11715..99c0e49). Tasks 10–13 were done after a hand-over (89c51f0 onwards).
|
||||
- Tasks 10–12 landed in one commit, because the route modules import each other (the routes list loads the lesson, library and account pages).
|
||||
- Styles are a TS string (`src/web/styles.ts`) injected once, not a `.css` text import: Bun's dev server loses text imports on hot rebuilds.
|
||||
- The browser entry is `@stc/feature-learner/web` (see `docs/contract-requests/learner.md` §3); the index cannot be bundled for the browser.
|
||||
- Decks render client-side with pdf.js; the importer's `<dataDir>/cache/decks` is not used.
|
||||
- The exercise editor is a textarea with a line gutter, as in the mockup (CodeMirror was optional).
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { CourseCard, ContinueItem, Format, LibraryPayload, SearchHit } from
|
||||
import { api, courseHref, ensureStyles, fmtMinutes, lessonHref, refreshLibrary, useLibrary, useSearch } from "../api";
|
||||
import { HeaderExtras } from "../shell/HeaderExtras";
|
||||
import { accentStyle } from "../shell/CourseFrame";
|
||||
import { TitleText } from "../lesson/TitleText";
|
||||
|
||||
const FORMAT_LABEL: Record<Format, string> = { text: "Text", video: "Video", slides: "Slides", exercises: "Exercises" };
|
||||
type LengthFilter = "any" | "short" | "medium" | "long";
|
||||
@@ -104,16 +105,18 @@ export function LibraryView({ lib, initialQuery }: { lib: LibraryPayload; initia
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="sec-h">{q ? `Courses matching “${q}”` : "Courses"}</h2>
|
||||
{q && hits === null ? <p className="lr-nohit">Searching…</p> : results.length ? <CourseTable courses={results} signedIn={lib.signedIn} /> : <p className="lr-nohit">No courses match.</p>}
|
||||
{results.length || !lessonHits.length ? <h2 className="sec-h">{q ? `Courses matching “${q}”` : "Courses"}</h2> : null}
|
||||
{q && hits === null ? <p className="lr-nohit">Searching…</p>
|
||||
: results.length ? <CourseTable courses={results} signedIn={lib.signedIn} />
|
||||
: !lessonHits.length ? <p className="lr-nohit">Nothing matches. Try fewer words, or clear the filters.</p> : null}
|
||||
{lessonHits.length ? (
|
||||
<>
|
||||
<h2 className="sec-h" style={{ marginTop: 36 }}>Lessons</h2>
|
||||
<h2 className="sec-h" style={{ marginTop: results.length ? 36 : 0 }}>{q ? `Lessons matching “${q}”` : "Lessons"}</h2>
|
||||
<ul className="hits">
|
||||
{lessonHits.flatMap((h) => h.lessons.map((l) => (
|
||||
<li key={`${h.courseId}/${l.id}`}>
|
||||
<Link to={lessonHref(h.courseId, l.id)}>
|
||||
<span><span className="h1">{l.title}</span><span className="h2" style={{ display: "block" }}>{h.courseTitle} · {l.moduleTitle}</span></span>
|
||||
<span><span className="h1"><TitleText text={l.title} /></span><span className="h2" style={{ display: "block" }}>{h.courseTitle} · {l.moduleTitle}</span></span>
|
||||
</Link>
|
||||
</li>
|
||||
)))}
|
||||
|
||||
Reference in New Issue
Block a user