---
path: /nisaba/notebooks
surface: customer
domain: nisaba
auth: signed-in
source: apps/oshun/web/src/app/nisaba/notebooks/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. 2026-06-29
  notebooks-index direct-route addendum — Playwright real-dev-infra coverage now
  seeds real BFF notebooks, proves the explicit BFF-empty state, forces the
  authenticated fixture fallback when the BFF refuses, verifies mobile and
  standalone containment, replays the cached document offline through the real
  service worker, asserts no telemetry hook attributes or dispatched analytics
  events on card/footer activations, and confirms anonymous
  redirect-before-render. Evidence:
  WALKTHROUGH/results/v1-real-infra-run-2026-06-22.md §217; spec:
  apps/oshun/web/e2e/nisaba-notebooks-index.spec.ts.
---

# Nisaba · notebooks (index)

## Purpose

The reading desk: every notebook the caller owns, each a thread of passages and
annotations. The page reads `/v1/nisaba/notebooks` and, when the authenticated
endpoint refuses (so the surface still renders for layout review), falls back to
a small fixture set. Open one to read or compose, or start a new one. See
[`V1/features.md#nisaba`](../../../V1/features.md#nisaba).

## Entry points

- **Nisaba hub (`/nisaba`)** — verify a "Notebooks" link from the hub sub-nav
- **`/nisaba/notebook/new` confirmation** — the "All notebooks" button → here
- **`/nisaba/notebook/new` form** — the "Back" button → `/nisaba/notebooks`
- **Direct URL / bookmark** — yes
  (`alternates.canonical = '/nisaba/notebooks'`); signed-in
- **Shell nav** — arrives under the `library` tab
  (`LCustomerNav active="library"`)

## Layout regions

`page.tsx` is async: `await bffGet<NotebooksResponse>('/v1/nisaba/notebooks')`,
then a remote array is normalized from the BFF's `notebookId` / `updatedAt` /
`status` contract into the route's card view model; a refused or malformed BFF
response falls back to `NOTEBOOK_FIXTURE`. Centered column, `maxWidth: 880`,
`data-responsive-page`, `data-nisaba-notebooks-page`.

- **Top chrome**: `LCustomerNav` (Library active) with "Lilith" wordmark + "⌕
  Search"
- **Masthead** (`LMasthead`): left `Nisaba · notebooks`, right
  `${notebooks.length} held`, kicker "The reading desk", title "The
  _notebooks_."
- **"How this opens" note**: "Notebooks are the long-form companions to your
  reading. Each one threads passages from primary texts with your own
  annotations."
- **Main**: `data-nisaba-notebooks-list`, a grid (`gap: 12`) of notebook rows
  (each a `Link` card) — or the empty-state card
- **Footer button row**: "← Back to Nisaba", "Scholarly read", "Start a
  notebook" (`data-nisaba-notebooks-actions`)

## States

- [ ] **Loading** — `page.tsx` is async; no adjacent `loading.tsx`, so the route
      blocks server-side on `bffGet` (5 s timeout)
- [x] **Populated (BFF)** — `/v1/nisaba/notebooks` returns `notebooks[]`; each
      renders as an "Open →" card with passage count, last-edited date, and
      notebook status. Verified against a real BFF-created notebook in
      `apps/oshun/web/e2e/nisaba-notebooks-index.spec.ts`.
- [x] **Populated (fixture fallback)** — when `bffGet` returns `null` or a
      non-array `notebooks`, the page shows `NOTEBOOK_FIXTURE`: "On the citadel"
      (18 passages, private, 1 day ago), "The night watch" (6 passages, private,
      9 days ago), "Quiet hour" (11 passages, unlisted, 32 days ago); masthead
      reads "3 held". Verified by signing in without an access-token cookie so
      the BFF read returns 401 before the route falls back.
- [x] **Empty** — only reachable when the BFF returns `{ notebooks: [] }` (an
      explicit empty array): renders a dashed-border card "No notebooks yet.
      Start one to thread your first passage." Note: a `null` BFF response does
      NOT reach empty — it falls back to the fixture instead. Verified against a
      fresh real BFF user with zero notebooks.
- [ ] **Populated (long)** — 50+ notebooks render as a flat vertical grid; no
      pagination or virtualization in code (verify scroll perf — see gaps)
- [x] **Error (recoverable BFF refusal)** — `bffGet` swallows non-2xx → `null` →
      fixture fallback; no retry surfaced (and the fixture masks the error).
      Verified for the signed-in/no-bearer 401 path.
- [ ] **Error (unrecoverable)** — no `error.tsx` adjacent; unexpected throw
      bubbles to a higher boundary (verify)
- [x] **Offline** — served from the real SW cache where present; the cached
      signed-in document replays the same BFF-backed card and does not show the
      generic offline fallback
- [x] **Gated** — middleware signed-in; anonymous users redirect to `/welcome`
      before the notebooks page renders. If the authenticated BFF refuses, the
      fixture renders for layout (no information leak — fixture data is generic)
- [x] **Standalone PWA** — renders cleanly at 390 px with standalone
      display-mode mocked and no horizontal overflow

## Interactions

### Notebook card

- [x] **Notebook card** (`Link`, one per notebook,
      `data-nisaba-notebook-card="<id>"`)
  - Function: → `/nisaba/notebook?id=<encodeURIComponent(id)>` to open in the
    reading desk; `id` is normalized from BFF `notebookId` first, then fixture
    `id`
  - Content: mono meta line "<N> passage(s) · last edited <localeDate> ·
    <status-or-fixture-visibility>" (singular/plural handled), serif `title`,
    accent "Open →" marker
  - Screen reader: announces the meta line + title + "Open"
  - Touch target: full-card click area (16–18 px padding) — verified ≥ 44 px
    height at 390 px
  - Mobile (≤ 640 px): `1fr auto` grid keeps title and "Open →" aligned; no
    horizontal overflow at 390 px in the focused spec
  - Telemetry: explicitly absent; no hook attributes and no
    `oshun-analytics:event` dispatch on activation

### Empty-state card

- [x] **"No notebooks yet…" card** (`data-nisaba-notebooks-empty`) —
      non-interactive text only; verify whether it should link to
      `/nisaba/notebook/new` (currently plain copy — see gaps)

### Footer buttons

- [x] **"← Back to Nisaba"** (`LBtn` ghost) → `/nisaba`
- [x] **"Scholarly read"** (`LBtn` ghost) → `/nisaba/scholar`
- [x] **"Start a notebook"** (`LBtn` primary) → `/nisaba/notebook/new`

All footer buttons are verified as ≥ 44 px tall at 390 px and explicitly
telemetry-silent: no hook attributes and no `oshun-analytics:event` dispatch
when click-guarded.

## E2E coverage

- [`apps/oshun/web/e2e/nisaba-notebooks-index.spec.ts`](../../../apps/oshun/web/e2e/nisaba-notebooks-index.spec.ts)
  — real BFF create/list readback into the server-rendered index, explicit
  BFF-empty state, authenticated fixture fallback on BFF refusal, card href/meta
  normalization from `notebookId` / `updatedAt` / `status`, mobile 44 px
  activation targets and no horizontal overflow, standalone display-mode launch,
  real service-worker cached-document offline replay, explicit absence of card
  and footer telemetry hooks/click-dispatched analytics events, and anonymous
  redirect-before-render.

## Data & contracts

- **Reads**: `bffGet<NotebooksResponse>('/v1/nisaba/notebooks')`. Live BFF
  notebooks are serialized as
  `{ notebookId, title, summary, tags, status, itemCount, passageCount, annotationCount, evidenceViewCount, compareItemCount, updatedAt, launch }`.
  The page normalizes `notebookId ?? id` into the href id,
  `updatedAt ?? updatedAtIso` into the last-edited date, and
  `status ?? visibility ?? 'active'` into the meta label. Local
  `NOTEBOOK_FIXTURE` is the fallback and still uses generic private/unlisted
  labels for layout review.
- **Writes**: none on this view (creation happens at `/nisaba/notebook/new`)
- **Realtime**: none
- **Caching**: `bffGet` `next: { revalidate: 60 }` (60 s ISR)
- **Auth/role check**: middleware signed-in; `bffGet` forwards the
  `oshun-access` cookie as `Authorization: Bearer` and session cookies as
  `cookie`; refusal → fixture for layout review
- **Privacy**: status or fixture visibility shown per row; `metadata` here does
  NOT set `robots: noindex` (unlike `/nisaba/notebook`) — verify whether the
  index should be search-excluded (see gaps)

## Cross-references

- Feature spec: [`V1/features.md#nisaba`](../../../V1/features.md#nisaba)
- Architecture:
  [`V1/ARCHITECTURE.md#nisaba`](../../../V1/ARCHITECTURE.md#nisaba)
- Sibling Nisaba routes: [`nisaba.md`](./nisaba.md),
  [`nisaba-notebook.md`](./nisaba-notebook.md),
  [`nisaba-notebook-new.md`](./nisaba-notebook-new.md),
  [`nisaba-scholar.md`](./nisaba-scholar.md)
- Journeys:
  [`journeys/nisaba-notebook-capture-and-cite.md`](../../journeys/nisaba-notebook-capture-and-cite.md)
  (this index → open/create a notebook)
- Component / data sources:
  - `apps/oshun/web/src/lib/server/bff-fetch.ts` (`bffGet`)

## Open questions / known gaps

- [ ] The fixture fallback fires on any `bffGet` failure (including a real
      outage), so an authenticated user could be shown three sample notebooks
      that are not theirs — decide whether a refusal should render empty/error
      rather than generic fixtures
- [x] The empty state is only reachable via an explicit `{ notebooks: [] }`; the
      focused real-BFF spec confirms a fresh user with zero notebooks returns an
      empty array and renders the dashed empty-state card
- [ ] Empty-state copy is not a link to `/nisaba/notebook/new` — verify the
      productive next-action affordance
- [ ] No pagination/virtualization for long lists — verify acceptable for V1
      notebook counts
- [ ] No `robots: noindex` on the notebooks index (the individual notebook sets
      it); confirm whether the listing of titles/visibilities should be
      search-excluded
- [x] No telemetry on card opens or footer buttons — current V1 behavior is
      explicitly no hook attributes and no `oshun-analytics:event` dispatch on
      static card/footer activation
