Context. surface customer · domain nyx · route /nyx/observation · auth signed-in · source apps/oshun/web/src/app/nyx/observation/page.tsx
Last walked. 2026-05-29 automated runtime walk (Playwright headless) — render, /v1 data (2xx), console/page-errors, expected content, screenshot verified; live screen-reader, touch, offline, and telemetry-delivery checks pending a manual AT pass. Evidence: WALKTHROUGH/results/runtime-sweep-2026-05-29.md
Purpose#
A quiet field log: what you looked at, when, sky conditions, and a short note.
Nyx anchors it to your private observation lifetime — "no streaks, no badges;
only an honest record." The page is a server shell hosting the client form
NyxLogObservationForm, which POSTs to /v1/nyx/observations. See
V1/features.md#nyx.
Entry points#
/nyx/tonighttimeline — each "Log it →" row link routes here with?eventId=<id>to pre-anchor the log to that event/nyx/eventscards — each Nyx card's "Plan an observation →" link routes here with?eventId=<id>/nyx/tonightfooter — "Plan an observation" primary button →/nyx/observation- Direct URL / bookmark — yes (
alternates.canonical = '/nyx/observation'); signed-in - Shell nav — arrives under the
exploretab (LCustomerNav active="explore")
Layout regions#
page.tsx is a (synchronous) server component reading searchParams.eventId
and passing initialEventId into the client form. Centered column,
maxWidth: 640, data-responsive-page.
- Top chrome:
LCustomerNav(Explore active) with "Lilith" wordmark + "⌕ Search" - Masthead (
LMasthead): leftNyx · field log, rightprivate, kicker "An observation, gently held", title "Log an observation." - "How this opens" note: "Nyx keeps a quiet lifetime log of what you actually looked at — for your eyes only, unless you choose to share. No streaks, no badges; only an honest record."
- Main:
<NyxLogObservationForm initialEventId={...} />— a card-bordered form (target, when, conditions, note) or, after success, the "Logged" confirmation card
States#
- Default / empty form — target empty, "When" prefilled to local now via
formatLocalNow(), conditions defaulting to "Clear", note empty - Pre-anchored — arrived with
?eventId=;initialEventIdis held and submitted, but the form shows no visible chip naming the anchored event (verify — see gaps) - Validating —
targetinput isrequired,minLength={2},maxLength={120}; "When" isrequireddatetime-local; native constraint UI blocks submit - Submitting —
pendingtrue: submit button reads "Logging…",opacity 0.6,cursor: progress,disabled - Accepted — POST 2xx returns
AcceptedResponse; form swaps to arole="status" aria-live="polite"card titled "Logged" showingtargetLabel · <localized observedAt>and the monoobservationId - Error (recoverable) — non-2xx renders a
role="alert"box with the BFFmessage(or "Nyx could not record the observation ()."); form fields preserved for retry - Offline —
catchrenders "Nyx is offline. Try again in a moment." in the alert box; no write queueing - Gated — middleware enforces signed-in; POST sends
credentials: include+ CSRF header, so an unauthenticated/CSRF-missing call yields the recoverable error - Standalone PWA — form renders cleanly; verify the
datetime-localnative picker and safe-area insets in standalone window
Interactions#
Form fields#
- "What did you look at?" (text input
#obs-target)- Function: sets
targetLabel; placeholder "e.g. Jupiter near the waxing moon" - Validation:
required,minLength 2,maxLength 120 - Screen reader: label "What did you look at?" associated via
htmlFor
- Function: sets
- "When" (datetime-local input
#obs-when)- Function: sets
observedAt; default = local now; submitted asnew Date(observedAt).toISOString() - Validation:
required
- Function: sets
- Conditions cell buttons (4 toggle buttons,
role="radiogroup"aria-label "Conditions")- Options: Clear ("naked eye, no haze"), Partial ("some cloud, broken"), Overcast ("sky closed"), Light dome ("city wash, low contrast")
- Function: each
type="button"setsconditions; selected button getsaria-pressed, accent border, italic label - Keyboard: buttons are tabbable; verify arrow-key navigation within the radiogroup (currently plain buttons, not roving tabindex — see gaps)
- Mobile (≤ 640 px):
repeat(4, 1fr)grid may crowd; verify min target
- "Note (optional)" (textarea
#obs-note)- Function: sets
note;rows={3},maxLength 400; placeholder "What you saw, what surprised you, what stayed with you."
- Function: sets
- "Tonight" (
LBtnghost, lg) →/nyx/tonight(cancel/leave path) - "Log this observation" / "Logging…" (submit button)
- Function: POSTs
{ eventId, targetLabel, observedAtIso, conditions, note }to/v1/nyx/observations - Disabled when:
pending→ opacity 0.6, cursor progress - Offline:
catch→ "Nyx is offline" alert; no queue
- Function: POSTs
Confirmation card (after accept)#
- "Tonight" (
LBtnghost) →/nyx/tonight - "Log another" (
LBtnprimary) —onClickclearsaccepted, returning to a fresh form (target/note already reset on success)
Data & contracts#
- Reads:
searchParams.eventId(string | string[]) →initialEventId; no server fetch - Writes:
POST /v1/nyx/observationsviabuildOshunBffUrl,credentials: 'include', headerscontent-type: application/json+csrfHeaders(). Body:{ eventId, targetLabel, observedAtIso, conditions, note }. Response on success:AcceptedResponse(observationId,targetLabel,observedAtIso,conditions,note,state: 'logged'); on failure:RejectedResponse(code,message) - Realtime: none
- Caching: none (form route; write-only client interaction)
- Auth/role check: middleware signed-in; CSRF token from
GET /v1/csrfcached in memory by@/lib/csrf
Cross-references#
- Feature spec:
V1/features.md#nyx - Architecture:
V1/ARCHITECTURE.md#nyx - Sibling Nyx routes:
nyx.md,nyx-tonight.md,nyx-events.md - Journeys:
journeys/nyx-tonight-observation.md(tonight → log it → this form) - Component sources:
apps/oshun/web/src/app/nyx/observation/NyxLogObservationForm.tsxapps/oshun/web/src/lib/csrf.ts(csrfHeaders)
Open questions / known gaps#
- When pre-anchored via
?eventId=, the form never displays which event it is anchored to (no name/chip) — the id is silently submitted; confirm whether a visible "logging for:" affordance is intended - Conditions radiogroup uses plain
<button aria-pressed>not roving tabindex /role="radio"; verify screen-reader semantics meet therole="radiogroup"contract - No telemetry events fired on submit/accept/error — confirm intended for V1
- Offline write is dropped (no queue); the copy promises an "honest record" but offline logs are lost — confirm acceptable