V1 Web PWA · Surface walkthrough

Nyx · tonight

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/tonight · auth signed-in · source apps/oshun/web/src/app/nyx/tonight/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#

Tonight's sky over the user's observation window, in order: civil twilight, planet rises, conjunctions, meteor peaks. The page fetches /v1/nyx/tonight (60 s revalidate), promotes the BFF event flagged accent: true to a highlighted "lead event", and stacks the rest below in chronological order, each row offering a "Log it →" shortcut into the field log. See V1/features.md#nyx.

Entry points#

  • Nyx hub (/nyx) — verify a "Tonight" link from the hub sub-nav
  • /nyx/events footer — "Tonight" ghost button → /nyx/tonight
  • /nyx/observation — both the form's "Tonight" button and the accepted card's "Tonight" button route here
  • Direct URL / bookmark — yes (alternates.canonical = '/nyx/tonight'); signed-in
  • Shell nav — arrives under the explore tab (LCustomerNav active="explore")

Layout regions#

page.tsx is async: await getNyxTonight(), then picks headline = events.find(e => e.accent) ?? events[0] and rest = events.filter(e => e.id !== headline?.id). Centered column, maxWidth: 880, data-responsive-page.

  • Top chrome: LCustomerNav (Explore active) with "Lilith" wordmark + "⌕ Search"
  • Masthead (LMasthead): left data.date, right data.observationWindow, kicker "The sky tonight", title = italic data.tonightHeadline
  • Lead event card (only when headline exists): accent border, LEyebrow "Lead event ·
  • "The order of the night" section: LEyebrow then an <ol> rule-bordered list of rest rows; each row is 90px | 1fr | auto — mono time, serif name + italic detail, and a "Log it →" accent link
  • Empty timeline row: when rest is empty, one italic muted <li> "No further events on the timeline tonight."
  • Footer button row: "← Back to Nyx", "Upcoming events", "Plan an observation"

States#

  • Loadingpage.tsx is async; no adjacent loading.tsx, so the route blocks server-side on getNyxTonight() (5 s bffGet timeout)
  • Populated (BFF)/v1/nyx/tonight returns events; the accent one leads, the rest list chronologically with "Log it →" links
  • Lead-only — exactly one event (the accent lead): the card renders and rest is empty, showing "No further events on the timeline tonight."
  • Data unavailable — when bffGet returns null, shapeTonighttonightUnavailable(): observationWindow = 'Unavailable', tonightHeadline = "Tonight's sky data is unavailable right now — check back shortly.", and events = []. Deliberately no fabricated conjunctions: no lead card, empty-timeline row shows. (This is the honest empty/error state — astronomy claims must not be invented.)
  • Error (recoverable)bffGet swallows non-2xx/network and returns null → unavailable state above; no retry button surfaced
  • Error (unrecoverable) — no error.tsx adjacent; unexpected throw bubbles to a higher boundary (verify)
  • Offline — served from SW cache where present; "Log it →" links are plain hrefs to /nyx/observation
  • Gated — middleware signed-in; bffGet forwards session cookie
  • Standalone PWA — renders cleanly; verify safe-area insets on footer row

Interactions#

Lead event card#

  • Lead event card (section; non-interactive)
    • Function: displays the accent event's time, name, and detail; no link
    • Screen reader: announces "Lead event ·

Timeline list ("The order of the night")#

  • Event row (per rest entry; the row itself is not a link)
    • Function: shows mono time, serif name, italic detail
    • Screen reader: announces time, name, detail, then the row's "Log it" link
  • "Log it →" (link, one per row)
    • Function: → /nyx/observation?eventId=<encodeURIComponent(event.id)> to pre-anchor a field log to that event
    • Touch target: small mono uppercase link (fontSize 10) — verify ≥ 44×44 px hit area on coarse pointers (see gaps)
    • Mobile (≤ 640 px): whiteSpace: nowrap keeps it on one line; verify it doesn't overflow the auto column
  • "← Back to Nyx" (LBtn ghost) → /nyx
  • "Upcoming events" (LBtn ghost) → /nyx/events
  • "Plan an observation" (LBtn primary) → /nyx/observation (no eventId, so a freeform log)

Data & contracts#

  • Reads: getNyxTonight() (@/lib/server/nyx-depth) → bffGet<BffNyxTonight>('/v1/nyx/tonight'); shapeTonight normalizes to NyxTonightData (generatedAtIso, date, observationWindow, tonightHeadline, events: NyxTonightEvent[] where each event is id, time, name, detail, accent). Events missing any required string field are dropped by the filter.
  • Writes: none here (the "Log it →" link defers the write to /nyx/observation)
  • Realtime: none
  • Caching: bffGet next: { revalidate: 60 } (matches the page header's "60s revalidate")
  • Auth/role check: middleware signed-in; session cookie forwarded by bffGet

Cross-references#

Open questions / known gaps#

  • The two NyxTonight states (populated, unavailable) collapse the same way when events is empty: there is no separate retry affordance for a true BFF failure — confirm whether a recoverable-error state with retry is wanted
  • "Log it →" link is a 10 px mono link with no padded hit area — verify ≥ 44×44 px touch target on coarse pointers
  • No telemetry events on the lead card or "Log it →" links — confirm intended for V1
  • data.observationWindow and data.date are surfaced verbatim from the BFF; verify they reflect the caller's actual location/lat-lng, not a server default