feat(ui): one consistent navbar on every page (TODO.md L3)

This commit is contained in:
2026-08-26 15:39:42 -04:00
parent 0a46f07fa8
commit b2d8696741
19 changed files with 2122 additions and 678 deletions
+34 -24
View File
@@ -8,16 +8,20 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
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);
* chat / sources / viewer: brand + nav [Chat, Sources — admin only] +
New Chat + Sign in / Sign out;
* document viewer: the standard bar (row 1) + back + title + meta in a
second titlebar row (phase 34, owner confirmation 2026-08-26 — the
viewer's old "no nav" single-row bar is superseded; it now carries
the SAME nav contract as every other page);
* 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);
users on every page and shown for admin (phase-16 UX revision with
owner permission; the soft-gate page and the A10 API split are
untouched) — now on the viewer as well (phase 34);
* the bar height never moves: 64px desktop / 58px at ≤640px (phase-12
``--header-h`` contract, bounding-box measurement convention).
``--header-h`` contract, bounding-box measurement convention) — on
the viewer this is ROW 1 (``.doc-header .app-header``); the
titlebar row is content-sized.
Determinism note: every assertion is settled-state — ``assert_shared_bar``
first waits for the whoami toggle to land (exactly one of Sign in /
@@ -109,7 +113,10 @@ def _expected_h(page: Page) -> int:
def _bar_selector(page_kind: str) -> str:
return ".doc-header" if page_kind == "viewer" else ".app-header"
"""The STANDARD bar element on each page kind. Phase 34: the
viewer's header is two rows — the height contract applies to row 1
(the standard bar), not the whole two-row <header>."""
return ".doc-header .app-header" if page_kind == "viewer" else ".app-header"
def assert_shared_bar(page: Page, admin: bool, page_kind: str) -> None:
@@ -134,24 +141,26 @@ def assert_shared_bar(page: Page, admin: bool, page_kind: str) -> None:
# 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"
# Phase 34: EVERY page kind — viewer included — carries the SAME
# nav contract: the Chat link always visible; the admin-only
# #nav-sources / #nav-tuning links (phase 19 / phase 29) ship
# hidden and are revealed for admin (phase-16 UX revision, owner
# permission 2026-08-23; the soft-gate page and the A10 API split
# are untouched).
expect(page.locator(".app-nav a[href='/']")).to_be_visible()
for link_id in ("#nav-sources", "#nav-tuning"):
nav = page.locator(link_id)
assert nav.count() == 1, f"one {link_id} expected on the {page_kind} page"
if admin:
expect(nav).to_be_visible()
else:
expect(nav).to_be_hidden()
if page_kind == "viewer":
# 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)
# 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()
@@ -238,8 +247,9 @@ def test_sources_nav_hidden_for_anonymous_everywhere(
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).
# The login page carries the full shared header too (phase 34);
# for anonymous the admin-only nav links stay hidden (they ship
# hidden and are revealed only for the admin).
page.goto(app_url + "/login.html")
page.wait_for_load_state("networkidle") # the whoami round-trip has settled
nav = page.locator("#nav-sources")