V1 Web PWA · Surface walkthrough

Scene · Public Living Offering

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

walked
9sections8 minread

On this page

Context. surface customer · domain scene · route /scene/[id] · auth anon · source apps/oshun/web/src/app/scene/[id]/page.tsx

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

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#

  • Password required — no valid unlock-token cookie → password gate
  • Password error?error=invalid-password or ?error=missing-password (set by unlockSceneAction's redirect) → password gate with the role="alert" message
  • Password accepted (playable) — the action issued an unlock-token cookie; the next request resolves to playable → playable layout
  • Tombstone — share is retired; tombstone copy + no further affordances
  • Tenant-auth required — tenant gating in effect; tenant attribution shown if branding exists
  • Playable (default branding) — no tenant branding; accent defaults to #7dd3c7; coloured dot in place of logo
  • Playable (tenant branding) — branding logoUrl and attributionCopy present; accent comes from branding
  • 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)
  • 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)
  • Indexable vs noindexmetadata.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')#

  • Password input (input, type=password, name=password, required, autoComplete=off) + hidden shortCode input
    • Keyboard: standard
    • Touch target: ≥ 44px confirm
  • "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
  • "Open OSHUN" (Link, href="/") — navigates to home

Playable media#

  • 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.
  • Visible-mark badge ([data-living-scene-visible-mark]) — non-interactive

Playable share settings#

  • 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
  • Copy iframe snippet — button with data-scene-share-settings-copy="iframe" writes the literal iframe HTML for /scene/<shortCode>/embed and announces Iframe copied
  • Copy oEmbed endpoint — button with data-scene-share-settings-copy="oembed" writes the absolute /scene/<shortCode>/oembed URL and announces oEmbed copied
  • oEmbed routeGET /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#

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

  • "Report" link (Link) → /scene/<id>/report
  • "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:
  • Public-path gating: apps/oshun/web/src/proxy.ts PUBLIC_PREFIXES (/scene ~line 59); also documented in 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)
  • 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