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:
@@ -10,7 +10,8 @@ Pinned design (PLAN §7.4 note / phase 14):
|
||||
* save points: user message on send, brain message on ``done``;
|
||||
* every ``localStorage`` access wrapped in try/catch (failure-safe);
|
||||
* 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
|
||||
|
||||
@@ -144,24 +145,29 @@ def test_new_chat_clears_key_and_ui() -> None:
|
||||
assert "removeItem(STORAGE_KEY)" in js
|
||||
|
||||
|
||||
def test_new_chat_button_in_chat_header_only() -> None:
|
||||
"""#new-chat-btn lives in the chat header (index.html) as a real
|
||||
type=button with an accessible name — and nowhere else (A10: chat-page
|
||||
only control)."""
|
||||
def test_new_chat_button_in_the_shared_header_bar() -> None:
|
||||
"""#new-chat-btn is a real type=button with an accessible name in the
|
||||
chat header (index.html). Phase 19 (owner permission 2026-08-23): it
|
||||
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()
|
||||
btn = re.search(r'<button[^>]*id="new-chat-btn"[^>]*>', html)
|
||||
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"')
|
||||
assert nav_idx != -1 and btn.start() > nav_idx, (
|
||||
"the button belongs after the nav, inside .header-inner"
|
||||
)
|
||||
main_idx = html.find('main id="main"')
|
||||
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:
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user