V1 Web PWA · Surface walkthrough

Nisaba · new notebook

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

walked + e2e-covered
9sections5 minread

On this page

Context. surface customer · domain nisaba · route /nisaba/notebook/new · auth signed-in · source apps/oshun/web/src/app/nisaba/notebook/new/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. 2026-06-30 notebook-create addendum — Playwright real-dev-infra coverage now exercises the authenticated browser create form against the live BFF, verifies CSRF header attachment, BFF response/readback visibility contract, radio keyboard semantics, mobile containment, and text-safe submit/success telemetry. Evidence: WALKTHROUGH/results/v1-real-infra-run-2026-06-22.md; spec: apps/oshun/web/e2e/nisaba-notebook-new.spec.ts.

Purpose#

Start a reading notebook: give it a name, a visibility, and an optional first passage. "A notebook is a quiet thread of passages and your annotations on them. Private by default; share with a link only when you want a reader." The page is a server shell hosting the client form NisabaNewNotebookForm, which POSTs to /v1/nisaba/notebooks. See V1/features.md#nisaba.

Entry points#

  • /nisaba/notebooks footer — "Start a notebook" primary button → here
  • /nisaba/notebooks empty state — verify the empty-notebooks card's "Start one to thread your first passage" copy links here (currently plain text — see gaps)
  • Direct URL / bookmark — yes (alternates.canonical = '/nisaba/notebook/new'); signed-in
  • Shell nav — arrives under the library tab (LCustomerNav active="library")

Layout regions#

page.tsx is a synchronous server component rendering the shell + form. Centered column, maxWidth: 640, data-responsive-page.

  • Top chrome: LCustomerNav (Today / Explore / Activity / Library; Library active) with "Lilith" wordmark + "⌕ Search"
  • Masthead (LMasthead): left Nisaba · notebook, right new, kicker "A reading thread, started", title "Begin a notebook."
  • "How this opens" note: "A notebook is a quiet thread of passages and your annotations on them. Private by default; share with a link only when you want a reader."
  • Main: <NisabaNewNotebookForm /> — the create form, or after success the "Created" confirmation card

States#

  • Default / empty form — title empty, visibility defaulting to "Private", first-passage textarea empty
  • Validating — title input is required, minLength={2}, maxLength={120}; the submit handler trims title and blocks whitespace or short values before the BFF write
  • Submittingpending true: submit button reads "Creating…", opacity 0.6, cursor: progress, disabled
  • Created — POST 2xx returns the live BFF notebook mutation envelope; form normalizes notebook.notebookId, title, summary, and visibility, then swaps to a role="status" aria-live="polite" card titled "Created" showing "“” — ready for its first passage." and the mono notebook id</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Error (recoverable) — non-2xx renders a role="alert" box: "Nisaba could not create the notebook ()."; entered title/passage preserved; unit coverage asserts the failure telemetry path" checked=""/> <strong>Error (recoverable)</strong> — non-2xx renders a <code>role="alert"</code> box: "Nisaba could not create the notebook (<status>)."; entered title/passage preserved; unit coverage asserts the failure telemetry path</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Offline — catch renders "Nisaba is offline. Try again in a moment." in the alert box; no write queueing" checked=""/> <strong>Offline</strong> — <code>catch</code> renders "Nisaba is offline. Try again in a moment." in the alert box; no write queueing</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Gated — middleware signed-in; POST sends credentials: include, bearer auth from tryGetApiAuthToken(), and csrfHeaders() into the create request" checked=""/> <strong>Gated</strong> — middleware signed-in; POST sends <code>credentials: include</code>, bearer auth from <code>tryGetApiAuthToken()</code>, and <code>csrfHeaders()</code> into the create request</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Standalone PWA — route-specific Playwright coverage launches the page at a 390 px standalone viewport, checks the Library shell context, form visibility, radio group semantics, Back link, no horizontal overflow, and 44 px controls" checked=""/> <strong>Standalone PWA</strong> — route-specific Playwright coverage launches the page at a 390 px standalone viewport, checks the Library shell context, form visibility, radio group semantics, Back link, no horizontal overflow, and 44 px controls</li> </ul> <h2 id="interactions" class="hd hd-l2">Interactions<a class="hd-anchor" href="#interactions" aria-label="Permalink to this section">#</a></h2> <h3 id="form-fields" class="hd hd-l3">Form fields<a class="hd-anchor" href="#form-fields" aria-label="Permalink to this section">#</a></h3> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: "The notebook, named" (text input #nb-title)" checked=""/> <strong>"The notebook, named"</strong> (text input <code>#nb-title</code>) <ul> <li>Function: sets <code>title</code>; placeholder "e.g. On the citadel"</li> <li>Validation: <code>required</code>, <code>minLength 2</code>, <code>maxLength 120</code></li> <li>Screen reader: label "The notebook, named" via <code>htmlFor</code></li> </ul> </li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Visibility radio buttons (3 cell buttons inside role="radiogroup" aria-labelledby="nb-visibility-label")" checked=""/> <strong>Visibility radio buttons</strong> (3 cell buttons inside <code>role="radiogroup"</code> <code>aria-labelledby="nb-visibility-label"</code>) <ul> <li>Options: <strong>Private</strong> ("only you can read"), <strong>Unlisted</strong> ("link-only, no listing"), <strong>Public</strong> ("in the open library")</li> <li>Function: each <code>type="button" role="radio"</code> sets <code>visibility</code>; selected gets <code>aria-checked="true"</code>, accent border, italic label, and <code>tabIndex=0</code></li> <li>Keyboard: ArrowRight/ArrowDown advance, ArrowLeft/ArrowUp reverse, Home/End jump to edges with roving focus</li> <li>Mobile: <code>repeat(auto-fit, minmax(92px, 1fr))</code> grid avoids horizontal overflow in the 390 px Playwright pass</li> </ul> </li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: "First passage (optional)" (textarea #nb-seed)" checked=""/> <strong>"First passage (optional)"</strong> (textarea <code>#nb-seed</code>) <ul> <li>Function: sets <code>seedPassage</code>; <code>rows={4}</code>, <code>maxLength 1200</code>; placeholder "A line, a paragraph, a footnote — whatever the notebook begins with."</li> </ul> </li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: "Back" (LBtn ghost, lg) → /nisaba/notebooks (cancel path)" checked=""/> <strong>"Back"</strong> (<code>LBtn</code> ghost, lg) → <code>/nisaba/notebooks</code> (cancel path)</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: "Start the notebook" / "Creating…" (submit button)" checked=""/> <strong>"Start the notebook" / "Creating…"</strong> (submit button) <ul> <li>Function: POSTs <code>{ title, summary, visibility, tags: [] }</code> to <code>/v1/nisaba/notebooks</code>; <code>summary</code> is the trimmed first passage, or the honest fallback "Notebook created from the new notebook form."</li> <li>Disabled when: <code>pending</code> → opacity 0.6, cursor progress</li> </ul> </li> </ul> <h3 id="confirmation-card-after-create" class="hd hd-l3">Confirmation card (after create)<a class="hd-anchor" href="#confirmation-card-after-create" aria-label="Permalink to this section">#</a></h3> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: "All notebooks" (LBtn ghost) → /nisaba/notebooks" checked=""/> <strong>"All notebooks"</strong> (<code>LBtn</code> ghost) → <code>/nisaba/notebooks</code></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: "Open it" (LBtn primary) → /nisaba/notebook?id=<encodeURIComponent(notebookId)> — opens the new notebook in the reading desk" checked=""/> <strong>"Open it"</strong> (<code>LBtn</code> primary) → <code>/nisaba/notebook?id=<encodeURIComponent(notebookId)></code> — opens the new notebook in the reading desk</li> </ul> <h2 id="data--contracts" class="hd hd-l2">Data & contracts<a class="hd-anchor" href="#data--contracts" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Reads</strong>: none (pure create form)</li> <li><strong>Writes</strong>: <code>POST /v1/nisaba/notebooks</code> via <code>buildOshunBffUrl</code>, <code>credentials: 'include'</code>, headers <code>content-type: application/json</code>, bearer auth from <code>tryGetApiAuthToken()</code>, plus <code>csrfHeaders()</code>. Body: <code>{ title, summary, visibility, tags: [] }</code>. Response on success: live BFF mutation envelope <code>{ generatedAt, userId, revision, notebook: { notebookId, title, summary, visibility, ... } }</code>.</li> <li><strong>Realtime</strong>: none</li> <li><strong>Caching</strong>: none (write-only client interaction)</li> <li><strong>Auth/role check</strong>: middleware signed-in</li> <li><strong>Privacy</strong>: <code>visibility</code> defaults to <code>private</code>; "unlisted" is link-only, "public" lists in the open library</li> <li><strong>Telemetry</strong>: dispatches <code>nisaba_notebook_create_submitted</code>, <code>nisaba_notebook_created</code>, and <code>nisaba_notebook_create_failed</code> with lengths, visibility, success/failure metadata, and no title or passage text</li> </ul> <h2 id="e2e-coverage" class="hd hd-l2">E2E coverage<a class="hd-anchor" href="#e2e-coverage" aria-label="Permalink to this section">#</a></h2> <p>The Playwright route spec is the authoritative browser proof for this walkthrough: it runs against the dev Next/BFF stack, verifies the authenticated browser create form sends bearer auth and CSRF headers, reads the created notebook back from the live BFF, proves radio keyboard semantics and text-safe telemetry, and now covers the route-specific standalone mobile containment and touch-target contract. Component Vitest coverage backs the same create/error contracts at the form layer.</p> <ul> <li><code>apps/oshun/web/e2e/nisaba-notebook-new.spec.ts</code> — authenticated notebook create form against the live BFF, CSRF and authorization header attachment, mutation response/readback visibility contract, roving radio keyboard behavior, success links, no horizontal overflow, text-safe submit/success telemetry, and standalone mobile launch checks.</li> <li><code>apps/oshun/web/src/app/nisaba/notebook/new/NisabaNewNotebookForm.test.tsx</code> — form-level radio semantics, create payload/CSRF/auth headers, success telemetry, HTTP error state, and failure telemetry.</li> </ul> <h2 id="cross-references" class="hd hd-l2">Cross-references<a class="hd-anchor" href="#cross-references" aria-label="Permalink to this section">#</a></h2> <ul> <li>Feature spec: <a href="../../../V1/features.html#nisaba"><code>V1/features.md#nisaba</code></a></li> <li>Architecture: <a href="../../../V1/ARCHITECTURE.html#nisaba"><code>V1/ARCHITECTURE.md#nisaba</code></a></li> <li>Sibling Nisaba routes: <a href="./nisaba.html"><code>nisaba.md</code></a>, <a href="./nisaba-notebook.html"><code>nisaba-notebook.md</code></a>, <a href="./nisaba-notebooks.html"><code>nisaba-notebooks.md</code></a>, <a href="./nisaba-scholar.html"><code>nisaba-scholar.md</code></a></li> <li>Journeys: <a href="../../journeys/nisaba-notebook-capture-and-cite.html"><code>journeys/nisaba-notebook-capture-and-cite.md</code></a> (creating a notebook is the first leg)</li> <li>Component sources: <ul> <li><code>apps/oshun/web/src/app/nisaba/notebook/new/NisabaNewNotebookForm.tsx</code></li> </ul> </li> </ul> <h2 id="known-downstream-boundaries" class="hd hd-l2">Known downstream boundaries<a class="hd-anchor" href="#known-downstream-boundaries" aria-label="Permalink to this section">#</a></h2> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: This form now attaches bearer auth and csrfHeaders() to the BFF create request; the browser spec verifies the authorization and x-csrf-token headers on the real POST" checked=""/> This form now attaches bearer auth and <code>csrfHeaders()</code> to the BFF create request; the browser spec verifies the <code>authorization</code> and <code>x-csrf-token</code> headers on the real POST</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Visibility control now uses role="radio", aria-checked, and roving focus; unit and browser coverage verify ArrowRight behavior" checked=""/> Visibility control now uses <code>role="radio"</code>, <code>aria-checked</code>, and roving focus; unit and browser coverage verify ArrowRight behavior</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Submit / create / error telemetry now exists and excludes raw title or passage text" checked=""/> Submit / create / error telemetry now exists and excludes raw title or passage text</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: On the notebooks index, the empty-state copy ("Start one to thread your first passage") is not itself a link to this route — verify the intended affordance"/> On the notebooks index, the empty-state copy ("Start one to thread your first passage") is not itself a link to this route — verify the intended affordance</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: The form normalizes the real BFF mutation envelope and the browser spec reads the created notebook back by notebook.notebookId, proving the BFF returns a usable id" checked=""/> The form normalizes the real BFF mutation envelope and the browser spec reads the created notebook back by <code>notebook.notebookId</code>, proving the BFF returns a usable id</li> </ul> </article> <footer class="doc-foot"> <span>V1 Web PWA · Surface walkthrough</span> <span><a href="../../../vdocs-index.html">Documentation portal</a> · <a href="nisaba-notebook-new.md">Source</a></span> </footer> </main> <aside class="toc-rail" aria-label="On this page"> <div class="toc-head">On this page<span class="toc-count"></span></div> <div class="tocfilter-wrap"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"> <circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg> <input id="tocFilter" type="text" placeholder="Filter sections" autocomplete="off" aria-label="Filter sections"/><!-- stub:legitimate: standard HTML placeholder attribute --> </div> <nav><ul class="toc"><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#purpose" class="toc-link">Purpose</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#entry-points" class="toc-link">Entry points</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#layout-regions" class="toc-link">Layout regions</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#states" class="toc-link">States</a></div></li><li class="toc-h2 has-children collapsed"><div class="toc-row"><button class="toc-toggle" type="button" aria-label="Toggle section"></button><a href="#interactions" class="toc-link">Interactions</a></div><ul class="toc-sub"><li><a href="#form-fields" class="toc-link toc-sub-link">Form fields</a></li><li><a href="#confirmation-card-after-create" class="toc-link toc-sub-link">Confirmation card (after create)</a></li></ul></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#data--contracts" class="toc-link">Data & contracts</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#e2e-coverage" class="toc-link">E2E coverage</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#cross-references" class="toc-link">Cross-references</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#known-downstream-boundaries" class="toc-link">Known downstream boundaries</a></div></li></ul></nav> </aside> </div> </div> </div> <button id="backtop" aria-label="Back to top" aria-hidden="true" tabindex="-1"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" aria-hidden="true"> <path d="M12 19V5M5 12l7-7 7 7"/></svg> </button> <div class="cmdk" id="cmdk" aria-hidden="true"> <div class="cmdk-backdrop" data-cmdk-close></div> <div class="cmdk-panel" role="dialog" aria-modal="true" aria-label="Search documentation"> <div class="cmdk-inputwrap"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"> <circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg> <input id="cmdkInput" type="text" autocomplete="off" spellcheck="false" role="combobox" aria-expanded="false" aria-controls="cmdkResults" aria-autocomplete="list" aria-haspopup="listbox" placeholder="Search the documentation center…" aria-label="Search query"/><!-- stub:legitimate: standard HTML placeholder attribute --> <kbd>esc</kbd> </div> <div class="cmdk-facets" id="cmdkFacets" role="group" aria-label="Filter results by type, audience, layer, and version"></div> <div class="cmdk-results" id="cmdkResults" role="listbox"></div> <div class="cmdk-foot"> <span><kbd>↑</kbd><kbd>↓</kbd> navigate</span> <span><kbd>↵</kbd> open</span> <span><kbd>esc</kbd> close</span> <span class="cmdk-foot-sp"></span> <span id="cmdkCount"></span> </div> </div> </div> <div class="kmodal" id="kmodal" role="dialog" aria-modal="true" aria-label="Keyboard shortcuts" aria-hidden="true"> <div class="kmodal-backdrop"></div> <div class="kmodal-panel"> <div class="kmodal-head"> <b>Keyboard shortcuts</b> <button class="icon-btn" id="kmClose" aria-label="Close keyboard shortcuts"> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"> <path d="M18 6L6 18M6 6l12 12"/></svg> </button> </div> <dl class="kmodal-list"> <div class="kmodal-row"><dt>Search all docs</dt><dd><kbd>/</kbd><kbd>⌘K</kbd></dd></div> <div class="kmodal-row"><dt>Move through results</dt><dd><kbd>↑</kbd><kbd>↓</kbd> then <kbd>Enter</kbd></dd></div> <div class="kmodal-row"><dt>Toggle left navigation</dt><dd><kbd>[</kbd></dd></div> <div class="kmodal-row"><dt>Toggle on-page contents</dt><dd><kbd>]</kbd></dd></div> <div class="kmodal-row"><dt>Close overlays</dt><dd><kbd>Esc</kbd></dd></div> <div class="kmodal-row"><dt>This panel</dt><dd><kbd>?</kbd></dd></div> </dl> </div> </div> <div class="mmodal" id="mmodal" role="dialog" aria-modal="true" aria-label="Expanded diagram viewer" aria-hidden="true"> <div class="mmodal-bar"> <b>Diagram — scroll to zoom, drag to pan</b> <div class="mmodal-tools"> <button id="mmZoomOut" title="Zoom out" aria-label="Zoom out">−</button> <button id="mmReset" title="Reset" aria-label="Reset zoom" style="font-size:13px;">1:1</button> <button id="mmZoomIn" title="Zoom in" aria-label="Zoom in">+</button> <button id="mmClose" title="Close" aria-label="Close diagram">×</button> </div> </div> <div id="mstage"></div> <div class="mmodal-hint">Press Esc to close</div> </div> <script src="../../../docs-center/assets/reader.js"></script> </body> </html>