Remove the blanket .agent/ gitignore so the phase roadmap, user stories, reports, and PLAN.md are versioned with the code. Only runtime artifacts (.agent/phase-sessions/, .agent/pipeline.log) remain ignored. Update AGENTS.md git protocol rule to match.
122 lines
6.9 KiB
Markdown
122 lines
6.9 KiB
Markdown
# 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).
|