---
path: /scene/[id]
surface: customer
domain: scene
auth: anon
source: apps/oshun/web/src/app/scene/[id]/page.tsx
status: walked
last_walked: >
  2026-06-28 focused automated addendum — default public viewer contract,
  no-reduced-motion media layer, materialized APNG source, visible mark, share
  snippet values, scene-URL clipboard success, clipboard-denied fallback, Report
  link, and download-grant form contract covered in
  scene-viewer-deepening.spec.ts; existing scene-password-and-grants.spec.ts and
  living-scene-card.spec.ts evidence reconciled against the previously unchecked
  password, tombstone, tenant-auth, reduced-motion, metadata, embed, grant, and
  takedown states below. 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; content re-verified
  2026-06-03 against current source
---

# Scene · Public Living Offering

## Purpose

Anonymous-accessible viewer for a shared "living offering" (a public
short-code-addressed scene). Resolves the share at request time via
`fetchPublicLivingScene(params.id, { unlockToken })`, where `unlockToken` comes
from `resolveUnlockToken(params.id)` — a read of the per-share HttpOnly cookie
set by the password gate (page.tsx ~15, 22, 57). It then renders one of four
terminal layouts based on `resolution.kind`: password challenge, tombstone,
tenant-auth required, or playable scene with accessibility panel and download
grant.

`/scene` is in `apps/oshun/web/src/proxy.ts` `PUBLIC_PREFIXES` (~line 59) —
anyone with the share code can open it.

## Entry points

- Direct URL via short code: `/scene/<id>`
- Email / SMS / social-share link with the URL
- QR code printed alongside a physical artifact
- `/scene/[id]/report` "Back to offering" link
- Redirect back from the `unlockSceneAction` server action after a successful or
  failed password submit (lands on `/scene/<id>` or `/scene/<id>?error=…`)
- Open-Graph / Twitter preview tap from a social card (uses the metadata block
  built in `generateMetadata`)

## Layout regions

`page.tsx` resolves `fetchPublicLivingScene(params.id, { unlockToken })` (token
from the per-share cookie) and branches on `resolution.kind`:

### kind === 'password-required'

Single `<main>` on dark teal background.

- **Eyebrow**: `Private Living Offering` (mono caps, teal)
- **Title**: `Password required` (5xl serif)
- **Error alert** (conditional, `role="alert"`): rendered when
  `searchParams.error` is `invalid-password` ("That password did not unlock this
  offering.") or `missing-password` ("Enter the password shared with you.")
- **Server-action form** (`<form action={unlockSceneAction}>`, imported from
  `./unlock-actions`): a hidden `<input name="shortCode" value={params.id}>`, a
  required `<input name="password" type="password" autoComplete="off">`, and an
  "Open offering" submit. There is no `method="get"` and no `?password=` in the
  URL — the action exchanges the password for a short-lived unlock token via the
  BFF and stores the token in an HttpOnly, path-scoped cookie, then redirects
  back to `/scene/<id>` (or `?error=…` on failure)

### kind === 'tombstone'

- **Eyebrow**: `Living Offering <shortCode>` (mono caps, slate)
- **Title**: `resolution.message` (typically "This offering has been retired"
  copy)
- **Sub-paragraph**: explains that share URL, preview card, embed, and download
  grants are no longer available

### kind === 'tenant-auth-required'

- **Eyebrow**: `Tenant Living Offering <shortCode>` (teal)
- **Title**: `Tenant access required` (5xl serif)
- **Body paragraph**: `resolution.message`
- **Tenant branding attribution**: `resolution.tenantBranding.attributionCopy`
  rendered as a smaller line when present

### kind === 'playable' (full viewer)

Linear-gradient near-black background, three rows (`grid-rows-[auto_1fr_auto]`).

- **Header**: tenant branding (logo or coloured dot) + attribution copy
  - "Open OSHUN" `<Link href="/">`
- **Media section** (left column on `lg:` breakpoint):
  - Motion layer: linear-gradient + cover-image background
  - Reduced-motion artifact: hidden by default; revealed by
    `@media (prefers-reduced-motion: reduce)`; shows "Reduced-motion carousel ·
    N segments"
  - Signed materialized media: an `<img>` pointing at the BFF
    `/v1/living-scenes/public/<shortCode>/media` endpoint. The current V1
    renderer ships APNG bytes; the page deliberately bypasses Next image
    optimisation so the browser receives the same bytes the C2PA media hash
    signed.
  - Visible-mark short code overlay (bottom-right,
    `[data-living-scene-visible-mark]`)
- **Aside (right column on `lg:`)**:
  - Eyebrow "Attested public view" in `accent` colour
  - Title (`resolution.openGraph.title`) + description
  - `<dl>` metadata grid: Privacy, Rendition, C2PA manifest id, Media hash
    (truncated to 24 chars), Sound ("User initiated")
  - Share settings panel (`[data-scene-share-settings]`) with copy controls for:
    - Scene URL (`/scene/<shortCode>`)
    - Literal iframe snippet:
      `<iframe src="/scene/<shortCode>/embed" width="960" height="540" loading="lazy" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
    - oEmbed endpoint (`/scene/<shortCode>/oembed`), which proxies the BFF embed
      response's `oEmbed` body
  - Accessibility panel (`[data-living-scene-accessibility-panel]`,
    `[data-living-scene-viewer-chrome]`) with data attributes:
    - `data-honors-reduced-motion`
    - `data-honors-high-contrast`
    - `data-honors-large-text`
    - `data-assistive-tech-navigable`
    - Caption track summary
    - Audio-description track summary
    - Transcript artifact (download link if available)
    - Keyboard cue buttons (one per
      `accessibility.viewerChrome.keyboardCueAccess`)
  - Action row: "Report" Link → `/scene/[id]/report` (uses accent background);
    Download-grant POST form to the BFF
    `/v1/living-scenes/public/<shortCode>/download-grants`
- **Footer**:
  - "Autoplay with sound is disabled."
  - `Robots: <robotsMeta>`

## States

- [x] **Password required** — no valid unlock-token cookie → password gate
- [x] **Password error** — `?error=invalid-password` or
      `?error=missing-password` (set by `unlockSceneAction`'s redirect) →
      password gate with the `role="alert"` message
- [x] **Password accepted (playable)** — the action issued an unlock-token
      cookie; the next request resolves to `playable` → playable layout
- [x] **Tombstone** — share is retired; tombstone copy + no further affordances
- [x] **Tenant-auth required** — tenant gating in effect; tenant attribution
      shown if branding exists
- [x] **Playable (default branding)** — no tenant branding; accent defaults to
      `#7dd3c7`; coloured dot in place of logo
- [x] **Playable (tenant branding)** — branding `logoUrl` and `attributionCopy`
      present; accent comes from branding
- [x] **Reduced motion** — `[data-living-scene-motion-layer]` hidden;
      `[data-living-scene-reduced-motion-artifact]` revealed (via
      `@media (prefers-reduced-motion: reduce)` in the inline style block)
- [x] **High contrast** — viewer chrome gets a 2px outline (via
      `@media (prefers-contrast: more)`)
- [ ] **High-DPI (2dppx)** — accessibility panel font scales to 1.05rem
- [ ] **No transcript / caption / audio description** — accessibility panel
      shows "unavailable"
- [ ] **No download grants** — form button still rendered (POST will respond
      with whatever the BFF returns)
- [x] **Indexable vs noindex** — `metadata.robots.{index, follow}` is
      `!resolution.noindex` for playable; non-playable kinds force `noindex`
- [ ] **Offline** — service-worker cache: verify; the page is anon and
      relatively cacheable, but the renderable is request-time
- [ ] **Standalone PWA** — verify behaviour when launched from PWA shell

## Interactions

### Password gate (kind === 'password-required')

- [x] **Password input** (input, type=password, name=password, required,
      autoComplete=off) + hidden `shortCode` input
  - Keyboard: standard
  - Touch target: ≥ 44px confirm
- [x] **"Open offering"** (submit button) — submits the
      `<form action={unlockSceneAction}>` server action; on success the action
      sets the unlock-token cookie and redirects to `/scene/<id>`, on failure to
      `/scene/<id>?error=invalid-password` (or `missing-password`). No password
      is ever placed in the URL.

### Tombstone / tenant-auth — no actions

### Playable header

- [ ] **Tenant logo / dot** (`aria-hidden`) — visual only
- [x] **"Open OSHUN"** (Link, `href="/"`) — navigates to home

### Playable media

- [x] **Materialized APNG image** — `<img aria-label={title} alt={title}>` loads
      `/v1/living-scenes/public/<shortCode>/media` via `buildOshunBffUrl`. The
      bytes should match the share's C2PA media hash and keep the visible-mark
      overlay in place.
- [x] **Visible-mark badge** (`[data-living-scene-visible-mark]`) —
      non-interactive

### Playable share settings

- [x] **Copy scene URL** — button with
      `data-scene-share-settings-copy="scene-url"` writes the absolute
      `/scene/<shortCode>` URL to the clipboard and announces `Scene URL copied`
      in `[data-scene-share-settings-status]`; clipboard-denied browsers show
      `Copy unavailable`
- [x] **Copy iframe snippet** — button with
      `data-scene-share-settings-copy="iframe"` writes the literal iframe HTML
      for `/scene/<shortCode>/embed` and announces `Iframe copied`
- [x] **Copy oEmbed endpoint** — button with
      `data-scene-share-settings-copy="oembed"` writes the absolute
      `/scene/<shortCode>/oembed` URL and announces `oEmbed copied`
- [x] **oEmbed route** — `GET /scene/<shortCode>/oembed` returns the BFF
      `oEmbed` JSON for allowed public/private-link shares; denied embeds return
      `{ reason: 'embed-unavailable' }` with the upstream status

### Playable accessibility panel

- [x] **Transcript download link** (anchor) — when present, points to
      `buildOshunBffUrl('/v1/living-scenes/public/<shortCode>/transcripts/<locale>')`
- [ ] **Keyboard cue buttons** (button, `type="button"`) — one per
      `accessibility.viewerChrome.keyboardCueAccess` entry; verify handler
      bindings

### Playable actions

- [x] **"Report" link** (Link) → `/scene/<id>/report`
- [x] **"Download grant" button** (submit inside
      `<form action={url} method="post">`) — POSTs to
      `/v1/living-scenes/public/<shortCode>/download-grants`

## Data & contracts

- **Reads**:
  - `resolveUnlockToken(params.id)` — reads the per-share unlock-token cookie
    (`sceneUnlockCookieName(shortCode)`, defined in `./unlock-actions.shared`)
  - `fetchPublicLivingScene(params.id, { unlockToken })` from
    `@/lib/living-scenes-public` — returns one of
    `password-required | tombstone | tenant-auth-required | playable`
- **Writes**:
  - Password gate: `unlockSceneAction` (server action, `./unlock-actions`) →
    `POST /v1/living-scenes/public/<shortCode>/unlock-token` via
    `exchangePasswordForSceneUnlockToken`; on success writes the HttpOnly
    unlock-token cookie
  - "Download grant" form POST →
    `/v1/living-scenes/public/<shortCode>/download-grants` (built via
    `buildOshunBffUrl`)
  - Share-settings copy buttons write to the browser clipboard only
  - oEmbed endpoint: `GET /scene/<shortCode>/oembed` proxies
    `/v1/living-scenes/public/<shortCode>/embed`
- **Realtime**: none from the page itself
- **Caching**: request-time fetch (server component without explicit cache
  directive); verify default Next.js fetch policy
- **Auth/role check**: anon (`/scene` is in `src/proxy.ts` `PUBLIC_PREFIXES`,
  ~line 59)
- **Metadata** (`generateMetadata`):
  - Playable: `title: resolution.openGraph.title` (bare, ~36), `description`,
    `robots: { index: !noindex, follow: !noindex }`, OpenGraph
    `type: 'video.other'` with image, Twitter `summary_large_image`
  - Non-playable kinds: `title: 'Living Offering Retired'` (bare, ~29),
    `robots: { index: false, follow: false }`
  - The `| OSHUN` suffix seen in the browser tab is **not** in this file — it is
    applied by the root layout's `metadata.title.template = '%s | OSHUN'`, which
    wraps whatever bare title these branches return

## Automation Evidence

- `apps/oshun/web/e2e/scene-viewer-deepening.spec.ts`
  - `an indexable public scene head carries OG, Twitter card, and indexable robots`
  - `under prefers-contrast:more the viewer still renders its branding and visible mark`
  - `default public viewer exposes motion media, actions, snippets, and scene URL copy`
  - `clipboard-denied browsers surface an explicit copy-unavailable status`
  - content-addressed share idempotency for lost-response retries
- `apps/oshun/web/e2e/scene-password-and-grants.spec.ts`
  - password-protected share creation, password gate, invalid/missing password
    alerts, correct unlock cookie, expired-token fallback, password embed
    break-out, download-grant redemption/media hash, report edge cases, rate
    limiting, and operator takedown cascade.
- `apps/oshun/web/e2e/living-scene-card.spec.ts`
  - noindex public attestation, tenant branding, reduced-motion swap, iframe and
    oEmbed copy, playable embed, retired tombstone, and tenant-auth-required
    branch.

## Cross-references

- Sibling scene routes:
  - [`scene-id-embed.md`](./scene-id-embed.md)
  - [`scene-id-report.md`](./scene-id-report.md)
- Public-path gating: `apps/oshun/web/src/proxy.ts` `PUBLIC_PREFIXES` (`/scene`
  ~line 59); also documented in
  [`shell/04-auth-session.md`](../../shell/04-auth-session.md)
- Component sources:
  - `apps/oshun/web/src/app/scene/[id]/page.tsx` (`PublicLivingScenePage`,
    `resolveUnlockToken`, `generateMetadata`)
  - `apps/oshun/web/src/app/scene/[id]/ShareSettingsPanel.tsx` (public URL,
    iframe snippet, and oEmbed copy controls)
  - `apps/oshun/web/src/app/scene/[id]/unlock-actions.ts` (`unlockSceneAction`
    server action) + `unlock-actions.shared.ts` (`sceneUnlockCookieName`)
  - `apps/oshun/web/src/lib/living-scenes-public.ts` (`fetchPublicLivingScene`,
    `exchangePasswordForSceneUnlockToken`)
  - `apps/oshun/web/src/lib/bff-endpoints.ts` (`buildOshunBffUrl`)

## Open questions / known gaps

- [ ] Verify that the four keyboard cue buttons have real handlers (currently
      `type="button"` with no `onClick`)
- [x] Resolved: the password gate is a POST server action (`unlockSceneAction`),
      not a GET form — the password is never reflected in the URL; it is
      exchanged for a short-lived unlock token stored in an HttpOnly cookie, and
      only `?error=…` is ever surfaced in the URL
- [ ] Confirm whether `fetchPublicLivingScene` is cacheable per share code (it's
      hit on every request, keyed by the unlock-token cookie)
- [ ] Document the `accessibility.viewerChrome.*` boolean meanings and whether
      failing checks affect rendering
- [ ] Confirm Open-Graph / Twitter image URLs return real assets for
      `openGraph.imageUrl` and `twitterCard.imageUrl`
