V1 Web PWA · Surface walkthrough

Metis · lessons index

A per-surface walkthrough of the V1 Web PWA customer surface: layout, states, interactions, data, and cross-references.

walked
8sections4 minread

On this page

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.md links to the lessons backlog
  • Metis today's session — "All lessons" ghost button on metis-session.md links 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 — LCustomerNav with active="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> with courseLabel plus 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, and data-empty; course sections expose data-metis-lessons-course plus course/count attributes; lesson rows expose data-metis-lessons-row plus 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)
  • EmptybyCourse.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 →" in L.muted
  • Error / null fetchbffGet returns null on non-2xx/timeout → falls back to empty (renders the Empty card, not an error banner)
  • Loading — server-rendered; no route-level loading.tsx skeleton 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 LWebShell without horizontal overflow

Interactions#

Per-course section#

  • Course heading (<h2>) — non-interactive; shows courseLabel ?? 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

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
  • ← 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/lessons via bffGet<LessonsResponse> (server-side, forwards oshun-session/__session cookie; revalidate: 60s default; timeout 5000ms); returns null on 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_FIXTURE with 3 lessons across 2 courses (phl-102, ast-110), exactly one assessmentReady=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; bffGet forwards 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#

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) with id/courseId query params; the hrefs are proven, but confirm the destination page's query-param behavior against the real lesson source (see metis-lesson.md)
  • /v1/metis/lessons is 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