Context. surface customer · domain metis · route /metis/lessons · auth signed-in · source apps/oshun/web/src/app/metis/lessons/page.tsx
Last walked. 2026-06-29 real-infra Playwright chromium — live guarded BFF multi-course fixture, signed-in customer shell, Explore nav, course grouping, lesson rows, assessment-ready state, hrefs, 44px lesson/course tap targets, mobile standalone shape, no horizontal overflow, and shared axe scan. Evidence: WALKTHROUGH/results/v1-real-infra-run-2026-06-22.md#199-2026-06-29-metis-lessons-session-live-bff-coverage
Purpose#
The schoolroom backlog: every active lesson across the user's courses, grouped
by course, each row showing section number, week N/M, minutes estimated, and
whether its assessment is ready. Read-only index page — server-rendered from
/v1/metis/lessons. See
V1/features.md#metis.
Entry points#
- Metis schoolroom — verify whether
metis.mdlinks to the lessons backlog - Metis today's session — "All lessons" ghost button on
metis-session.mdlinks here - Direct URL / bookmark — yes (auth required);
canonical/metis/lessons - Search result — page metadata title "Metis · lessons"
Layout regions#
page.tsx is an async server component:
await bffGet<LessonsResponse>('/v1/metis/lessons') (falls back to
{ lessons: [] } on null), groups lessons by courseId, renders inside
<LWebShell top={<LCustomerNav active="explore" />}> in an 880px-max centered
column.
- Header: web shell top —
LCustomerNavwithactive="explore" - Masthead:
LMasthead— left "Metis · lessons", right "<count>active", kicker "The schoolroom backlog", title "The lessons." - Intro:
LEyebrow"How this opens" + serif-italic explainer - Main: one
<section>per course — an<h2>withcourseLabelplus an "Open course →" link, then an<ol>of lesson<li>rows - Footer: a button row — "← Back to Metis", "Today's session", "Propose a course"
- Automation anchors: root exposes
data-metis-lessons-page,data-route,data-generated-at, lesson/course/assessment-ready counts, anddata-empty; course sections exposedata-metis-lessons-courseplus course/count attributes; lesson rows exposedata-metis-lessons-rowplus lesson/course/week/minute/assessment attributes.
States#
- Populated (short) — 3 lessons across 2 course sections from the live guarded BFF fixture; grouped layout
- Populated (long) — 50+ lessons / many courses; verify scroll and that grouping stays performant (no pagination present)
- Empty —
byCourse.length === 0→ dashed-border card "No active lessons. Start a course to populate the backlog." - Per-lesson assessment ready — row link reads "open · quiz ready" in
L.accent; when not ready it reads "open →" inL.muted - Error / null fetch —
bffGetreturnsnullon non-2xx/timeout → falls back to empty (renders the Empty card, not an error banner) - Loading — server-rendered; no route-level
loading.tsxskeleton in the route folder - Offline — first load needs the BFF; on failure renders Empty; verify SW caches the rendered HTML for offline reload
- Signed-in shell context — walked through an authenticated customer shell with the Explore nav active
- Standalone PWA — mobile standalone launch renders within
LWebShellwithout horizontal overflow
Interactions#
Per-course section#
- Course heading (
<h2>) — non-interactive; showscourseLabel ?? courseId - Open course → (
Link)- Function: navigates to
/metis/lesson?courseId=<encodeURIComponent(courseId)> - Screen reader: announces "Open course
<courseLabel>" - Styling: mono, uppercase,
L.accent - Touch target: verified ≥ 44×44 px
- Function: navigates to
Per-lesson row (<li>)#
- Lesson meta line — non-interactive: "§
<sectionNumber>· week<week>/<weekTotal>·<minutesEstimated>min" (mono uppercase) - Lesson title — non-interactive serif text
- Open lesson link (
Link)- Function: navigates to
/metis/lesson?id=<encodeURIComponent(lesson.id)> - Label: "open · quiz ready" when
assessmentReady, else "open →" - Screen reader: announces "Open lesson
<title>" and appends "(quiz ready)" when relevant;whiteSpace: nowrap - Touch target: verified ≥ 44×44 px
- Function: navigates to
Footer buttons#
- ← Back to Metis (
LBtn kind="ghost" size="md",href="/metis") - Today's session (
LBtn kind="ghost" size="md",href="/metis/session") - Propose a course (
LBtn kind="primary" size="md",href="/metis/courses/new")
Data & contracts#
- Reads:
GET /v1/metis/lessonsviabffGet<LessonsResponse>(server-side, forwardsoshun-session/__sessioncookie;revalidate: 60sdefault;timeout 5000ms); returnsnullon failure →{ generatedAt, lessons: [] }LessonsResponse = { generatedAt: string; lessons: Lesson[] }Lesson = { id, courseId, courseLabel, title, week, weekTotal, sectionNumber, minutesEstimated, assessmentReady }- BFF serves the production-guarded
METIS_LESSONS_FIXTUREwith 3 lessons across 2 courses (phl-102,ast-110), exactly oneassessmentReady=true, and 58 total planned minutes for session consumers (apps/oshun/bff/src/routes/domain-stubs.ts)
- Writes: None.
- Realtime: None.
- Caching: SSR with Next
revalidate: 60; SW HTML cache for offline reload - Auth/role check: page is walked through a signed-in customer shell;
bffGetforwards the session cookie. The current domain-stubs BFF endpoint is production-guarded fixture data, not an auth-scoped per-user route.
No typed contract in libs/contracts//libs/openapi/; shapes are inline in
page.tsx.
Cross-references#
- Feature spec:
V1/features.md#metis - Architecture:
V1/ARCHITECTURE.md#metis - Sibling Metis routes:
metis.md,metis-session.md,metis-lesson.md,metis-courses-new.md,metis-assessment.md - Journeys:
../../journeys/metis-lesson-completion.md - Component sources:
apps/oshun/web/src/app/metis/lessons/page.tsx- BFF:
apps/oshun/bff/src/routes/domain-stubs.ts
Open questions / known gaps#
- No pagination/virtualization — confirm expected behavior for a large backlog (the page renders all lessons inline)
- Rows link to
/metis/lesson(singular) withid/courseIdquery params; the hrefs are proven, but confirm the destination page's query-param behavior against the real lesson source (seemetis-lesson.md) -
/v1/metis/lessonsis still a production-guarded fixture (METIS_LESSONS_FIXTURE); confirm the real backlog source and per-user filtering - A null fetch silently renders Empty rather than a distinct error state — confirm whether an error/retry affordance is desired