# Story: Shared Header (Auth + New Chat on Every Page, Sources Link Admin-Only) **Phase:** 19_shared_header · **E2E:** tests/e2e/test_shared_header.py ## Narrative As **a user**, the top bar should feel like **one shared component**: when I move to Sources or open a document, I should still see **Sign in** (or **Sign out**) and **New Chat** — and the **Sources** link should not offer me a page I can't use until I sign in. - **Given** I am on any page (chat, sources, or the document viewer) - **When** the bar renders - **Then** I always see the same controls — New Chat plus exactly one of Sign in (anonymous) / Sign out (admin) — and the "Sources" nav link is visible only to the admin. - **When** I am anonymous and I look at the nav - **Then** the "Sources" link is hidden on every page that has a nav — the soft-gated page is still reachable by direct URL (phase-16 gate intact), but nothing in the UI points me at a page I can't use. ## Acceptance criteria 1. Chat, Sources, and the document viewer each show **New Chat** + **Sign in** (anonymous) or **Sign out** (admin) in the header; the login page shows neither (it is the auth page, not an app page). 2. The **"Sources" nav link is hidden for anonymous users on every page that has a nav** (chat, sources, login), and visible for the admin (chat, sources — the viewer has no nav). 3. Anonymous **direct-URL access to `/sources.html`** still shows the phase-16 soft gate (link hidden, gate intact); the API split is unchanged (`/api/docs` → **403** for anonymous). 4. **New Chat on chat: in-place reset (unchanged).** New Chat on sources/viewer: clears the local conversation (`bor.chat.v1`) and navigates to the chat page (the empty state). 5. **Sign out works from any page** (logout + reload → anonymous state restored on that page). 6. The bar stays exactly **64px (desktop) / 58px (≤640px)** on all three pages in both auth states, with **no horizontal overflow at 360px** (phase-12 contract, phase-07 overflow guard). 7. **Exactly one `/api/whoami` request per page load** (shared cached fetch — every gate and toggle on the page shares the same promise). ## UI Visualization & Structure - **Shared module `frontend/assets/header.js`** (new, ES module — all pages already load JS as `type="module"`), loaded before each page script: - `fetchIsAdmin()` — one `GET /api/whoami`, cached in a module-level promise (`adminPromise`; anonymous-safe: non-2xx or network failure → `false`); the single whoami call site for the whole frontend, so a page makes exactly one request no matter how many gates reuse it. - `initSharedHeader()` — awaits `fetchIsAdmin()`, then toggles **only the elements that exist on the page** (missing → no-op, which is how the login page reuses the module without gaining chat controls): `#sign-in-link` / `#sign-out-btn` (exactly one visible — phase-16 semantics) and `#nav-sources` (shown for admin); returns the admin flag so callers reuse it. - `clearChatStorage()` — removes the `bor.chat.v1` key in a try/catch (same key + fail-silence contract as app.js's `clearStoredConversation`). - The `#sign-out-btn` binding lives here (POST `/api/logout`, disable during the call, `location.reload()`) — app.js deleted its copy, so there is exactly one implementation. - **Element ids** (existing patterns): `#nav-sources` ships **hidden-by-default** in the HTML (anonymous-safe — revealed once whoami says admin, never flashed for anonymous); `#sign-in-link` / `#sign-out-btn` follow the phase-16 pattern (exactly one visible, decided by whoami at load — both start hidden so the wrong state is never shown for a frame). - **Viewer bar:** a `.doc-header-actions` wrapper (New Chat + Sign in / Sign out) appended to the right of the title block — `margin-left: auto` flex row; the title block keeps its `min-width: 0` truncation (phase-12 "clip, don't wrap") while the two pills fit. The bar height and the back-link/title contract (phase 13) are untouched. - **Mobile:** the new controls reuse `.new-chat-btn` / `.auth-link`, so the phase-14/16 icon-only rules (labels hidden, 16px icon shown at ≤640px) apply automatically — the pills never grow the 58px bar and never overflow at 360px. - **Login page boundary:** `#nav-sources` (hidden) + `header.js` only — no chat controls are added to its markup, so none appear (it is the auth page, not an app page). - **Non-chat New Chat:** on sources.js / document.js, click → `clearChatStorage()` → `location.href = "/"` (a new chat means going to the chat, fresh); chat keeps app.js's `startNewChat` (in-place reset + focus + announce). ## Playwright Mapping Rule **Test Scenario → `tests/e2e/test_shared_header.py`** (DB seeded with the fixture docs for the viewer URL and the admin catalog; real form logins via `tests/e2e/auth_helpers.py::login`; the `assert_shared_bar(admin, page_kind)` helper waits for the settled whoami state — exactly one of Sign in / Sign out visible — before asserting the bar): 1. `test_anonymous_bar_on_all_pages` — fresh (anonymous) page: the shared bar on chat, sources (phase-16 `#sources-gate` still visible above it), and the viewer, in the anonymous state (desktop viewport). 2. `test_admin_bar_on_all_pages` — `login(page, app_url, next="/")`: the bar on all three pages in the signed-in state (Sign out + Sources link where a nav exists) — the login → `next` flow still lands right. 3. `test_sources_nav_hidden_for_anonymous_everywhere` — anonymous: `#nav-sources` hidden on chat, sources, and the login page; after a real login on the chat page, `#nav-sources` is visible (the toggle works, not just the initial state). 4. `test_new_chat_from_sources_clears_and_navigates` — a seeded `bor.chat.v1` conversation; on sources, `#new-chat-btn` → navigation to `/` with the empty state visible and the localStorage key removed. 5. `test_sign_out_from_viewer_returns_to_anonymous` — log in with `next=/sources.html`; open the viewer (admin bar); `#sign-out-btn` → after the reload the same page shows the anonymous bar. 6. `test_mobile_bar_fits_and_heights_held` — viewport 375×812, anonymous: 58px bars on all three pages with `documentElement.scrollWidth <= clientWidth` (no horizontal overflow; the pills are icon-only); repeat after login (Sign out + Sources link present) — the bar never grows. **Regression suites (adapted / re-run in isolation by this phase):** `test_header_consistency.py` (64/58px with the new pills on sources + viewer), `test_responsive_polish.py` (no 360px overflow), `test_admin_auth.py` (phase-16 flows with `#nav-sources` hidden for anonymous), `test_document_back_navigation.py` (viewer back-link/title with the actions wrapper), `test_chat_persistence.py` (chat New Chat in-place; the "New chat is chat-page-only" pin adapted — the shared bar now puts it on sources/viewer too, by owner permission).