feat(ui): shared header — Sign in/Sign out and New Chat on every page; hide the Sources nav link from anonymous users

This commit is contained in:
2026-08-24 12:32:45 -04:00
parent fd7f02ce68
commit 2afc77ee56
14 changed files with 904 additions and 59 deletions
+22 -26
View File
@@ -63,6 +63,8 @@
* All DOM ids match frontend/index.html. * All DOM ids match frontend/index.html.
*/ */
import { fetchIsAdmin, initSharedHeader } from "/assets/header.js";
const messagesEl = document.querySelector("#messages"); const messagesEl = document.querySelector("#messages");
const emptyState = document.querySelector("#empty-state"); const emptyState = document.querySelector("#empty-state");
const suggestionsEl = document.querySelector("#suggestions"); const suggestionsEl = document.querySelector("#suggestions");
@@ -756,9 +758,19 @@ function rememberBrainTurn(rawText, meta) {
* tuning surface at all — the Tuning toggle + panel are removed from the * tuning surface at all — the Tuning toggle + panel are removed from the
* DOM (the story says "absent", not just hidden), /api/steering is never * DOM (the story says "absent", not just hidden), /api/steering is never
* fetched, and appendTuneButton injects nothing (new or restored * fetched, and appendTuneButton injects nothing (new or restored
* messages). Admin → Sign out (POST /api/logout + reload) + the full * messages). Admin → Sign out + the full phase-15 UI. Whoami is awaited
* phase-15 UI. Whoami is awaited BEFORE the phase-14 restore, so restored * BEFORE the phase-14 restore, so restored brain bubbles never flash a
* brain bubbles never flash a Tune button that should not be there. * Tune button that should not be there.
*
* Phase 19: the whoami fetch, the Sign in / Sign out / Sources-nav
* toggling, and the #sign-out-btn click binding (POST /api/logout +
* reload) all moved to the shared header module (assets/header.js) —
* initSharedHeader() does the header toggling on every page, and
* fetchIsAdmin() is the single cached whoami, so this page still makes
* exactly one request per load. applyAuthState keeps only the
* chat-page-specific work (removing the tuning surface for anonymous
* visitors) — idempotent alongside the header module's own link/button
* toggling.
*/ */
const signInLink = document.querySelector("#sign-in-link"); const signInLink = document.querySelector("#sign-in-link");
const signOutBtn = document.querySelector("#sign-out-btn"); const signOutBtn = document.querySelector("#sign-out-btn");
@@ -773,27 +785,6 @@ function applyAuthState() {
} }
} }
async function loadAuthState() {
try {
const r = await fetch("/api/whoami");
if (r.ok) isAdmin = (await r.json()).authenticated === true;
} catch {
isAdmin = false; // API unreachable: anonymous-safe defaults
}
applyAuthState();
return isAdmin;
}
if (signOutBtn) {
signOutBtn.addEventListener("click", async () => {
signOutBtn.disabled = true;
try {
await fetch("/api/logout", { method: "POST" });
} catch { /* the reload resets the UI either way */ }
window.location.reload();
});
}
const newChatBtn = document.querySelector("#new-chat-btn"); const newChatBtn = document.querySelector("#new-chat-btn");
function startNewChat() { function startNewChat() {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return; if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
@@ -983,9 +974,14 @@ composer.addEventListener("submit", handleSend);
/* Boot: auth state FIRST — it decides whether the restored conversation /* Boot: auth state FIRST — it decides whether the restored conversation
gets Tune buttons and whether the steering UI exists at all (phase 16). gets Tune buttons and whether the steering UI exists at all (phase 16).
Phase 14: the conversation then comes back exactly as left. */ Phase 14: the conversation then comes back exactly as left. Phase 19:
the shared header module runs the whoami (cached — exactly one
request per page load) and toggles the Sign in/out pair + the Sources
nav link; applyAuthState() then applies the chat-page-only gating. */
(async () => { (async () => {
await loadAuthState(); await initSharedHeader(); // header.js: whoami + Sign in/out + #nav-sources
isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami
applyAuthState(); // chat page: the admin-only tuning surface
restoreConversation(); restoreConversation();
loadSuggestions(); loadSuggestions();
loadHealth(); loadHealth();
+29
View File
@@ -15,8 +15,16 @@
* *
* A missing document (unknown pair, missing params, network error) shows * A missing document (unknown pair, missing params, network error) shows
* the designed not-found card with a link back to the Sources page. * the designed not-found card with a link back to the Sources page.
*
* Phase 19: the viewer joins the shared header (assets/header.js) — the
* whoami fetch is the module's cached promise (one request per page,
* shared with initSharedHeader's toggling), and the bar gains the New
* chat button: on a non-chat page "new chat" means going to the chat,
* fresh (clear the phase-14 conversation key, then navigate to "/").
*/ */
import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "/assets/header.js";
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const source = params.get("source") || ""; const source = params.get("source") || "";
const path = params.get("path") || ""; const path = params.get("path") || "";
@@ -111,6 +119,27 @@ function showNotFound() {
notFoundEl.hidden = false; notFoundEl.hidden = false;
} }
/* Phase 19: the shared header controls (Sign in / Sign out — exactly one
* visible) are toggled here; the viewer has no nav, so there is no
* #nav-sources for the module to touch. Independent of the doc fetch
* (its own IIFE — load() below never waits on it).
* (fetchIsAdmin is imported for parity with the other header consumers —
* the module's cached promise is the single whoami per page either way.) */
(async () => {
await initSharedHeader();
})();
/* Phase 19: New chat on a non-chat page means "go to the chat, fresh":
* clear the phase-14 conversation key, then land on the chat page — its
* empty state, since the conversation is gone from storage. */
const newChatBtn = document.querySelector("#new-chat-btn");
if (newChatBtn) {
newChatBtn.addEventListener("click", () => {
clearChatStorage();
window.location.href = "/";
});
}
async function load() { async function load() {
try { try {
if (!source || !path) { if (!source || !path) {
+96
View File
@@ -0,0 +1,96 @@
/* Brain of Reese — shared header module (phase 19).
*
* Owner report 2026-08-23: clicking "Sources" made New Chat and Sign in
* vanish — the user expects ONE consistent bar on every page. This module
* is the single owner of the shared header controls:
*
* • the Sign in / Sign out auth pair (phase 16, exactly one visible —
* decided by /api/whoami at load);
* • the "Sources" nav link (#nav-sources) — phase 19 UX revision
* (owner permission 2026-08-23): hidden for anonymous on every page
* that has a nav (chat, sources, login), revealed for admin. The
* link SHIPS hidden in the HTML (anonymous-safe default — the
* phase-16 "absent, not hidden" spirit), so no anonymous user ever
* sees it for a frame;
* • the sign-out click binding (POST /api/logout → reload) — moved
* here from app.js so there is exactly one implementation;
* • clearChatStorage() — the phase-14 conversation key, for the
* New Chat buttons on the NON-CHAT pages (sources / document
* viewer): a new chat means going to the chat, fresh.
*
* Every page loads this module (type="module", before its page script)
* and its page script calls initSharedHeader() once at boot. init…
* toggles ONLY the controls that exist on the page — a missing element
* is a no-op, which is how the login page reuses the module without
* gaining chat controls (no #new-chat-btn / #sign-in-link /
* #sign-out-btn in its markup → none appear).
*
* whoami is fetched at most ONCE per page load: the promise is cached in
* the module-level `adminPromise`, so app.js's tuning gate, the sources
* page's catalog gate, and the header toggling all share one request.
* Anonymous-safe: any network failure resolves to false (the anonymous
* UI), mirroring the per-page catch the pages used before phase 19.
*
* A10/A11 untouched: no API change, no CDN, no state beyond the cached
* promise; the soft gate page and the A10 API split are unchanged —
* this is UI visibility only.
*/
let adminPromise = null;
/* The SINGLE /api/whoami call site for the whole frontend. First call
stores the promise in `adminPromise`; every later call — on this page
— returns the same promise, i.e. exactly one request per page load.
Anonymous-safe: non-2xx or a network failure resolves to false. */
export function fetchIsAdmin() {
if (!adminPromise) {
adminPromise = fetch("/api/whoami")
.then(async (r) => (r.ok ? (await r.json()).authenticated === true : false))
.catch(() => false);
}
return adminPromise;
}
/* Toggle the shared header controls, only the ones present on this page
(querySelector, null-safe — missing → no-op). Returns the admin flag
so callers can reuse it instead of awaiting fetchIsAdmin() again (the
cached promise makes both awaits the same single request). */
export async function initSharedHeader() {
const admin = await fetchIsAdmin();
const signIn = document.querySelector("#sign-in-link");
if (signIn) signIn.hidden = admin;
const signOut = document.querySelector("#sign-out-btn");
if (signOut) signOut.hidden = !admin;
const navSources = document.querySelector("#nav-sources");
if (navSources) navSources.hidden = !admin;
return admin;
}
/* Remove the phase-14 conversation key — same key + fail-silence
contract as app.js's clearStoredConversation: private mode or a
storage error is swallowed, the navigation still happens. */
export function clearChatStorage() {
try {
localStorage.removeItem("bor.chat.v1");
} catch {
/* nothing was stored */
}
}
/* Sign-out binding (phase 16 behavior, now module-owned): runs at module
import, so every page that loads header.js gets it exactly once.
Disable during the call, POST /api/logout (the result is ignored —
the reload resets the UI either way), then reload so the header
re-resolves to the anonymous state (Sign in back, Sources gone). */
const signOutBtn = document.querySelector("#sign-out-btn");
if (signOutBtn) {
signOutBtn.addEventListener("click", async () => {
signOutBtn.disabled = true;
try {
await fetch("/api/logout", { method: "POST" });
} catch {
/* the reload resets the UI either way */
}
window.location.reload();
});
}
+19 -9
View File
@@ -7,10 +7,18 @@
* role=alert error region. On load, /api/whoami already says admin → * role=alert error region. On load, /api/whoami already says admin →
* straight to `next`, no form. * straight to `next`, no form.
* *
* Phase 19: the whoami check runs on the shared header module's cached
* promise (assets/header.js) — one request per page, and the module's
* initSharedHeader() toggles the (admin-only) Sources nav link. The
* login page carries no chat controls, so the module's missing-element
* no-op keeps this page control-free.
*
* No CDN, no state in this file: the signed cookie is the whole session. * No CDN, no state in this file: the signed cookie is the whole session.
* All DOM ids match frontend/login.html. * All DOM ids match frontend/login.html.
*/ */
import { fetchIsAdmin, initSharedHeader } from "/assets/header.js";
const form = document.querySelector("#login-form"); const form = document.querySelector("#login-form");
const passwordInput = document.querySelector("#login-password"); const passwordInput = document.querySelector("#login-password");
const submitBtn = document.querySelector("#login-submit"); const submitBtn = document.querySelector("#login-submit");
@@ -33,14 +41,12 @@ function showError(message) {
passwordInput.select(); passwordInput.select();
} }
async function alreadySignedIn() { /* Phase 19: the shared header module IS the whoami call site (cached
try { * promise, anonymous-safe) — same result as the private fetch it
const r = await fetch("/api/whoami"); * replaces: a network failure stays on the form (submit will explain).
if (!r.ok) return false; */
return (await r.json()).authenticated === true; function alreadySignedIn() {
} catch { return fetchIsAdmin();
return false; // API unreachable: stay on the form — submit will explain
}
} }
form.addEventListener("submit", async (e) => { form.addEventListener("submit", async (e) => {
@@ -70,11 +76,15 @@ form.addEventListener("submit", async (e) => {
} }
}); });
/* Already the admin? Skip the form and go straight to the target. */ /* Already the admin? Skip the form and go straight to the target.
* (For anonymous visitors, initSharedHeader toggles the Sources nav
* link — the only shared control this page carries; for the signed-in
* case the redirect above makes the toggle moot.) */
(async () => { (async () => {
if (await alreadySignedIn()) { if (await alreadySignedIn()) {
window.location.replace(safeNext()); window.location.replace(safeNext());
return; return;
} }
await initSharedHeader(); // phase 19: Sources link toggle (no chat controls here)
passwordInput.focus(); passwordInput.focus();
})(); })();
+25 -7
View File
@@ -4,8 +4,16 @@
* full-width document table, or the designed empty state when nothing is * full-width document table, or the designed empty state when nothing is
* indexed yet. Cells are built with DOM APIs (textContent) — never * indexed yet. Cells are built with DOM APIs (textContent) — never
* innerHTML with document-derived data (XSS-safe by construction). * innerHTML with document-derived data (XSS-safe by construction).
*
* Phase 19: the page joins the shared header (assets/header.js) — the
* whoami gate below runs on the module's cached promise (one request per
* page, shared with the header toggling), and the header gains the New
* chat button: on a non-chat page "new chat" means going to the chat,
* fresh (clear the phase-14 conversation key, then navigate to "/").
*/ */
import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "/assets/header.js";
const tbody = document.querySelector("#docs-tbody"); const tbody = document.querySelector("#docs-tbody");
const emptyEl = document.querySelector("#sources-empty"); const emptyEl = document.querySelector("#sources-empty");
const tableWrap = document.querySelector(".table-wrap"); const tableWrap = document.querySelector(".table-wrap");
@@ -18,13 +26,22 @@ const statLast = document.querySelector("#stat-last");
/* Phase 16: whoami BEFORE the docs fetch. Anonymous visitors get the /* Phase 16: whoami BEFORE the docs fetch. Anonymous visitors get the
* sign-in gate (stat cards + table hidden) and NO /api/docs call — the * sign-in gate (stat cards + table hidden) and NO /api/docs call — the
* catalog is admin-only. The document viewer itself stays public (the * catalog is admin-only. The document viewer itself stays public (the
* soft rule), so the gate copy points at what keeps working. */ * soft rule), so the gate copy points at what keeps working.
async function isAdmin() { * Phase 19: the whoami request is the shared header module's cached
try { * promise — the same single request initSharedHeader() awaited. */
const r = await fetch("/api/whoami"); function isAdmin() {
if (r.ok) return (await r.json()).authenticated === true; return fetchIsAdmin();
} catch { /* API unreachable: anonymous-safe gate */ } }
return false;
/* Phase 19: New chat on a non-chat page means "go to the chat, fresh":
* clear the phase-14 conversation key, then land on the chat page — its
* empty state, since the conversation is gone from storage. */
const newChatBtn = document.querySelector("#new-chat-btn");
if (newChatBtn) {
newChatBtn.addEventListener("click", () => {
clearChatStorage();
window.location.href = "/";
});
} }
function fmtDate(iso) { function fmtDate(iso) {
@@ -112,6 +129,7 @@ function showEmpty() {
} }
(async () => { (async () => {
await initSharedHeader(); // phase 19: Sign in/out + Sources link in the shared bar
if (!(await isAdmin())) { if (!(await isAdmin())) {
// Anonymous: gate in, catalog out, and no /api/docs request at all. // Anonymous: gate in, catalog out, and no /api/docs request at all.
if (statCards) statCards.hidden = true; if (statCards) statCards.hidden = true;
+13
View File
@@ -998,6 +998,19 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.doc-back:hover { background: #2a345f; } .doc-back:hover { background: #2a345f; }
.doc-back svg { width: 16px; height: 16px; display: block; } .doc-back svg { width: 16px; height: 16px; display: block; }
.doc-title-block { min-width: 0; } .doc-title-block { min-width: 0; }
/* Phase 19: the shared header controls reach the viewer bar (New chat
+ Sign in / Sign out) — margin-left:auto pushes them to the right;
the title block keeps clipping (min-width: 0 above) so the two pills
fit while the bar still measures exactly --header-h. The reused
.new-chat-btn / .auth-link classes already carry the ≤640px icon-only
rules, so at 360px the bar is back pill + clipping title + two icon
pills (no overflow — test_responsive_polish pins scrollWidth). */
.doc-header-actions {
margin-left: auto;
display: flex;
gap: 0.5rem;
align-items: center;
}
#doc-title { #doc-title {
margin: 0; margin: 0;
font-size: 1.3rem; font-size: 1.3rem;
+25
View File
@@ -21,6 +21,27 @@
<h1 id="doc-title">Loading…</h1> <h1 id="doc-title">Loading…</h1>
<div id="doc-meta" class="doc-meta"></div> <div id="doc-meta" class="doc-meta"></div>
</div> </div>
<!-- Phase 19: the shared header controls reach the viewer bar —
same markup, ids, and aria as the chat header (one consistent
bar on every page). The viewer has no nav, so no Sources link
here. header.js (assets/header.js) reveals exactly one of Sign in /
Sign out after whoami; New chat here means "go to the chat,
fresh" (document.js). The title block clips while the two
pills fit (.doc-header-actions, styles.css). -->
<div class="doc-header-actions">
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
<span class="new-chat-label">New chat</span>
</button>
<a href="/login.html?next=/document.html" class="auth-link" id="sign-in-link" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
<span class="auth-label">Sign in</span>
</a>
<button type="button" class="auth-link" id="sign-out-btn" aria-label="Sign out" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
<span class="auth-label">Sign out</span>
</button>
</div>
</div> </div>
</header> </header>
@@ -53,6 +74,10 @@
</footer> </footer>
<script src="assets/markdown.js"></script> <script src="assets/markdown.js"></script>
<!-- Phase 19: shared header module (whoami caching, Sign in/out,
sign-out binding) loads before the page script, which calls
initSharedHeader() at boot. -->
<script type="module" src="/assets/header.js"></script>
<script type="module" src="assets/document.js"></script> <script type="module" src="assets/document.js"></script>
</body> </body>
</html> </html>
+8 -1
View File
@@ -19,7 +19,10 @@
</span> </span>
<nav class="app-nav" aria-label="Primary"> <nav class="app-nav" aria-label="Primary">
<a href="/" class="nav-link is-active" aria-current="page">Chat</a> <a href="/" class="nav-link is-active" aria-current="page">Chat</a>
<a href="/sources.html" class="nav-link">Sources</a> <!-- Phase 19: the Sources link is admin-only (owner permission
2026-08-23) — hidden by default, header.js reveals it once
whoami says admin. The soft-gated page itself is unchanged. -->
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
</nav> </nav>
<!-- Phase 15: open the tuning-notes panel (stored in Postgres, read <!-- Phase 15: open the tuning-notes panel (stored in Postgres, read
into every system prompt) — chat page only. --> into every system prompt) — chat page only. -->
@@ -110,6 +113,10 @@
</footer> </footer>
<script src="assets/markdown.js"></script> <script src="assets/markdown.js"></script>
<!-- Phase 19: shared header module (whoami caching, Sign in/out,
Sources-link toggle, sign-out binding) loads before the page
script, which calls initSharedHeader() at boot. -->
<script type="module" src="/assets/header.js"></script>
<script type="module" src="/assets/app.js"></script> <script type="module" src="/assets/app.js"></script>
</body> </body>
</html> </html>
+9 -1
View File
@@ -20,7 +20,11 @@
</span> </span>
<nav class="app-nav" aria-label="Primary"> <nav class="app-nav" aria-label="Primary">
<a href="/" class="nav-link">Chat</a> <a href="/" class="nav-link">Chat</a>
<a href="/sources.html" class="nav-link">Sources</a> <!-- Phase 19: the Sources link is admin-only (owner permission
2026-08-23) — hidden by default, header.js reveals it once
whoami says admin. The login page deliberately carries NO
chat controls, so header.js only toggles this link here. -->
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
</nav> </nav>
</div> </div>
</header> </header>
@@ -57,6 +61,10 @@
</div> </div>
</footer> </footer>
<!-- Phase 19: shared header module — the login page reuses it for the
Sources-link toggle only (no chat controls in this markup, so
none appear). -->
<script type="module" src="/assets/header.js"></script>
<script type="module" src="/assets/login.js"></script> <script type="module" src="/assets/login.js"></script>
</body> </body>
</html> </html>
+25 -1
View File
@@ -19,8 +19,28 @@
</span> </span>
<nav class="app-nav" aria-label="Primary"> <nav class="app-nav" aria-label="Primary">
<a href="/" class="nav-link">Chat</a> <a href="/" class="nav-link">Chat</a>
<a href="/sources.html" class="nav-link is-active" aria-current="page">Sources</a> <!-- Phase 19: the Sources link is admin-only (owner permission
2026-08-23) — hidden by default, header.js reveals it once
whoami says admin. The soft-gated page itself is unchanged. -->
<a href="/sources.html" class="nav-link is-active" aria-current="page" id="nav-sources" hidden>Sources</a>
</nav> </nav>
<!-- Phase 19: the shared header controls reach the Sources page —
same markup, ids, and aria as the chat header (one consistent
bar on every page). header.js (assets/header.js) reveals
exactly one of Sign in / Sign out after whoami; the New chat
button here means "go to the chat, fresh" (sources.js). -->
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
<span class="new-chat-label">New chat</span>
</button>
<a href="/login.html?next=/sources.html" class="auth-link" id="sign-in-link" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
<span class="auth-label">Sign in</span>
</a>
<button type="button" class="auth-link" id="sign-out-btn" aria-label="Sign out" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
<span class="auth-label">Sign out</span>
</button>
</div> </div>
</header> </header>
@@ -99,6 +119,10 @@
</div> </div>
</footer> </footer>
<!-- Phase 19: shared header module (whoami caching, Sign in/out,
Sources-link toggle, sign-out binding) loads before the page
script, which calls initSharedHeader() at boot. -->
<script type="module" src="/assets/header.js"></script>
<script type="module" src="/assets/sources.js"></script> <script type="module" src="/assets/sources.js"></script>
</body> </body>
</html> </html>
+6 -4
View File
@@ -271,12 +271,14 @@ def test_persists_across_page_navigation(
_ask(page, QUESTION) _ask(page, QUESTION)
_ask_deflected(page, OFF_TOPIC) # mixed conversation: grounded + deflected _ask_deflected(page, OFF_TOPIC) # mixed conversation: grounded + deflected
# A trip to Sources — the New chat control is chat-page-only. # A trip to Sources (phase 16: the catalog is admin-only — the trip
# (Phase 16: the catalog is admin-only — the trip starts with a # starts with a real form login). Phase 19 (owner permission
# real form login.) # 2026-08-23): the New chat control is part of the SHARED bar, so it
# is present on the sources page too — only clicking it would clear
# the conversation (pinned in tests/e2e/test_shared_header.py).
login(page, app_url, next="/sources.html") login(page, app_url, next="/sources.html")
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000) expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#new-chat-btn")).to_have_count(0) expect(page.locator("#new-chat-btn")).to_be_visible()
# Back to the chat: the conversation is exactly as left — both turns, # Back to the chat: the conversation is exactly as left — both turns,
# the source chip, and the amber deflected bubble with its chips. # the source chip, and the amber deflected bubble with its chips.
+363
View File
@@ -0,0 +1,363 @@
"""Phase 19 E2E (Playwright): the shared header bar on every page.
Story: ``.agent/user_stories/shared-header.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_shared_header.py -v --no-cov
Contract under test (owner report 2026-08-23, phase 19) — ONE bar per
page, the same controls everywhere:
* chat / sources: brand + nav [Chat, Sources — admin only] + New Chat
+ Sign in / Sign out;
* document viewer: back + title + New Chat + Sign in / Sign out (the
viewer has no nav, so no Sources link at all);
* the "Sources" nav link (``#nav-sources``) is HIDDEN for anonymous
users on every page that has a nav and shown for admin (phase-16 UX
revision with owner permission; the soft-gate page and the A10 API
split are untouched);
* the bar height never moves: 64px desktop / 58px at ≤640px (phase-12
``--header-h`` contract, bounding-box measurement convention).
Determinism note: every assertion is settled-state — ``assert_shared_bar``
first waits for the whoami toggle to land (exactly one of Sign in /
Sign out visible), and the viewer waits for the document to render. No
streaming is involved in this story: the chat page is opened at most for
its header; no turn is ever submitted.
Test → story mapping (Playwright Mapping Rule):
1. ``test_anonymous_bar_on_all_pages``
2. ``test_admin_bar_on_all_pages``
3. ``test_sources_nav_hidden_for_anonymous_everywhere``
4. ``test_new_chat_from_sources_clears_and_navigates``
5. ``test_sign_out_from_viewer_returns_to_anonymous``
6. ``test_mobile_bar_fits_and_heights_held``
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from threading import Thread
from typing import Any
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
#: A seeded fixture doc (source=docs), URL-encoded — the same document
#: every viewer suite uses (title "Kubernetes Homelab Cluster").
VIEWER_URL = "/document.html?source=docs&path=homelab%2Fkubernetes.md"
SOURCES_URL = "/sources.html"
DOC_TITLE = "Kubernetes Homelab Cluster"
#: The shared header-bar token values (frontend/assets/styles.css :root
#: and the ≤640px media query) — phase 12, pinned here as a regression.
DESKTOP_HEADER_H = 64
MOBILE_HEADER_H = 58
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread (Playwright owns the test loop)."""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _seed_db(mock_port: int) -> None:
"""Fresh KB with the fixture docs (needed for the viewer URL and the
admin sources catalog)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
_run_in_thread(_import_fixtures(mock_port))
# ---------------------------------------------------------------------------
# The heart of the suite: one helper, the full shared-bar contract
# ---------------------------------------------------------------------------
def _expected_h(page: Page) -> int:
"""The phase-12 bar height for the current viewport (≤640 → 58)."""
viewport = page.viewport_size
assert viewport is not None, "every test here sets an explicit viewport"
return MOBILE_HEADER_H if viewport["width"] <= 640 else DESKTOP_HEADER_H
def _bar_selector(page_kind: str) -> str:
return ".doc-header" if page_kind == "viewer" else ".app-header"
def assert_shared_bar(page: Page, admin: bool, page_kind: str) -> None:
"""Assert the phase-19 shared-bar contract on the page the ``page``
is already showing.
``page_kind`` is ``"chat"``, ``"sources"``, or ``"viewer"``. The
helper waits for the SETTLED state — both auth controls ship hidden
in the HTML, so "exactly one is visible" means /api/whoami resolved
and header.js (``initSharedHeader``) did its toggle — before any
assertion runs.
"""
# Settled auth state: exactly one of Sign in / Sign out is visible
# (phase-16 semantics, now owned by the shared module).
if admin:
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
expect(page.locator("#sign-in-link")).to_be_hidden()
else:
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
expect(page.locator("#sign-out-btn")).to_be_hidden()
# New Chat is on the bar on every page kind (the owner's ask).
expect(page.locator("#new-chat-btn")).to_be_visible()
if page_kind == "viewer":
# The viewer has no nav — no Sources link in the DOM at all.
assert page.locator("#nav-sources").count() == 0, (
"the viewer bar must not carry a Sources nav link"
)
# The document itself has settled (rendered, not Loading…/not-found)
# so the bar is being measured on the real page.
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
else:
# The Sources nav link: admin-only (phase-16 revision, owner
# permission 2026-08-23) — hidden for anonymous, shown for admin.
nav = page.locator("#nav-sources")
assert nav.count() == 1, f"one #nav-sources expected on the {page_kind} page"
if admin:
expect(nav).to_be_visible()
else:
expect(nav).to_be_hidden()
# The bar height never moves: 64px desktop / 58px ≤640px (phase 12),
# bounding-box measurement — the new pills must fit inside it.
box = page.locator(_bar_selector(page_kind)).bounding_box()
assert box is not None, f"{_bar_selector(page_kind)} not rendered"
assert box["height"] == _expected_h(page), (
f"{page_kind} bar is {box['height']}px, expected {_expected_h(page)}px"
)
def _assert_no_overflow(page: Page, label: str) -> None:
"""No horizontal page overflow (the responsive-polish convention)."""
scroll, client = page.evaluate(
"() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]"
)
assert scroll <= client, f"horizontal overflow on {label}: {scroll} > {client}"
# ---------------------------------------------------------------------------
# 1. Anonymous: the bar exists on all three pages, in the anonymous state
# ---------------------------------------------------------------------------
def test_anonymous_bar_on_all_pages(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
page.goto(app_url + "/")
assert_shared_bar(page, admin=False, page_kind="chat")
page.goto(app_url + SOURCES_URL)
# Phase 16's soft gate is unchanged for direct-URL visitors — the
# bar above it is what this suite pins.
expect(page.locator("#sources-gate")).to_be_visible()
assert_shared_bar(page, admin=False, page_kind="sources")
page.goto(app_url + VIEWER_URL)
assert_shared_bar(page, admin=False, page_kind="viewer")
# ---------------------------------------------------------------------------
# 2. Admin: the bar on all three pages flips to the signed-in state
# ---------------------------------------------------------------------------
def test_admin_bar_on_all_pages(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
# Real form login with next=/ — the phase-16 redirect flow still
# lands the admin on the chat page.
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/")
assert_shared_bar(page, admin=True, page_kind="chat")
page.goto(app_url + SOURCES_URL)
expect(page.locator("#sources-gate")).to_be_hidden()
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
assert_shared_bar(page, admin=True, page_kind="sources")
page.goto(app_url + VIEWER_URL)
assert_shared_bar(page, admin=True, page_kind="viewer")
# ---------------------------------------------------------------------------
# 3. The Sources nav link: hidden for anonymous everywhere, revealed
# after a real login (a toggle, not just initial state)
# ---------------------------------------------------------------------------
def test_sources_nav_hidden_for_anonymous_everywhere(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
for path in ("/", SOURCES_URL):
page.goto(app_url + path)
# Settled anonymous state, then the nav-link contract.
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
nav = page.locator("#nav-sources")
assert nav.count() == 1
expect(nav).to_be_hidden()
# The login page has no chat controls — header.js only toggles the
# nav link there; for anonymous it stays hidden (it ships hidden).
page.goto(app_url + "/login.html")
page.wait_for_load_state("networkidle") # the whoami round-trip has settled
nav = page.locator("#nav-sources")
assert nav.count() == 1
expect(nav).to_be_hidden()
# And the toggle works, not just the initial state: after a real
# form login on the chat page the link appears.
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/")
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
expect(page.locator("#nav-sources")).to_be_visible()
# ---------------------------------------------------------------------------
# 4. New Chat from a non-chat page: clear the conversation, land on the
# chat empty state
# ---------------------------------------------------------------------------
def test_new_chat_from_sources_clears_and_navigates(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
# Seed the phase-14 conversation before any page script runs. The
# init script runs on EVERY navigation, so it is scoped to the
# sources page — the post-click navigation to "/" must start clean.
page.add_init_script(
"""(() => {
if (location.pathname !== "/sources.html") return;
try {
localStorage.setItem("bor.chat.v1", JSON.stringify({
v: 1,
messages: [
{ who: "user", text: "hello brain" },
{ who: "brain", text: "hey there" }
]
}));
} catch {}
})();"""
)
page.goto(app_url + SOURCES_URL)
# The seeded conversation is in storage…
assert (
page.evaluate("() => localStorage.getItem('bor.chat.v1')") is not None
), "init script must have seeded the phase-14 conversation key"
# New Chat from the sources page: a new chat means going to the
# chat — fresh.
page.click("#new-chat-btn")
expect(page).to_have_url(app_url + "/", timeout=30_000)
# …and the chat lands on its empty state with the key removed.
expect(page.locator("#empty-state")).to_be_visible()
expect(page.locator(".msg")).to_have_count(0)
assert page.evaluate("() => localStorage.getItem('bor.chat.v1')") is None, (
"New Chat from a non-chat page must clear the localStorage key"
)
# ---------------------------------------------------------------------------
# 5. Sign out from the viewer: the same page comes back anonymous
# ---------------------------------------------------------------------------
def test_sign_out_from_viewer_returns_to_anonymous(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
# Log in with next=/sources.html — lands on the admin sources bar…
login(page, app_url, next="/sources.html")
expect(page).to_have_url(app_url + "/sources.html")
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
# …and open the viewer directly: the admin bar is there too.
page.goto(app_url + VIEWER_URL)
assert_shared_bar(page, admin=True, page_kind="viewer")
# Sign out from the viewer: header.js POSTs /api/logout and reloads;
# after the reload the same page shows the anonymous bar.
page.click("#sign-out-btn")
assert_shared_bar(page, admin=False, page_kind="viewer")
expect(page).to_have_url(app_url + VIEWER_URL)
# ---------------------------------------------------------------------------
# 6. Mobile (375×812): 58px bars, no horizontal overflow, in BOTH auth
# states — the new pills never grow the bar
# ---------------------------------------------------------------------------
def test_mobile_bar_fits_and_heights_held(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 375, "height": 812})
_seed_db(mock_llm)
def check_all(admin: bool) -> None:
for kind, path in (
("chat", "/"),
("sources", SOURCES_URL),
("viewer", VIEWER_URL),
):
page.goto(app_url + path)
# 58px at 375px is asserted inside assert_shared_bar…
assert_shared_bar(page, admin=admin, page_kind=kind)
# …and the pills (icon-only at ≤640px) fit without overflow.
_assert_no_overflow(page, f"{kind} @375px (admin={admin})")
# Anonymous: the two icon pills are Sign in + New chat.
check_all(admin=False)
# Signed in: Sign out + the Sources nav link join the bars — and the
# bar never grows.
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/")
check_all(admin=True)
+16 -10
View File
@@ -10,7 +10,8 @@ Pinned design (PLAN §7.4 note / phase 14):
* save points: user message on send, brain message on ``done``; * save points: user message on send, brain message on ``done``;
* every ``localStorage`` access wrapped in try/catch (failure-safe); * every ``localStorage`` access wrapped in try/catch (failure-safe);
* size budget ~700k chars, oldest dropped first; * size budget ~700k chars, oldest dropped first;
* ``#new-chat-btn`` in the chat header (chat page only), ≥44px, ghost pill. * ``#new-chat-btn`` in the shared header bar (chat, sources, viewer —
phase 19, owner permission 2026-08-23), ≥44px, ghost pill.
""" """
from __future__ import annotations from __future__ import annotations
@@ -144,24 +145,29 @@ def test_new_chat_clears_key_and_ui() -> None:
assert "removeItem(STORAGE_KEY)" in js assert "removeItem(STORAGE_KEY)" in js
def test_new_chat_button_in_chat_header_only() -> None: def test_new_chat_button_in_the_shared_header_bar() -> None:
"""#new-chat-btn lives in the chat header (index.html) as a real """#new-chat-btn is a real type=button with an accessible name in the
type=button with an accessible name — and nowhere else (A10: chat-page chat header (index.html). Phase 19 (owner permission 2026-08-23): it
only control).""" is part of the SHARED bar — it also appears in sources.html and the
document viewer, where it means "go to the chat, fresh" (the
clear-storage + navigate binding is pinned in test_shared_header.py)."""
for html in (INDEX_HTML, SOURCES_HTML, DOCUMENT_HTML):
text = html.read_text(encoding="utf-8")
btn = re.search(r'<button[^>]*id="new-chat-btn"[^>]*>', text)
assert btn, f"{html.name} must contain #new-chat-btn"
tag = btn.group(0)
assert 'type="button"' in tag
assert 'aria-label="New chat"' in tag
# Chat page specifics: the button sits after the nav, inside the header.
html = _index() html = _index()
btn = re.search(r'<button[^>]*id="new-chat-btn"[^>]*>', html) btn = re.search(r'<button[^>]*id="new-chat-btn"[^>]*>', html)
assert btn, "index.html must contain #new-chat-btn" assert btn, "index.html must contain #new-chat-btn"
tag = btn.group(0)
assert 'type="button"' in tag
assert 'aria-label="New chat"' in tag
nav_idx = html.find('<nav class="app-nav"') nav_idx = html.find('<nav class="app-nav"')
assert nav_idx != -1 and btn.start() > nav_idx, ( assert nav_idx != -1 and btn.start() > nav_idx, (
"the button belongs after the nav, inside .header-inner" "the button belongs after the nav, inside .header-inner"
) )
main_idx = html.find('main id="main"') main_idx = html.find('main id="main"')
assert main_idx != -1 and btn.start() < main_idx, "the button belongs in the header" assert main_idx != -1 and btn.start() < main_idx, "the button belongs in the header"
assert 'id="new-chat-btn"' not in SOURCES_HTML.read_text(encoding="utf-8")
assert 'id="new-chat-btn"' not in DOCUMENT_HTML.read_text(encoding="utf-8")
def test_new_chat_button_style_contract() -> None: def test_new_chat_button_style_contract() -> None:
+248
View File
@@ -0,0 +1,248 @@
"""Unit: the shared header module contract (phase 19).
The browser behavior is E2E-covered (tests/e2e/test_shared_header.py);
here we pin the source-level wiring — the header.js exports, the cached
whoami promise, the per-page HTML ids (anonymous-safe hidden-by-default
controls), the sign-out binding move out of app.js, the non-chat New
Chat bindings, and the viewer-bar CSS — so a silent regression is caught
without a browser.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
HEADER_JS = ASSETS / "header.js"
APP_JS = ASSETS / "app.js"
SOURCES_JS = ASSETS / "sources.js"
DOCUMENT_JS = ASSETS / "document.js"
LOGIN_JS = ASSETS / "login.js"
STYLES_CSS = ASSETS / "styles.css"
INDEX_HTML = FRONTEND / "index.html"
SOURCES_HTML = FRONTEND / "sources.html"
DOCUMENT_HTML = FRONTEND / "document.html"
LOGIN_HTML = FRONTEND / "login.html"
def _text(path: Path) -> str:
assert path.is_file(), f"missing frontend file: {path}"
return path.read_text(encoding="utf-8")
def _script_srcs(path: Path) -> list[str]:
return re.findall(r'<script[^>]*src="([^"]+)"', _text(path))
# ---------- header.js: the module itself ----------
def test_header_module_exports_the_three_functions() -> None:
"""header.js must export the three functions every page script
imports (fetchIsAdmin / initSharedHeader / clearChatStorage)."""
js = _text(HEADER_JS)
assert "export function fetchIsAdmin" in js
assert "export async function initSharedHeader" in js
assert "export function clearChatStorage" in js
def test_whoami_fetch_is_cached_in_a_module_level_promise() -> None:
"""The whoami fetch is cached in the module-level `adminPromise`
marker — first call stores the promise, later calls return it, so a
page makes exactly ONE /api/whoami request per load no matter how
many consumers await it. Anonymous-safe: a failure resolves to
false."""
js = _text(HEADER_JS)
assert re.search(r"let\s+adminPromise\s*=\s*null", js), (
"module-level adminPromise marker missing"
)
assert 'fetch("/api/whoami")' in js
assert "if (!adminPromise)" in js, "fetchIsAdmin must reuse the stored promise"
assert "return adminPromise" in js
assert ".catch(() => false)" in js, "network failure must resolve to anonymous"
def test_init_shared_header_toggles_only_elements_that_exist() -> None:
"""initSharedHeader awaits the cached whoami, toggles ONLY the
controls present on the page (querySelector, null-safe), and returns
the admin flag for reuse."""
js = _text(HEADER_JS)
fn = js.find("function initSharedHeader")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
assert "await fetchIsAdmin()" in body
for selector in ('#sign-in-link', '#sign-out-btn', '#nav-sources'):
assert f'querySelector("{selector}")' in body
assert "return admin" in body, "callers may reuse the flag"
def test_clear_chat_storage_removes_the_phase14_key_silently() -> None:
"""clearChatStorage removes the SAME phase-14 key as app.js, inside
a try/catch (private mode / storage errors are swallowed — the
navigation still happens)."""
js = _text(HEADER_JS)
fn = js.find("function clearChatStorage")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
assert 'localStorage.removeItem("bor.chat.v1")' in body
assert "try" in body and "catch" in body
def test_sign_out_binding_lives_in_the_shared_module() -> None:
"""The #sign-out-btn click binding (disable → POST /api/logout →
reload) is owned by header.js at module import — exactly one
implementation for every page that loads it."""
js = _text(HEADER_JS)
assert "querySelector(\"#sign-out-btn\")" in js
assert "signOutBtn.addEventListener" in js
assert "signOutBtn.disabled = true" in js
assert 'fetch("/api/logout", { method: "POST" })' in js
assert "location.reload()" in js
# ---------- HTML wiring: one shared bar on every page ----------
def test_nav_sources_ships_hidden_on_every_nav_page() -> None:
"""Phase 19 UX revision (owner permission 2026-08-23): the Sources
nav link is hidden for anonymous — so it SHIPS with the hidden
attribute (anonymous-safe default) on every page that has a nav
(chat, sources, login)."""
for html in (INDEX_HTML, SOURCES_HTML, LOGIN_HTML):
text = _text(html)
assert re.search(r'id="nav-sources"[^>]*\bhidden\b', text), (
f"{html.name}: #nav-sources must ship hidden"
)
def test_nav_sources_is_absent_from_the_viewer() -> None:
"""The document viewer has no nav — no #nav-sources element there (the
module's missing-element no-op keeps it out)."""
assert 'id="nav-sources"' not in _text(DOCUMENT_HTML)
def test_sources_and_viewer_carry_the_shared_controls() -> None:
"""Sources AND the document viewer gain the New Chat button + the
Sign in / Sign out pair (both starting hidden — initSharedHeader
reveals exactly one after whoami), and they load header.js."""
for html in (SOURCES_HTML, DOCUMENT_HTML):
text = _text(html)
assert 'id="new-chat-btn"' in text
assert re.search(r'id="sign-in-link"[^>]*\bhidden\b', text)
assert re.search(r'id="sign-out-btn"[^>]*\bhidden\b', text)
assert "header.js" in text, "page must load the shared header module"
# Each page's Sign in link returns to ITS OWN page after login.
assert 'href="/login.html?next=/sources.html"' in _text(SOURCES_HTML)
assert 'href="/login.html?next=/document.html"' in _text(DOCUMENT_HTML)
def test_header_module_loads_before_the_page_script() -> None:
"""Every page loads header.js (type=module) BEFORE its page script,
so the sign-out binding and the whoami cache exist when the page
script boots."""
cases = [
(INDEX_HTML, "app.js"),
(SOURCES_HTML, "sources.js"),
(DOCUMENT_HTML, "document.js"),
(LOGIN_HTML, "login.js"),
]
for html, page_script in cases:
srcs = _script_srcs(html)
header_idx = [i for i, s in enumerate(srcs) if "header.js" in s]
page_idx = [i for i, s in enumerate(srcs) if page_script in s]
assert header_idx, f"{html.name}: must load header.js"
assert page_idx, f"{html.name}: must load {page_script}"
assert header_idx[0] < page_idx[0], (
f"{html.name}: header.js must load before {page_script}"
)
def test_login_page_carries_no_chat_controls() -> None:
"""Noted boundary (owner-confirmed): the login page is the auth page,
not an app page — no New Chat / Sign in / Sign out controls there;
header.js only toggles the Sources link."""
text = _text(LOGIN_HTML)
assert "new-chat-btn" not in text
assert "sign-in-link" not in text
assert "sign-out-btn" not in text
# ---------- page-script adaptations ----------
def test_app_js_delegates_the_shared_controls_to_header_module() -> None:
"""app.js imports the shared module, runs initSharedHeader() at boot
(BEFORE the phase-14 restore), takes its isAdmin from the cached
fetchIsAdmin(), and owns NO whoami fetch and NO sign-out binding
anymore (both moved to header.js)."""
js = _text(APP_JS)
assert 'from "/assets/header.js"' in js
assert "fetchIsAdmin" in js and "initSharedHeader" in js
assert "signOutBtn.addEventListener" not in js, (
"the sign-out binding moved to header.js"
)
assert 'fetch("/api/whoami")' not in js, (
"app.js must not fetch whoami itself — header.js caches it (one request/page)"
)
assert "loadAuthState" not in js, "loadAuthState was deleted in phase 19"
assert "function applyAuthState" in js, "chat-page tuning gate stays"
assert "isAdmin = await fetchIsAdmin();" in js
init_idx = js.find("await initSharedHeader();")
restore_idx = js.find("restoreConversation();")
assert -1 < init_idx < restore_idx, (
"header init must run before the phase-14 restore"
)
def test_login_js_uses_the_shared_fetch_is_admin() -> None:
"""login.js switches its whoami check to the shared cached promise
(one request per page) and calls initSharedHeader for the Sources
link; its already-admin → redirect behavior is unchanged."""
js = _text(LOGIN_JS)
assert 'from "/assets/header.js"' in js
assert "fetchIsAdmin" in js
assert "fetchIsAdmin()" in js
assert "initSharedHeader()" in js
assert 'fetch("/api/whoami")' not in js
assert "window.location.replace(safeNext())" in js
def test_non_chat_pages_bind_new_chat_to_the_chat_page() -> None:
"""On sources and the viewer, New Chat means "go to the chat,
fresh": the binding clears the phase-14 key (clearChatStorage) and
navigates to "/" — and both pages run initSharedHeader() at boot
on the shared cached whoami."""
for js_file in (SOURCES_JS, DOCUMENT_JS):
js = _text(js_file)
assert 'from "/assets/header.js"' in js
assert "initSharedHeader()" in js
btn_idx = js.find("new-chat-btn")
clear_idx = js.find("clearChatStorage();")
nav_idx = js.find('window.location.href = "/"')
assert -1 < btn_idx < clear_idx < nav_idx, (
f"{js_file.name}: #new-chat-btn must clear storage then navigate to '/'"
)
assert 'fetch("/api/whoami")' not in js, (
f"{js_file.name}: whoami goes through the shared cached promise"
)
# ---------- viewer-bar CSS ----------
def test_viewer_bar_css_pushes_actions_right_and_title_clips() -> None:
"""styles.css defines .doc-header-actions (margin-left:auto flex
cluster) and the title block keeps min-width: 0 so
#doc-title/#doc-meta clip instead of overflowing the --header-h bar."""
css = _text(STYLES_CSS)
block = re.search(r"\.doc-header-actions\s*\{([^}]*)\}", css)
assert block, "styles.css must define .doc-header-actions"
body = block.group(1)
assert "margin-left: auto" in body
assert "display: flex" in body
assert re.search(r"\.doc-title-block\s*\{[^}]*min-width:\s*0", css), (
"the title block must keep clipping while the pills fit"
)