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

# Nyx · field log (log an observation)

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

## Entry points

- **`/nyx/tonight` timeline** — each "Log it →" row link routes here with
  `?eventId=<id>` to pre-anchor the log to that event
- **`/nyx/events` cards** — each Nyx card's "Plan an observation →" link routes
  here with `?eventId=<id>`
- **`/nyx/tonight` footer** — "Plan an observation" primary button →
  `/nyx/observation`
- **Direct URL / bookmark** — yes (`alternates.canonical = '/nyx/observation'`);
  signed-in
- **Shell nav** — arrives under the `explore` tab
  (`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`): left `Nyx · field log`, right `private`, 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=`; `initialEventId` is held and
      submitted, but the form shows no visible chip naming the anchored event
      (verify — see gaps)
- [ ] **Validating** — `target` input is `required`, `minLength={2}`,
      `maxLength={120}`; "When" is `required` `datetime-local`; native
      constraint UI blocks submit
- [ ] **Submitting** — `pending` true: submit button reads "Logging…",
      `opacity 0.6`, `cursor: progress`, `disabled`
- [ ] **Accepted** — POST 2xx returns `AcceptedResponse`; form swaps to a
      `role="status" aria-live="polite"` card titled "Logged" showing
      `targetLabel · <localized observedAt>` and the mono `observationId`
- [ ] **Error (recoverable)** — non-2xx renders a `role="alert"` box with the
      BFF `message` (or "Nyx could not record the observation (<status>).");
      form fields preserved for retry
- [ ] **Offline** — `catch` renders "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-local`
      native 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`
- [ ] **"When"** (datetime-local input `#obs-when`)
  - Function: sets `observedAt`; default = local now; submitted as
    `new Date(observedAt).toISOString()`
  - Validation: `required`
- [ ] **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"` sets `conditions`; selected button gets
    `aria-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."
- [ ] **"Tonight"** (`LBtn` ghost, 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

### Confirmation card (after accept)

- [ ] **"Tonight"** (`LBtn` ghost) → `/nyx/tonight`
- [ ] **"Log another"** (`LBtn` primary) — `onClick` clears `accepted`,
      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/observations` via `buildOshunBffUrl`,
  `credentials: 'include'`, headers `content-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/csrf`
  cached in memory by `@/lib/csrf`

## Cross-references

- Feature spec: [`V1/features.md#nyx`](../../../V1/features.md#nyx)
- Architecture: [`V1/ARCHITECTURE.md#nyx`](../../../V1/ARCHITECTURE.md#nyx)
- Sibling Nyx routes: [`nyx.md`](./nyx.md),
  [`nyx-tonight.md`](./nyx-tonight.md), [`nyx-events.md`](./nyx-events.md)
- Journeys:
  [`journeys/nyx-tonight-observation.md`](../../journeys/nyx-tonight-observation.md)
  (tonight → log it → this form)
- Component sources:
  - `apps/oshun/web/src/app/nyx/observation/NyxLogObservationForm.tsx`
  - `apps/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: <event>" affordance is intended
- [ ] Conditions radiogroup uses plain `<button aria-pressed>` not roving
      tabindex / `role="radio"`; verify screen-reader semantics meet the
      `role="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
