---
path: /atelier/camera-obscura
surface: customer
domain: atelier
auth: signed-in
source: apps/oshun/web/src/app/atelier/camera-obscura/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'
---

# Atelier · Camera obscura (render queue)

## Purpose

The camera-obscura room's job queue — the render pipeline that composites
footage with a light box (composite / denoise / stabilise). The page reads the
whole atelier job feed and shows only the jobs whose
`room === 'camera-obscura'`, each with its run state, start time, and ETA.
Unlike the static [`atelier-image.md`](./atelier-image.md) specimen view, this
is a live, data-backed list server-rendered from `/v1/atelier/jobs`.

## Entry points

- **Atelier index** (`/atelier`) — verify a room/queue card links here
- **`/atelier/new` success card** — after a scene is created, the composer/queue
  is where work surfaces (verify the queue link is reachable from the flow)
- **"← Back to Atelier"** within this page returns to `/atelier`
- **"Start a new scene"** within this page goes to `/atelier/new`
- **Direct URL / bookmark** — yes (signed-in)

## Layout regions

`page.tsx` is an async server component: `await bffGet('/v1/atelier/jobs')` →
filter to `camera-obscura` → render inside `LWebShell` with
`LCustomerNav active="library"`. Max width 880, centered.

- **Header**: Lilith customer nav (Library tab active)
- **Masthead** (`LMasthead`): left "Atelier · camera obscura", right job count
  (`"N job"` / `"N jobs"`), kicker "The render pipeline", title "_Camera_
  obscura."
- **Job list** (grid, gap 12):
  - **Empty row** (dashed border, italic serif): "No camera-obscura jobs in
    flight." when the filtered list is empty
  - **Job card** per job (`1fr auto` grid): left side has a mono uppercase
    status line — `<state> · started <localeTime> · eta <N>m | no eta` (color
    per `STATE_COLOUR`: queued=muted, running=accent, done=green `#1f4f3a`,
    failed=red `#a30f0f`) — and the serif `job.label`; right side shows the mono
    `job.id`. Running jobs get an accent border, others a rule border.
- **Action row** (flex, wraps): ghost "← Back to Atelier" (→ `/atelier`),
  primary "Start a new scene" (→ `/atelier/new`)

## States

- [ ] **Loading** — server component awaits `bffGet`; no client skeleton. Verify
      whether a `loading.tsx` exists for this segment
- [ ] **Empty** — `bffGet` returns null/no camera-obscura jobs → "No
      camera-obscura jobs in flight." dashed row; masthead reads "0 jobs"
- [ ] **Populated (short)** — 1–5 camera-obscura jobs render as cards
- [ ] **Populated (long)** — 50+ jobs; list is a plain grid (no
      pagination/virtualization in code) — verify scroll behaviour
- [ ] **Job running** — accent border + accent status word
- [ ] **Job done** — green status word `done`
- [ ] **Job failed** — red status word `failed`; verify there is no retry/detail
      affordance (none coded)
- [ ] **Error (recoverable)** — `bffGet` returns `null` on non-2xx/network fail
      (helper swallows errors), so this collapses into the Empty state — no
      distinct error UI; verify whether that is the intended behaviour
- [ ] **Offline** — `bffGet` aborts after 5s and returns null → Empty state;
      SW-cached HTML may show last render — verify
- [ ] **Standalone PWA** — `data-responsive-page` wrapper; verify safe-area and
      narrow-width rendering of the `1fr auto` job cards
- [ ] **Gated** — N/A in code beyond signed-in (no role gate on `/atelier/*`)

## Interactions

The job cards themselves are **non-interactive** (no link/handler in code) — the
only interactive elements are the two footer buttons.

- [ ] **Job card** (`<div>`) — non-interactive display only
  - Function: shows state, start time, ETA, label, id; no click target
  - Screen reader: verify the mono status line + label read coherently (no
    explicit list semantics — cards are sibling `<div>`s, not a `<ul>`/`<ol>`)
  - Telemetry: none coded
- [ ] **"← Back to Atelier"** (`LBtn kind="ghost"`, `href="/atelier"`)
  - Function: navigates to `/atelier`
  - Keyboard: Enter activates; in tab order
  - Touch target: verify `LBtn size="md"` meets ≥ 44×44 px
- [ ] **"Start a new scene"** (`LBtn kind="primary"`, `href="/atelier/new"`)
  - Function: navigates to [`/atelier/new`](./atelier-new.md)
  - Keyboard: Enter activates
  - Mobile (≤ 640 px): action row `flexWrap: 'wrap'` allows buttons to stack

## Data & contracts

- **Reads**: `bffGet<JobsResponse>('/v1/atelier/jobs')` from
  `@/lib/server/bff-fetch`; returns `{ generatedAt, jobs: Job[] }` where
  `Job = { id, room: 'camera-obscura'|'scene-composer'|'mastering', state: 'queued'|'running'|'done'|'failed', label, startedAtIso, etaSeconds }`.
  Filtered client-of-server to `room === 'camera-obscura'`
- **Writes**: none in this view
- **Realtime**: none — running jobs do not poll/subscribe in code (ETA is static
  at render time); verify whether a refresh mechanism is planned
- **Caching**: `bffGet` default `next.revalidate = 60` s; session cookie
  (`oshun-session` / `__session`) forwarded for caller resolution
- **Auth/role check**: proxy enforces signed-in (`/atelier/*` not in
  `PUBLIC_PATHS`); no extra role gate

## Cross-references

- Atelier index: [`atelier.md`](./atelier.md)
- New scene: [`atelier-new.md`](./atelier-new.md)
- Specimen/static image room: [`atelier-image.md`](./atelier-image.md)
- Sibling rooms: [`atelier-audio.md`](./atelier-audio.md),
  [`atelier-video.md`](./atelier-video.md),
  [`atelier-forms.md`](./atelier-forms.md),
  [`atelier-motion.md`](./atelier-motion.md)
- Journey:
  [`../../journeys/atelier-image-to-library.md`](../../journeys/atelier-image-to-library.md)
  (the queue is where in-flight renders surface)
- Feature spec: [`V1/features.md`](../../../V1/features.md#atelier)
- Architecture: [`V1/ARCHITECTURE.md`](../../../V1/ARCHITECTURE.md#atelier)

## Open questions / known gaps

- [ ] No realtime/poll: running jobs and ETAs freeze at SSR time. Confirm
      whether a 60s revalidate (the `bffGet` default) is the intended refresh
      cadence or whether a WebSocket/SSE subscription is planned
- [ ] Job cards have no detail link — a failed job offers no way to inspect or
      retry. Confirm where job detail/diagnostics live
- [ ] `bffGet` returning `null` flattens both "no jobs" and "BFF down" into the
      same Empty row — no distinct error affordance. Verify intended UX
- [ ] Cards are bare `<div>`s, not list items — confirm assistive-tech list
      semantics are acceptable
- [ ] Nav uses `active="library"` while sibling `/lilith-studio/scene/new` uses
      `active="explore"` — confirm the canonical tab for Atelier surfaces
