---
path: /metis/courses/new
surface: customer
domain: metis
auth: signed-in
source: apps/oshun/web/src/app/metis/courses/new/page.tsx
status: walked
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'
---

# Metis · propose a course

## 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`](../../../V1/features.md#metis).

## Entry points

- **Metis lessons index** — `Propose a course` primary button on
  [`metis-lessons.md`](./metis-lessons.md) links here
- **Metis schoolroom** — verify whether [`metis.md`](./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

- [x] **Idle form** — empty fields; `domainPath` defaults to `PHL`, `weeks`
      defaults to `6`; submit button reads "Propose to Metis"
- [x] **Pending** — `pending=true`; submit button text "Submitting…",
      `opacity 0.6`, `cursor: progress`, `disabled`; proven with a delayed
      `route.fetch()` that still completes against the live BFF
- [x] **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`
- [x] **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
- [x] **Offline** — fetch throws → `error` = "Metis is offline. Try again in a
      moment."; submit is a no-op until reconnect (no queue)
- [x] **Loading** — N/A; the form is client-rendered with no initial fetch
- [x] **Empty** — N/A; this is a create form, not a list
- [x] **Signed-in shell context** — walked through an authenticated customer
      shell with the Explore nav active
- [x] **Standalone PWA** — renders within `LWebShell` at 390px width with no
      horizontal overflow

## Interactions

### Form fields (`MetisProposeCourseForm`)

- [x] **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)
- [x] **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"
- [x] **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)"
- [x] **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

- [x] **Back** (`LBtn kind="ghost" size="lg"`, `href="/metis"`)
  - Function: navigates to `/metis` without submitting
  - Touch target: ≥ 44×44 px (verify `LBtn size="lg"`)
- [x] **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

- [x] **Back to Metis** (`LBtn kind="ghost" size="md"`, `href="/metis"`)
- [x] **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

- Feature spec: [`V1/features.md#metis`](../../../V1/features.md#metis)
- Architecture: [`V1/ARCHITECTURE.md#metis`](../../../V1/ARCHITECTURE.md#metis)
- Sibling Metis routes: [`metis.md`](./metis.md),
  [`metis-lessons.md`](./metis-lessons.md),
  [`metis-ingest.md`](./metis-ingest.md), [`metis-byom.md`](./metis-byom.md),
  [`metis-lesson.md`](./metis-lesson.md), [`metis-tutor.md`](./metis-tutor.md)
- Journeys:
  [`../../journeys/metis-byom-ingest-to-course.md`](../../journeys/metis-byom-ingest-to-course.md)
  (the course-proposal endpoint shares its journey anchor with ingest)
- Component sources:
  - `apps/oshun/web/src/app/metis/courses/new/page.tsx`
  - `apps/oshun/web/src/app/metis/courses/new/MetisProposeCourseForm.tsx`
  - BFF: `apps/oshun/bff/src/routes/domain-stubs.ts`

## Open questions / known gaps

- [x] 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
- [x] 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
