V1 Web PWA · Surface walkthrough

Nyx · field log (log an observation)

A per-surface walkthrough of the V1 Web PWA customer surface: layout, states, interactions, data, and cross-references.

walked
8sections4 minread

On this page

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/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)
  • Validatingtarget input is required, minLength={2}, maxLength={120}; "When" is required datetime-local; native constraint UI blocks submit
  • Submittingpending 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 ()."); form fields preserved for retry
  • Offlinecatch 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#

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 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