V1 Web PWA · Surface walkthrough

Metis · propose a course

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

walked
9sections7 minread

On this page

Context. surface customer · domain metis · route /metis/courses/new · auth signed-in · source apps/oshun/web/src/app/metis/courses/new/page.tsx

Last walked. 2026-06-29 real-infra Playwright chromium — submit and accepted-state telemetry absence covered for the course proposal form, submit button, accepted status card, Back-to-Metis link, Propose-another button, reset form, and descendants: no data-telemetry-event, data-analytics-event, or data-event hooks. Evidence: WALKTHROUGH/results/v1-real-infra-run-2026-06-22.md#214-2026-06-29-metis-course-proposal-submitaccept-telemetry-coverage' '2026-06-29 real-infra Playwright chromium — signed-in customer shell, idle field defaults, controlled field state, delayed live-BFF pending state, real accepted proposal, accepted-state metadata, Propose another reset, real server 422 validation, explicit offline abort branch, mobile standalone shape, no horizontal overflow, and shared axe scan. Evidence: WALKTHROUGH/results/v1-real-infra-run-2026-06-22.md#200-2026-06-29-metis-create-form-state-coverage

Purpose#

A four-field proposal form for a course the user wants to teach (or learn): a name, a domain, a length in weeks, and one optional sentence about the intended takeaway. Submitting POSTs to /v1/metis/courses; on accept Metis holds the proposal for syllabus review and returns a six-week outline within a few days. See V1/features.md#metis.

Entry points#

  • Metis lessons indexPropose a course primary button on metis-lessons.md links here
  • Metis schoolroom — verify whether metis.md surfaces a "propose a course" affordance
  • Direct URL / bookmark — yes (auth required); metadata.alternates.canonical is /metis/courses/new
  • Search result — page metadata title "Metis · propose a course"

Layout regions#

page.tsx is a server component: <LWebShell top={<LCustomerNav active="explore" />}> wrapping a 640px-max centered column that renders <LMasthead>, an italic "How this opens" intro block, then the client <MetisProposeCourseForm />.

  • Header: web shell top — LCustomerNav with active="explore"
  • Masthead: LMasthead — left "Metis · courses", right "proposal", kicker "A course, sketched", title "Propose a course."
  • Intro: LEyebrow "How this opens" + serif-italic copy ("A name, a domain, a length, and one sentence … six-week outline within three days.")
  • Main: the proposal form (MetisProposeCourseForm, a <form> card on L.paper with 1px solid L.rule)
  • Footer / sticky bottom: None.
  • Automation anchors: root exposes data-metis-course-proposal-page and data-route; the form exposes data-metis-course-proposal-form, state, pending, field-length/default, and error attributes; the accepted card exposes data-metis-course-proposal-status, course id, domain, weeks, review window, and BFF state.

States#

  • Idle form — empty fields; domainPath defaults to PHL, weeks defaults to 6; submit button reads "Propose to Metis"
  • Pendingpending=true; submit button text "Submitting…", opacity 0.6, cursor: progress, disabled; proven with a delayed route.fetch() that still completes against the live BFF
  • Accepted (status card)accepted set; the form is replaced by a role="status" aria-live="polite" card: eyebrow "Submitted", "<title>" — held for syllabus review., "Metis returns within <reviewWithinDays> days … Course id <courseId>", with "Back to Metis" + "Propose another". Verified 2026-06-29 in metis-byom-ingest.spec.ts and metis-create-forms.spec.ts
  • Error (recoverable)role="alert" banner with the server message (e.g. "A course title needs at least four characters.") or generic Metis could not record the proposal (<status>).; fields preserved, retry by resubmitting
  • Offline — fetch throws → error = "Metis is offline. Try again in a moment."; submit is a no-op until reconnect (no queue)
  • Loading — N/A; the form is client-rendered with no initial fetch
  • Empty — N/A; this is a create form, not a list
  • Signed-in shell context — walked through an authenticated customer shell with the Explore nav active
  • Standalone PWA — renders within LWebShell at 390px width with no horizontal overflow

Interactions#

Form fields (MetisProposeCourseForm)#

  • The course, named (text input#course-title)
    • Function: controlled title; required, minLength={4}, maxLength={140}
    • Placeholder: "e.g. Stoic ethics, the longer view"
    • Keyboard: standard text entry; first field, tab order 1 within form
    • Screen reader: labelled by FieldLabel caption "The course, named"
    • Disabled when: never (but server 422s if trimmed length < 4)
  • Domain (select#course-domain)
    • Function: controlled domainPath; options PHL · Philosophy, PSY · Psychology, REL · Religion, NEU · Neuroscience, ANT · Anthropology, AST · Astronomy (value = id)
    • Keyboard: native select; arrow keys cycle options
    • Screen reader: labelled "Domain"
  • Weeks (1–26) (number input#course-weeks)
    • Function: controlled weeks; type=number, min={1}, max={26}; invalid parse falls back to 6
    • Keyboard: spinner; up/down adjust
    • Screen reader: labelled "Weeks (1–26)"
  • Why this one (optional) (textarea#course-why)
    • Function: controlled why → sent as whyItMatters; maxLength={400}, rows={3}, resize: vertical
    • Placeholder: "Tell Metis what understanding you want students to leave with."
    • Screen reader: labelled "Why this one (optional)"

Buttons#

  • Back (LBtn kind="ghost" size="lg", href="/metis")
    • Function: navigates to /metis without submitting
    • Touch target: ≥ 44×44 px (verify LBtn size="lg")
  • Propose to Metis (<button type="submit">)
    • Function: submit() → POST /v1/metis/courses with { title, domainPath, weeks, whyItMatters }; on response.ok sets accepted and resets the next form to title='', domainPath='PHL', weeks=6, and why=''; on non-ok reads RejectedResponse.message
    • Keyboard: Enter from any field submits; this is the form submit control
    • Disabled when: pending → text "Submitting…", cursor: progress
    • Offline behavior: catch → error banner; not queued
    • Telemetry: metis-create-forms.spec.ts asserts no data-telemetry-event, data-analytics-event, or data-event hooks on the form, submit button, or descendants before submit and while the delayed live-BFF POST is pending.

Accepted-state buttons#

  • Back to Metis (LBtn kind="ghost" size="md", href="/metis")
  • Propose another (LBtn kind="primary" size="md")
    • Function: setAccepted(null) → returns to the reset idle form
    • Telemetry: metis-create-forms.spec.ts asserts no data-telemetry-event, data-analytics-event, or data-event hooks on the accepted status card, Back-to-Metis link, Propose-another button, or descendants, then asserts the reset form remains hook-free.

Data & contracts#

  • Reads: None — no initial fetch; the form starts empty client-side
  • Writes: POST /v1/metis/courses (via buildOshunBffUrl) with body { title, domainPath, weeks, whyItMatters }, credentials: 'include', headers include content-type: application/json + csrfHeaders() + bearer auth when an API token is present
    • BFF handler (apps/oshun/bff/src/routes/domain-stubs.ts) is guarded by originGuard + csrfGuard; validates trimmed title.length >= 4 else 422 { code: 'course_title_too_short', message }; clamps weeks to 1–26 (default 6); persists to domainStubsStore.courseProposals and returns 202 { courseId, title, domainPath, weeks, whyItMatters, state: 'awaiting-syllabus-review', reviewWithinDays }
    • Client AcceptedResponse shape: { courseId, title, domainPath, weeks, state: 'awaiting-syllabus-review', reviewWithinDays }
  • Realtime: None.
  • Caching: write response is cache-control: no-store
  • Auth/role check: shell middleware enforces signed-in; BFF CSRF via csrfHeaders() (GET /v1/csrf handshake) + originGuard; missing auth returns 401 { reason: 'missing_auth_context' }

No typed contract found in libs/contracts/ or libs/openapi/; shapes are declared inline in MetisProposeCourseForm.tsx and the BFF route.

E2E coverage#

  • apps/oshun/web/e2e/metis-byom-ingest.spec.ts — 2026-06-29 live browser test opens /metis/courses/new, submits a real authenticated POST /v1/metis/courses, asserts the accepted status card, then opens /metis and verifies the same learner's drafted proposal appears in "Open courses · 4"
  • apps/oshun/web/e2e/metis-create-forms.spec.ts — 2026-06-29 live browser route test verifies idle defaults, controlled fields, delayed pending state against the real BFF, accepted metadata, reset behavior, real 422 validation, offline abort handling, mobile standalone layout, route-level axe scan, and explicit absence of data-telemetry-event, data-analytics-event, and data-event hooks on the submit/accepted-state controls.
  • apps/oshun/web/e2e/metis-ingest-bff.spec.ts — contract-level coverage for accepted proposals, 422 course_title_too_short, and BFF persistence

Cross-references#

Open questions / known gaps#

  • No DOM telemetry hook is fired on submit or accept: metis-create-forms.spec.ts asserts no data-telemetry-event, data-analytics-event, or data-event hooks on the form, submit button, accepted status card, Back-to-Metis link, Propose-another button, reset form, or descendants. Whether a future non-DOM metis_course_proposed event should be wired remains product/analytics scope, not an observed V1 DOM contract.
  • The /v1/metis/courses BFF handler is a fixture/stub store (domainStubsStore.courseProposals); confirm the production syllabus-review pipeline and where the returned courseId becomes a real course
  • Course proposal form auth dead end closed — the browser component now sends bearer auth alongside CSRF, matching the BFF's member-action requirement; metis-byom-ingest.spec.ts proves no "Authentication required" response in the real browser flow.
  • Client maxLength={140} vs. server only enforcing a 4-char floor — confirm whether the server should also cap title length
  • Confirm whether domainPath should be a full path (e.g. PHL/102) rather than the bare domain id the select currently sends