V1 Web PWA · Surface walkthrough

Metis · ingest a source

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/ingest · auth signed-in · source apps/oshun/web/src/app/metis/ingest/page.tsx

Last walked. 2026-06-29 real-infra Playwright chromium — signed-in customer shell, idle URL default, true radio source selector with Arrow/Home/End roving keyboard, PDF reference accepted through the live BFF, delayed pasted-text pending state, completed outline status card, real invalid-URL 422 validation, explicit offline abort branch, Queue another reset, mobile standalone shape, no horizontal overflow, and shared axe scan; 2026-06-29 telemetry addendum verifies the ingest form, submit button, accepted status card, Back-to-Metis link, Queue-another button, and descendants expose no data-telemetry-event, data-analytics-event, or data-event hooks. Evidence: WALKTHROUGH/results/v1-real-infra-run-2026-06-22.md#212-2026-06-29-metis-ingest-submitaccept-telemetry-coverage

Purpose#

Creator-facing surface to hand Metis one piece of raw material — a public URL, a PDF upload, or a pasted passage — and queue an ingest job. On accept the BFF returns a 202 with an ingestJobId: pasted text is processed immediately into state=completed with a study outline, while URL/PDF references are honestly recorded as state=awaiting_source_content until deploy-bound fetch/parsing can provide the source body. See V1/features.md#metis.

Entry points#

  • Metis schoolroom / BYOM — verify whether metis.md or metis-byom.md links to /metis/ingest
  • Direct URL / bookmark — yes (auth required); canonical is /metis/ingest
  • Search result — page metadata title "Metis · ingest"

Layout regions#

page.tsx is a server component: <LWebShell top={<LCustomerNav active="explore" />}> wrapping a 640px-max centered column with <LMasthead>, an italic intro, then the client <MetisIngestForm />.

  • Header: web shell top — LCustomerNav with active="explore"
  • Masthead: LMasthead — left "Metis · ingest", right "creator", kicker "From source to syllabus", title "Ingest a source."
  • Intro: LEyebrow "How this opens" + serif-italic copy ("Hand Metis a single source. She returns a draft outline with section breaks, quotable passages, and a first-pass assessment rubric …")
  • Main: the ingest form card (L.paper, 1px solid L.rule)
  • Footer / sticky bottom: None.
  • Automation anchors: root exposes data-metis-ingest-page and data-route; the form exposes data-metis-ingest-form, source-kind, pending/state, field-length/file-name, and error attributes; the accepted card exposes data-metis-ingest-status, job id, BFF state, outline presence, segment/prompt/minute counts, and pending reason.

States#

  • Idle formkind defaults to url; the URL input is shown; submit reads "Queue for ingest"
  • Kind = URLinput#ingest-url (type=url, required) visible
  • Kind = PDFinput#ingest-pdf (type=file, accept=application/pdf, required) visible; selected file name echoed below in mono
  • Kind = Texttextarea#ingest-text (required, minLength={60}, rows={6}) visible
  • Pending — submit text "Queueing…", opacity 0.6, cursor: progress, disabled; proven with a delayed route.fetch() that still completes against the live BFF
  • Accepted (text status card)accepted set; role="status" aria-live="polite" card: eyebrow "Outline ready", "Job <ingestJobId> processed.", section/minute summary, outline segments, "Study prompts", with "Back to Metis" + "Queue another"; on accept kind resets to url and url/text/fileName clear. Verified 2026-06-29 in metis-byom-ingest.spec.ts and metis-create-forms.spec.ts
  • Accepted (PDF reference status card) — BFF returns state=awaiting_source_content; card eyebrow "Received", "Job <ingestJobId> accepted.", and the deploy-bound fetch/parse or PDF parse reason. URL reference remains contract-covered; the browser proof covers PDF because it exercises the same awaiting_source_content card branch.
  • Error (recoverable)role="alert" banner "Ingest could not start (<status>)." on non-ok; real invalid URL 422 preserves fields
  • Offline — fetch throws → "Metis is offline. Try again in a moment."; submit is a no-op (no queue)
  • Loading — N/A; client-rendered form with no initial fetch
  • Empty — N/A; create form
  • 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#

Source-kind selector (role="radiogroup" aria-label="Source kind")#

  • URL (<button type="button" role="radio" aria-checked>)
    • Function: setKind('url'); cell shows "URL" / "archive / paper"; selected cell gets L.accent border, L.bg background, italic label
    • Keyboard: true roving radio group; ArrowRight/ArrowDown move forward, ArrowLeft/ArrowUp move backward, Home selects URL, End selects Text
  • PDF (<button type="button" role="radio" aria-checked>)
    • Function: setKind('pdf'); cell "PDF" / "upload a file"
  • Text (<button type="button" role="radio" aria-checked>)
    • Function: setKind('text'); cell "Text" / "paste a passage"

Conditional inputs#

  • Public URL (input#ingest-url, shown when kind==='url')
    • Function: controlled url; type=url + required (native validation)
    • Placeholder: "https://archive.org/details/…"
  • PDF file (input#ingest-pdf, shown when kind==='pdf')
    • Function: type=file accept=application/pdf required; on change sets fileName = files?.[0]?.name; file name echoed in mono below
    • Note: only the file name is sent; the file bytes are not uploaded (see gaps)
  • Passage (textarea#ingest-text, shown when kind==='text')
    • Function: controlled text; required, minLength={60}, rows={6}, resize: vertical
    • Placeholder: "Paste a passage — sixty characters minimum so Metis has enough to chew."

Buttons#

  • Back (LBtn kind="ghost" size="lg", href="/metis")
    • Function: navigates to /metis without submitting
  • Queue for ingest (<button type="submit">)
    • Function: submit() → POST /v1/metis/ingest with { kind, url, text, fileName } (non-active fields sent as null); on ok sets accepted, resets kind='url', and clears fields
    • Keyboard: Enter submits
    • Disabled when: pending → text "Queueing…"
    • Offline behavior: catch → error banner; not queued
    • Telemetry: none wired; E2E asserts the form, submit button, and form descendants expose no data-telemetry-event, data-analytics-event, or data-event hook before submit and while the live BFF request is pending.

Accepted-state buttons#

  • Back to Metis (LBtn kind="ghost" size="md", href="/metis")
  • Queue another (LBtn kind="primary" size="md")
    • Function: setAccepted(null) → returns to the reset idle URL form
    • Telemetry: none wired; E2E asserts the accepted status card, Back-to-Metis link, Queue-another button, and descendants expose no data-telemetry-event, data-analytics-event, or data-event hook.

Data & contracts#

  • Reads: None — no initial fetch
  • Writes: POST /v1/metis/ingest (via buildOshunBffUrl) with body { kind: 'url'|'pdf'|'text', url, text, fileName }, credentials: 'include', headers 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, and collectionsAuthGuard; missing auth returns 401 { reason: 'missing_auth_context' }
    • For kind='text', the handler validates at least 60 characters, persists an owner-scoped job in metisIngestJobStore, derives a real study outline from the submitted text, and returns 202 { ingestJobId: 'ingest_<uuid>', state: 'completed', outline, pendingReason: null }
    • For kind='url', the handler validates an http(s) URL, persists the owner-scoped reference, and returns state: 'awaiting_source_content' with a pending reason because fetch/extraction is deploy-bound
    • For kind='pdf', the handler validates a file name, persists the owner-scoped reference, and returns state: 'awaiting_source_content' because the browser sends no file bytes
    • GET /v1/metis/ingest/:jobId reads a job scoped to the same owner and 403s on owner mismatch
    • Client AcceptedResponse shape: { ingestJobId, state: 'completed'|'awaiting_source_content', pendingReason, outline }
    • Client success handling stores the accepted response, resets the next source kind to URL, and clears url, text, and fileName
  • Realtime: None. — pasted text completes synchronously in the 202 response; URL/PDF reference extraction is pending/deploy-bound, not streamed
  • Caching: write response is no-store
  • Auth/role check: shell middleware enforces signed-in; BFF also enforces origin, CSRF, and collection auth on the write

No typed contract in libs/contracts/ or libs/openapi/; shapes are inline.

E2E coverage#

  • apps/oshun/web/e2e/metis-byom-ingest.spec.ts — 2026-06-29 live browser test opens /metis/ingest, selects Text, submits a pasted passage to the real BFF, waits for the 202, and asserts the rendered outline status card
  • apps/oshun/web/e2e/metis-create-forms.spec.ts — 2026-06-29 live browser route test verifies idle defaults, true radio source-kind keyboard behavior, PDF reference acceptance through the real BFF, pasted-text delayed pending and completed outline states, Queue another reset, real invalid-URL 422 handling, offline abort handling, mobile standalone layout, route-level axe scan, and no telemetry hook attributes on the ingest form, submit button, accepted status card, accepted-state action controls, and descendants
  • apps/oshun/web/e2e/metis-ingest-bff.spec.ts — contract-level coverage for text completion, URL/PDF awaiting_source_content, validation, auth, and scoped job reads

Cross-references#

Open questions / known gaps#

  • PDF is not actually uploaded — only fileName is posted as JSON; there is no multipart upload or presigned URL flow, so Metis has no file bytes to ingest. Confirm the intended upload path.
  • The BFF /v1/metis/ingest stub ignores kind/url/text/fileName and returns a synthetic ingestJobId — resolved: the handler now validates source kind, processes pasted text, stores owner-scoped jobs, and honestly records URL/PDF references as awaiting_source_content.
  • /v1/metis/ingest lacks originGuard/csrfGuard while /v1/metis/courses has both — resolved: the route now uses originGuard, csrfGuard, and collectionsAuthGuard.
  • The radiogroup uses <button aria-pressed> rather than radio inputs; verify arrow-key roving and screen-reader announcement meet the radiogroup contract — resolved: the selector now uses role="radio", aria-checked, roving tabIndex, and Arrow/Home/End keyboard behavior.
  • No telemetry on submit/accept: metis-create-forms.spec.ts asserts the ingest form, submit button, accepted status card, Back-to-Metis link, Queue-another button, and descendants expose no data-telemetry-event, data-analytics-event, or data-event hooks. Product decision remains whether a future metis_ingest_queued event is expected.