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 index —
Propose a courseprimary button onmetis-lessons.mdlinks here - Metis schoolroom — verify whether
metis.mdsurfaces a "propose a course" affordance - Direct URL / bookmark — yes (auth required);
metadata.alternates.canonicalis/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 —
LCustomerNavwithactive="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 onL.paperwith1px solid L.rule) - Footer / sticky bottom: None.
- Automation anchors: root exposes
data-metis-course-proposal-pageanddata-route; the form exposesdata-metis-course-proposal-form, state, pending, field-length/default, and error attributes; the accepted card exposesdata-metis-course-proposal-status, course id, domain, weeks, review window, and BFF state.
States#
- Idle form — empty fields;
domainPathdefaults toPHL,weeksdefaults to6; submit button reads "Propose to Metis" - Pending —
pending=true; submit button text "Submitting…",opacity 0.6,cursor: progress,disabled; proven with a delayedroute.fetch()that still completes against the live BFF - Accepted (status card) —
acceptedset; the form is replaced by arole="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 inmetis-byom-ingest.spec.tsandmetis-create-forms.spec.ts - Error (recoverable) —
role="alert"banner with the servermessage(e.g. "A course title needs at least four characters.") or genericMetis 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
LWebShellat 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
FieldLabelcaption "The course, named" - Disabled when: never (but server 422s if trimmed length < 4)
- Function: controlled
- 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"
- Function: controlled
- Weeks (1–26) (number
input#course-weeks)- Function: controlled
weeks;type=number,min={1},max={26}; invalid parse falls back to6 - Keyboard: spinner; up/down adjust
- Screen reader: labelled "Weeks (1–26)"
- Function: controlled
- Why this one (optional) (
textarea#course-why)- Function: controlled
why→ sent aswhyItMatters;maxLength={400},rows={3},resize: vertical - Placeholder: "Tell Metis what understanding you want students to leave with."
- Screen reader: labelled "Why this one (optional)"
- Function: controlled
Buttons#
- Back (
LBtn kind="ghost" size="lg",href="/metis")- Function: navigates to
/metiswithout submitting - Touch target: ≥ 44×44 px (verify
LBtn size="lg")
- Function: navigates to
- Propose to Metis (
<button type="submit">)- Function:
submit()→ POST/v1/metis/courseswith{ title, domainPath, weeks, whyItMatters }; onresponse.oksetsacceptedand resets the next form totitle='',domainPath='PHL',weeks=6, andwhy=''; on non-ok readsRejectedResponse.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.tsasserts nodata-telemetry-event,data-analytics-event, ordata-eventhooks on the form, submit button, or descendants before submit and while the delayed live-BFF POST is pending.
- Function:
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.tsasserts nodata-telemetry-event,data-analytics-event, ordata-eventhooks on the accepted status card, Back-to-Metis link, Propose-another button, or descendants, then asserts the reset form remains hook-free.
- Function:
Data & contracts#
- Reads: None — no initial fetch; the form starts empty client-side
- Writes:
POST /v1/metis/courses(viabuildOshunBffUrl) with body{ title, domainPath, weeks, whyItMatters },credentials: 'include', headers includecontent-type: application/json+csrfHeaders()+ bearer auth when an API token is present- BFF handler (
apps/oshun/bff/src/routes/domain-stubs.ts) is guarded byoriginGuard+csrfGuard; validates trimmedtitle.length >= 4else422 { code: 'course_title_too_short', message }; clampsweeksto 1–26 (default 6); persists todomainStubsStore.courseProposalsand returns202 { courseId, title, domainPath, weeks, whyItMatters, state: 'awaiting-syllabus-review', reviewWithinDays } - Client
AcceptedResponseshape:{ courseId, title, domainPath, weeks, state: 'awaiting-syllabus-review', reviewWithinDays }
- BFF handler (
- Realtime: None.
- Caching: write response is
cache-control: no-store - Auth/role check: shell middleware enforces signed-in; BFF CSRF via
csrfHeaders()(GET/v1/csrfhandshake) +originGuard; missing auth returns401 { 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 authenticatedPOST /v1/metis/courses, asserts the accepted status card, then opens/metisand 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 ofdata-telemetry-event,data-analytics-event, anddata-eventhooks 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 - Architecture:
V1/ARCHITECTURE.md#metis - Sibling Metis routes:
metis.md,metis-lessons.md,metis-ingest.md,metis-byom.md,metis-lesson.md,metis-tutor.md - Journeys:
../../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.tsxapps/oshun/web/src/app/metis/courses/new/MetisProposeCourseForm.tsx- BFF:
apps/oshun/bff/src/routes/domain-stubs.ts
Open questions / known gaps#
- No DOM telemetry hook is fired on submit or accept:
metis-create-forms.spec.tsasserts nodata-telemetry-event,data-analytics-event, ordata-eventhooks on the form, submit button, accepted status card, Back-to-Metis link, Propose-another button, reset form, or descendants. Whether a future non-DOMmetis_course_proposedevent should be wired remains product/analytics scope, not an observed V1 DOM contract. - The
/v1/metis/coursesBFF handler is a fixture/stub store (domainStubsStore.courseProposals); confirm the production syllabus-review pipeline and where the returnedcourseIdbecomes 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.tsproves 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
domainPathshould be a full path (e.g.PHL/102) rather than the bare domain id the select currently sends