feat(ui): rename nav items — "Sources" becomes "RAG", "Git sources" becomes "Sources"

Owner request (2026-08-28): the two admin-only nav items read like the
same thing, so they are relabeled — the document-catalog link
(#nav-sources, /sources.html) becomes "RAG" and the source-manager link
(#nav-git-sources, /git-sources.html) becomes "Sources".

Phase 48 (48_nav_rename_sources), label-only per the locked decision:
- all six pages (index, sources, git-sources, tuning, document, login):
  the two <a> texts swap; ids, hrefs, hidden defaults, is-active /
  aria-current placement, and nav order (Chat, RAG, Sources, Tuning)
  are byte-unchanged otherwise.
- header.js: comment/docstring label mentions only — the reveal-by-id
  logic is untouched (ship-hidden/reveal contract intact).
- test_git_sources_admin.py / test_mobile_hamburger_nav.py: the two
  suites that asserted the old label text are updated; comment-only
  label fixes in test_shared_header.py / test_nav_consistency.py.
- tests/e2e/test_nav_rename_sources.py: the story E2E (green in
  isolation) — renamed labels + unchanged hrefs/order/markers on all
  six pages, click navigation with the active marker, the anonymous
  ship-hidden contract, and regression guards for the untouched
  controls (#sync-label "Sync sources", viewer #doc-back "Sources").
- All eight surrounding header/nav suites stay green in isolation;
  unit+integration green, app/ coverage 99% (frontend-only change),
  ruff + pyright clean.

Note: per this phase file-level staging, the six page files and
header.js also carry the same-day in-flight owner rework that was
already in the working tree when phase 48 ran (mobile sign-in dropdown
copy, sync button ship-hidden on the Sources page); the label rename
itself is the two-text swap on each page.
This commit is contained in:
2026-08-28 12:33:18 -04:00
parent 03bead092c
commit 872a07cee7
17 changed files with 785 additions and 118 deletions
@@ -0,0 +1,40 @@
# Phase 48 — Nav rename: "Sources" → "RAG", "Git sources" → "Sources"
**Source:** owner request (chat, 2026-08-28) — "The 'Git sources' navbar item should be renamed to 'Sources' and the 'Sources' navbar item should be renamed to 'RAG'."
**Story:** `.agent/user_stories/nav-sources-rag-rename.md`
**Context:** the shared header (phase 19, `frontend/assets/header.js`) ships ONE nav on all six pages — `[Chat, #nav-sources "Sources" → /sources.html, #nav-git-sources "Git sources" → /git-sources.html, #nav-tuning "Tuning"]` — with the two admin-only links ship-hidden and revealed by id once `/api/whoami` says admin (phase 16 contract, phase 34 same-header-everywhere, phase 46 mobile hamburger). Both labels are plain `<a>` text in the six page files; `header.js` toggles only the `hidden` attribute and never reads the label, so the rename is markup-only.
## Objective
Relabel the two admin-only nav items across all six pages — the document-catalog link (`#nav-sources`) becomes **"RAG"** and the source-manager link (`#nav-git-sources`) becomes **"Sources"** — with ids, hrefs, order, the ship-hidden/reveal contract, and every other page control (viewer back button, Sync button, page titles/h1s) unchanged.
## Dependencies
- `34_consistent_navbar` / `19_shared_header` (complete) — the same full header block on all six pages and the `header.js` reveal-by-id contract this phase preserves.
- `35_git_sources_admin` (complete) — the `#nav-git-sources` link (and the `/git-sources.html` page) it labels.
- `46_mobile_hamburger_nav` (complete) — the `LINK_TEXTS` assertion in `test_mobile_hamburger_nav.py` that this phase updates.
## Tasks
1. `01_nav_label_swap.md` — swap the two label texts in all six page files + the label mentions in `header.js`'s docstring/comments (no logic).
2. `02_existing_test_labels.md` — update the two test suites that assert the old label text; leave every other assertion (viewer back button, sync label, h1/title markers) untouched.
3. `03_story_e2e_regression_commit.md` — the story E2E (`test_nav_rename_sources.py`), the regression suites in isolation, ruff + pyright, the one `--no-gpg-sign` commit, and the phase-dir move.
## Testing & Quality
- Unit/integration: no new backend logic (frontend markup only). The existing no-CDN integration test (`tests/integration/test_api.py::test_html_pages_served_locally_no_cdn`) must still pass — the swap touches no tags or origins (the `/git-sources.html` "Git sources" marker keeps matching the page's unchanged `<h1>`).
- Coverage: frontend-only; the `app/` >90% gate is unaffected (unchanged).
- E2E (mandatory, A16): `tests/e2e/test_nav_rename_sources.py` — the story gate, run in isolation.
## Completion Criteria
- [ ] All six pages show **"RAG"** on `#nav-sources` (href `/sources.html`) and **"Sources"** on `#nav-git-sources` (href `/git-sources.html`); nav order and `is-active`/`aria-current` placement unchanged; no id/href/`hidden` change.
- [ ] Anonymous still sees neither link; the admin sees both (contract preserved, proven by the story E2E + `test_shared_header.py`).
- [ ] `uv run pytest` green (the two updated suites included); `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged (no backend change).
- [ ] Regression E2E suites green in isolation: `test_nav_consistency.py`, `test_shared_header.py`, `test_mobile_hamburger_nav.py`, `test_git_sources_admin.py`, `test_header_consistency.py`, `test_sources_midstream_bug.py`, `test_smoke.py`.
- [ ] `uv run pytest tests/e2e/test_nav_rename_sources.py -v --no-cov` green in isolation.
- [ ] `uv run ruff check . && uv run pyright` clean (no Python behavior change, but run the gate).
- [ ] UI Structure Check (AGENTS.md rule 5): labeled links, landmarks/contrast/focus unchanged; no CDN (rule 6).
- [ ] One `--no-gpg-sign` commit staging only this phase's files; `.agent/phases/todo/48_nav_rename_sources/` moved to `.agent/phases/complete/`.
## Locked decisions
- **Label-only rename (owner request 2026-08-28)** — exactly the two nav item texts change. Element ids (`#nav-sources`, `#nav-git-sources`), hrefs, nav order, the ship-hidden/reveal contract, `header.js` behavior, and every other label (the document viewer's "Sources" back button, the "Sync sources" button, page `<title>`/`<h1>` — incl. "Knowledge base" and "Git sources") are OUT of scope. If the owner later wants the page titles/h1s to follow the nav labels, that is a follow-up phase, not this one.
- **A11 untouched** — vanilla HTML only, no CDN, no new packages, no new tags.
- **A10 untouched** — no endpoint, auth, or `header.js` logic change; the rename rides the existing reveal-by-id path.
- **No schema / migration** — purely a markup + test-label change.
- **A16 / A17 honoured** — one new story E2E suite + one atomic `--no-gpg-sign` commit.
@@ -0,0 +1,46 @@
# Task 01 — Swap the two nav label texts on all six pages
**Phase:** `48_nav_rename_sources` · **Story:** `.agent/user_stories/nav-sources-rag-rename.md`
## Objective
Rename the nav items themselves: `#nav-sources` ("Sources" → **"RAG"**) and `#nav-git-sources` ("Git sources" → **"Sources"") in the shared header markup of all six pages, plus the label mentions in `header.js`'s doc comments. Markup only — no ids, hrefs, classes, logic, or other text changes.
## Work
On **each** of the six page files — `frontend/index.html`, `frontend/sources.html`, `frontend/git-sources.html`, `frontend/tuning.html`, `frontend/document.html`, `frontend/login.html` — make exactly two text swaps inside `<nav class="app-nav" aria-label="Primary">`:
1. The catalog link — change the visible text only:
```html
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
```
becomes
```html
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
```
(On `sources.html` the link carries `class="nav-link is-active" aria-current="page"` — keep those attributes exactly as they are; swap only `Sources` → `RAG`.)
2. The manager link — change the visible text only:
```html
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
```
becomes
```html
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
```
(On `git-sources.html` the link carries `class="nav-link is-active" aria-current="page"` — keep them; swap only `Git sources` → `Sources`.)
3. `frontend/assets/header.js` — **comments only** (the reveal logic is id-based and must stay byte-identical): in the module docstring (the nav-link inventory: `"Sources" (#nav-sources, phase 19), "Git sources" (#nav-git-sources, phase 35)`) and in the inline comment above the `navGitSources` reveal, update the quoted labels to the new ones (`"RAG" (#nav-sources)`, `"Sources" (#nav-git-sources)`). No executable line of `header.js` changes.
Rules for all edits:
- Do **not** touch element ids, `href`s, `hidden` defaults, `class` attributes, `aria-current`, indentation, or any other text on the page (page `<title>`, `<h1>`, the viewer's "Sources" back button, the "Sync sources" button label — all stay).
- Do **not** add `is-active` anywhere or move the links — the physical nav order stays Chat, `#nav-sources`, `#nav-git-sources`, `#nav-tuning` (the labels just swap, so it now *reads* Chat, RAG, Sources, Tuning).
- The phase-16/19/35 ship-hidden contract is untouched: the links still ship `hidden` and `header.js` still reveals them by id for admins.
## Testing & Quality
- No new unit/integration logic (frontend markup only).
- Coverage: frontend-only; `app/` coverage unaffected.
- The no-CDN integration test (`tests/integration/test_api.py::test_html_pages_served_locally_no_cdn`) still passes — no tags or origins change; its `("/git-sources.html", "Git sources")` marker still matches that page's unchanged `<h1>Git sources</h1>`.
## Completion Criteria
- [ ] All six pages: `#nav-sources` renders text **RAG** and `#nav-git-sources` renders text **Sources**; ids/hrefs/`hidden`/`is-active` unchanged (a quick `grep -c 'id="nav-sources" hidden>RAG<'` per page returns 1, and `grep -c 'id="nav-git-sources" .*>Sources<'` returns 1).
- [ ] `git diff --stat` shows only the six HTML files + `frontend/assets/header.js` (comment-only) changed.
- [ ] `uv run pytest tests/unit tests/integration -q` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,34 @@
# Task 02 — Update the tests that assert the old nav label text
**Phase:** `48_nav_rename_sources` · **Story:** `.agent/user_stories/nav-sources-rag-rename.md`
## Objective
Keep the existing suites honest after the rename: update the (few) assertions and label-describing comments that reference the OLD label texts, without touching any assertion that is about a different control.
## Work
Only these tests assert the old label text (verified by grepping `tests/` for the strings) — update exactly these:
1. `tests/e2e/test_git_sources_admin.py` — `test_admin_nav_link_on_all_five_pages_and_click_navigates`:
- the comment `pointing at the git sources page, labeled "Git sources" — and it` → `labeled "Sources"`;
- `expect(link).to_have_text("Git sources")` → `expect(link).to_have_text("Sources")`.
- This suite has no assertion on `#nav-sources`'s TEXT (it is id-based everywhere) — do not add one here; the story E2E (task 03) owns the label checks.
2. `tests/e2e/test_mobile_hamburger_nav.py` —
- `LINK_TEXTS = ("Chat", "Sources", "Git sources", "Tuning")` → `LINK_TEXTS = ("Chat", "RAG", "Sources", "Tuning")` (DOM order unchanged — only the labels swapped);
- the comment near `test_…` that reads `Chat / Sources / Git sources / Tuning — i.e. the whoami reveal` → `Chat / RAG / Sources / Tuning …`.
3. **Comments-only drift fixes** (no assertion changes) where a suite's docstring/comment quotes the old labels as the link's identity: `tests/e2e/test_shared_header.py` (module docstring line `"the 'Sources' nav link (#nav-sources)"` → `'RAG'`), `tests/e2e/test_nav_consistency.py` (the comment `the Git sources link joined in phase 35` → mention the relabeled link), `tests/e2e/test_header_consistency.py` if it quotes the labels. Where a comment also explains the phase-35 origin, keep that history and only fix the quoted label.
**Explicitly DO NOT touch** (different controls / different text — they stay green and must stay):
- `tests/e2e/test_nav_consistency.py` lines asserting the document viewer's **back button** `span` text "Sources" / "Chat" — that is the viewer's back link (`document.js`), not the nav.
- `tests/e2e/test_document_back_navigation.py` — same back button.
- `tests/unit/test_document_viewer.py` — the `'Sources' in js` check on the viewer's back-link label.
- `tests/e2e/test_nav_consistency.py` `expect(page.locator("#sync-label")).to_have_text("Sync sources")` — the Sync button label is unchanged.
- `tests/integration/test_api.py::test_html_pages_served_locally_no_cdn` — the `("/git-sources.html", "Git sources")` marker matches the page's unchanged `<h1>`; leave it.
## Testing & Quality
- No new logic — assertion-text updates only.
- Coverage: frontend/test-only; `app/` coverage unaffected.
## Completion Criteria
- [ ] `grep -rn '"Git sources"\|Git sources' tests/e2e/*.py` shows no remaining assertion expecting the old nav label (only phase-history comments, if any, are acceptable — and none should quote it as the current label).
- [ ] `uv run pytest tests/unit tests/integration -q` green; `uv run ruff check . && uv run pyright` clean.
- [ ] The two updated E2E suites pass in isolation: `uv run pytest tests/e2e/test_git_sources_admin.py -v --no-cov` and `uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov` (DB up).
@@ -0,0 +1,33 @@
# Task 03 — Story E2E, regression suites, commit, phase move
**Phase:** `48_nav_rename_sources` · **Story:** `.agent/user_stories/nav-sources-rag-rename.md`
## Objective
Prove the renamed nav end to end with the story's dedicated Playwright suite (run in isolation per A16), confirm the surrounding header suites stay green, and land the one atomic `--no-gpg-sign` commit.
## Work
1. **Create `tests/e2e/test_nav_rename_sources.py`** (one story, one file — follow the conventions of `test_git_sources_admin.py` / `test_nav_consistency.py`: the shared `app_url` / `db_ready` fixtures, the `login(page, app_url, next=…)` helper, desktop viewport 1280×800, `expect` sync-API assertions, a module docstring stating the story + the rename). The six pages under test are `/` (chat), `/sources.html`, `/git-sources.html`, `/tuning.html`, `/login.html`, and `/document.html` (viewer — seed one document row first, reusing the seeding pattern `test_nav_consistency.py` uses for its viewer pass, and wait for `#doc-title` to settle as that file does).
Test cases:
- **admin_labels_on_all_six_pages** — signed-in admin visits each of the six pages; on each: `#nav-sources` is visible, has text exactly `RAG` and href `/sources.html`; `#nav-git-sources` is visible, has text exactly `Sources` and href `/git-sources.html`; the nav DOM order is Chat, RAG, Sources, Tuning (assert the `.app-nav a` text sequence); the current page's link carries `is-active`/`aria-current="page"` (and only it does).
- **click_navigates_with_marker** — from the chat page: click `#nav-sources` (label "RAG") → lands on `/sources.html` where `#nav-sources` is the active link; from the chat page: click `#nav-git-sources` (label "Sources") → lands on `/git-sources.html` where `#nav-git-sources` is the active link.
- **anonymous_sees_neither** — anonymous (no login): on `/` and `/login.html` both `#nav-sources` and `#nav-git-sources` are present in the DOM but hidden (ship-hidden contract unchanged), `#sign-in-link` visible.
- **untouched_controls_stay** — the rename did not leak: on `/sources.html` the Sync button still reads "Sync sources" (`#sync-label`), and on the settled viewer page the back button's span still reads "Sources" (the viewer back link is a different control — regression guard for task 02's do-not-touch list).
2. **Run the regression suites in isolation** (DB up, `--no-cov`), fixing nothing unless a test genuinely asserted a renamed nav label (if one does, update it as in task 02 and note it in the commit message):
`test_nav_consistency.py`, `test_shared_header.py`, `test_mobile_hamburger_nav.py`, `test_git_sources_admin.py`, `test_header_consistency.py`, `test_sources_midstream_bug.py`, `test_smoke.py`, `test_tuning_nav_link.py`.
3. **Full gates**: `uv run pytest` (unit + integration) green; `uv run pytest --cov=app --cov-report=term-missing` with TOTAL unchanged from the pre-phase baseline (frontend-only change); `uv run ruff check . && uv run pyright` clean.
4. **UI Structure Check (AGENTS.md rule 5)** — the relabeled links are still labeled `<a>`s inside the semantic `<nav aria-label="Primary">`; landmarks/contrast/focus-visible untouched (label text only); no CDN (rule 6 — the no-CDN integration test covers it).
5. **Commit** — one atomic commit staging exactly this phase's files (the six HTML files, `frontend/assets/header.js`, `tests/e2e/test_git_sources_admin.py`, `tests/e2e/test_mobile_hamburger_nav.py`, the comment-only test files from task 02, and the new `tests/e2e/test_nav_rename_sources.py`):
`feat(ui): rename nav items — "Sources" becomes "RAG", "Git sources" becomes "Sources"` with a body citing the owner request (2026-08-28) + phase 48. `git commit --no-gpg-sign` (repo also has `commit.gpgsign=false`).
6. **Move the phase directory**: `mv .agent/phases/todo/48_nav_rename_sources .agent/phases/complete/` and `git add -f .agent/phases/complete/48_nav_rename_sources` into the SAME commit as step 5 (`.agent/` is gitignored by design — AGENTS.md rule 8), plus the story file `git add -f .agent/user_stories/nav-sources-rag-rename.md`.
## Testing & Quality
- Story E2E: `tests/e2e/test_nav_rename_sources.py` green **in isolation**.
- Coverage: frontend-only; `app/` >90% gate unaffected (TOTAL unchanged).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_nav_rename_sources.py -v --no-cov` green in isolation (DB up).
- [ ] All eight regression suites green in isolation (commands above).
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged; `uv run ruff check . && uv run pyright` clean.
- [ ] One `--no-gpg-sign` commit containing the code/test files, the story file, and the moved phase directory; `.agent/phases/todo/` no longer lists 48.
- [ ] No behavior change in completed phases (the suites above are the proof).
@@ -0,0 +1,49 @@
# Story: Nav rename — "Sources" becomes "RAG", "Git sources" becomes "Sources"
**Phase:** `48_nav_rename_sources` · **E2E:** `tests/e2e/test_nav_rename_sources.py`
## Narrative
As **the admin (owner)**, the two admin-only nav items are confusing:
"Sources" (the document catalog) and "Git sources" (the source manager)
read like the same thing. I want the navbar to say **"RAG"** for the
document catalog page and **"Sources"** for the source manager page, so
the labels say what each page *is*: RAG = the indexed knowledge base you
chat against; Sources = where that knowledge comes from.
- **Given** I am signed in as admin, on any of the six pages (chat,
sources, git-sources, tuning, document viewer, login)
- **When** I look at the primary nav
- **Then** the catalog link (`#nav-sources`, → `/sources.html`) is
labeled **"RAG"** and the manager link (`#nav-git-sources`, →
`/git-sources.html`) is labeled **"Sources"** — same ids, same hrefs,
same order, same admin-only ship-hidden/reveal contract as before.
## Acceptance criteria
1. All six pages (`frontend/index.html`, `sources.html`,
`git-sources.html`, `tuning.html`, `document.html`, `login.html`)
carry the swapped label text on `#nav-sources` ("RAG") and
`#nav-git-sources` ("Sources"). Element ids, `href`s, `hidden`
defaults, `is-active`/`aria-current` markers, and the nav order
(Chat, RAG, Sources, Tuning) are unchanged — this is a label-only
rename, and `header.js` logic (reveal by id) is untouched.
2. Anonymous visitors still see neither link (ship-hidden contract
unchanged); the admin sees both, as before.
3. Existing test assertions on the old label text are updated
(`test_git_sources_admin.py` nav-link text,
`test_mobile_hamburger_nav.py` `LINK_TEXTS`); assertions that are
NOT about these two nav items stay untouched (the document viewer's
"Sources" back button, the "Sync sources" button label, the
`/git-sources.html` page-`<h1>`/`<title>` marker).
4. Story E2E `tests/e2e/test_nav_rename_sources.py` proves: admin sees
"RAG" (→ `/sources.html`) and "Sources" (→ `/git-sources.html`) on
all six pages; clicking each lands on the right page with
`is-active`; anonymous sees both hidden.
5. Frontend-only: unit + integration green, `app/` coverage
unaffected, ruff + pyright clean, no CDN, one `--no-gpg-sign` commit.
## Playwright Mapping Rule
`tests/e2e/test_nav_rename_sources.py` — one story, one file, run in
isolation: the two relabeled nav items' full lifecycle (labels, hrefs,
admin reveal, anonymous hide, click-navigation with the current-page
marker) on the shared header, desktop viewport.
+8 -9
View File
@@ -6,8 +6,8 @@
*
* • the Sign in / Sign out auth pair (phase 16, exactly one visible —
* decided by /api/whoami at load);
* • the admin-only nav links — "Sources" (#nav-sources, phase 19),
* "Git sources" (#nav-git-sources, phase 35) and "Tuning"
* • the admin-only nav links — "RAG" (#nav-sources, phase 19),
* "Sources" (#nav-git-sources, phase 35) and "Tuning"
* (#nav-tuning, phase 29) — phase 19 UX revision
* (owner permission 2026-08-23): hidden for anonymous on EVERY
* page, revealed for admin. Phase 34 task 03 (owner confirmation
@@ -106,17 +106,16 @@ export async function initSharedHeader() {
// a query-safe "/…" string (never "//"; ? # and spaces stay
// percent-encoded in it), so it rides in next= as-is — the same shape
// the static fallbacks use (login.js safeNext re-validates it).
const signIn = document.querySelector("#sign-in-link");
if (signIn) {
signIn.hidden = admin;
signIn.href = "/login.html?next=" + (window.location.pathname || "/");
}
document.querySelectorAll(".sign-in-link").forEach(link => {
link.hidden = admin;
link.href = "/login.html?next=" + (window.location.pathname || "/");
});
document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.hidden = !admin; });
const navSources = document.querySelector("#nav-sources");
if (navSources) navSources.hidden = !admin;
// Phase 35 (owner permission 2026-08-26): the Git sources nav link —
// Phase 35 (owner permission 2026-08-26): the Sources nav link —
// admin-only, the same ship-hidden / reveal-for-admin contract as
// the Sources link above.
// the RAG link above.
const navGitSources = document.querySelector("#nav-git-sources");
if (navGitSources) navGitSources.hidden = !admin;
// Phase 29: the Global Tuning nav link (every page from phase 34
+9 -3
View File
@@ -41,18 +41,24 @@
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>
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
link is admin-only — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above. -->
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
<!-- Phase 29 (now every page — phase 34, owner confirmation
2026-08-26): the Global Tuning link is admin-only (owner
permission 2026-08-25) — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above. -->
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" 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>
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
outside the nav; see styles.css .sign-out-mobile rules). -->
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
@@ -70,7 +76,7 @@
out is visible; /api/whoami decides at load (the shared
header module). Icon-only below 640px (aria-labels keep the
accessible names). -->
<a href="/login.html?next=/document.html" class="auth-link" id="sign-in-link" hidden>
<a href="/login.html?next=/document.html" class="auth-link sign-in-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>
+9 -3
View File
@@ -37,19 +37,25 @@
2026-08-26): the Sources link is admin-only (owner
permission 2026-08-23) — hidden by default, header.js
reveals it once whoami says admin. -->
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
link is admin-only — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above; this page IS the current one, so the
link carries is-active + aria-current like Tuning on
tuning.html. -->
<a href="/git-sources.html" class="nav-link is-active" aria-current="page" id="nav-git-sources" hidden>Git sources</a>
<a href="/git-sources.html" class="nav-link is-active" aria-current="page" id="nav-git-sources" hidden>Sources</a>
<!-- Phase 29 (now every page — phase 34, owner confirmation
2026-08-26): the Global Tuning link is admin-only (owner
permission 2026-08-25) — hidden by default, header.js
reveals it once whoami says admin. -->
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" 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>
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
outside the nav; see styles.css .sign-out-mobile rules). -->
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
@@ -67,7 +73,7 @@
out is visible; /api/whoami decides at load (the shared
header module). Icon-only below 640px (aria-labels keep the
accessible names). -->
<a href="/login.html?next=/git-sources.html" class="auth-link" id="sign-in-link" hidden>
<a href="/login.html?next=/git-sources.html" class="auth-link sign-in-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>
+9 -3
View File
@@ -31,18 +31,24 @@
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>
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
link is admin-only — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above. -->
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
<!-- Phase 29 (now every page — phase 34, owner confirmation
2026-08-26): the Global Tuning link is admin-only (owner
permission 2026-08-25) — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above. -->
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" 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>
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
outside the nav; see styles.css .sign-out-mobile rules). -->
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
@@ -60,7 +66,7 @@
out is visible; /api/whoami decides at load (the shared
header module). Icon-only below 640px (aria-labels keep the
accessible names). -->
<a href="/login.html?next=/sources.html" class="auth-link" id="sign-in-link" hidden>
<a href="/login.html?next=/sources.html" class="auth-link sign-in-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>
+9 -3
View File
@@ -32,20 +32,26 @@
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>
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
link is admin-only — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above. The phase-34 identical-header contract
requires it here too: every page's nav carries the same
four links (Chat, Sources, Git sources, Tuning). -->
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
<!-- Phase 29 (now every page — phase 34, owner confirmation
2026-08-26): the Global Tuning link is admin-only (owner
permission 2026-08-25) — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above. -->
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" 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>
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
outside the nav; see styles.css .sign-out-mobile rules). -->
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
@@ -63,7 +69,7 @@
out is visible; /api/whoami decides at load (the shared
header module). Icon-only below 640px (aria-labels keep the
accessible names). -->
<a href="/login.html?next=/login.html" class="auth-link" id="sign-in-link" hidden>
<a href="/login.html?next=/login.html" class="auth-link sign-in-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>
+11 -5
View File
@@ -31,18 +31,24 @@
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>
<a href="/sources.html" class="nav-link is-active" aria-current="page" id="nav-sources" hidden>RAG</a>
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
link is admin-only — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above. -->
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
<!-- Phase 29 (now every page — phase 34, owner confirmation
2026-08-26): the Global Tuning link is admin-only (owner
permission 2026-08-25) — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above. -->
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" 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>
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
outside the nav; see styles.css .sign-out-mobile rules). -->
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
@@ -60,7 +66,7 @@
out is visible; /api/whoami decides at load (the shared
header module). Icon-only below 640px (aria-labels keep the
accessible names). -->
<a href="/login.html?next=/sources.html" class="auth-link" id="sign-in-link" hidden>
<a href="/login.html?next=/sources.html" class="auth-link sign-in-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>
@@ -91,9 +97,9 @@
<div class="page-head">
<div class="page-head-row">
<h1>Knowledge base</h1>
<button type="button" class="sync-btn" id="sync-btn" aria-label="Sync sources">
<button type="button" class="sync-btn" id="sync-btn" aria-label="Sync sources" hidden>
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
<span class="sync-label">Sync sources</span>
<span class="sync-label" id="sync-label">Sync sources</span>
</button>
</div>
<p class="page-sub">
+9 -3
View File
@@ -31,18 +31,24 @@
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>
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
link is admin-only — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above. -->
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
<!-- Phase 29 (now every page — phase 34, owner confirmation
2026-08-26): the Global Tuning link is admin-only (owner
permission 2026-08-25) — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above. -->
<a href="/tuning.html" class="nav-link is-active" aria-current="page" id="nav-tuning" hidden>Tuning</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" 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>
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
outside the nav; see styles.css .sign-out-mobile rules). -->
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
@@ -60,7 +66,7 @@
out is visible; /api/whoami decides at load (the shared
header module). Icon-only below 640px (aria-labels keep the
accessible names). -->
<a href="/login.html?next=/tuning.html" class="auth-link" id="sign-in-link" hidden>
<a href="/login.html?next=/tuning.html" class="auth-link sign-in-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>
+3 -3
View File
@@ -82,7 +82,7 @@ REPO = Path(__file__).resolve().parents[2]
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The five pages that ship the header (phase 34 contract) — the pages
#: task 05 gave the admin-only "Git sources" nav link.
#: task 05 gave the admin-only "Sources" nav link (then "Git sources").
CHAT_URL = "/"
SOURCES_URL = "/sources.html"
VIEWER_URL = "/document.html?source=docs&path=homelab%2Fkubernetes.md"
@@ -307,7 +307,7 @@ def test_admin_nav_link_on_all_five_pages_and_click_navigates(
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
# The link is revealed (admin) on every one of the five pages,
# pointing at the git sources page, labeled "Git sources" — and it
# pointing at the git sources page, labeled "Sources" — and it
# is NOT the current page on the four non-git-sources pages.
for _name, path in FIVE_PAGES:
if path != CHAT_URL:
@@ -316,7 +316,7 @@ def test_admin_nav_link_on_all_five_pages_and_click_navigates(
link = page.locator("#nav-git-sources")
expect(link).to_be_visible(timeout=15_000)
expect(link).to_have_attribute("href", GIT_SOURCES_URL)
expect(link).to_have_text("Git sources")
expect(link).to_have_text("Sources")
if path != GIT_SOURCES_URL:
expect(link).not_to_have_class(IS_ACTIVE)
+19 -10
View File
@@ -32,7 +32,7 @@ Test → story mapping (Playwright Mapping Rule):
3. ``test_admin_menu_contents`` — admin at 375px: the menu shows all
four links (the whoami reveal works inside the menu).
4. ``test_link_click_navigates_and_closes`` — admin at 375px: clicking
"Sources" navigates to /sources.html and the menu on the arrival
"RAG" navigates to /sources.html and the menu on the arrival
page ships closed.
5. ``test_esc_and_outside_close`` — Esc closes AND returns focus to the
toggle; an outside click does NOT close (accepted — see the test
@@ -58,7 +58,7 @@ MOBILE: ViewportSize = {"width": 375, "height": 812} # the story's phone viewpo
DESKTOP: ViewportSize = {"width": 1280, "height": 800} # the conftest page size
NAV_LINKS = ("#app-nav a[href='/']", "#nav-sources", "#nav-git-sources", "#nav-tuning")
LINK_TEXTS = ("Chat", "Sources", "Git sources", "Tuning")
LINK_TEXTS = ("Chat", "RAG", "Sources", "Tuning")
def _mobile_page(browser: Browser) -> Page:
@@ -67,16 +67,25 @@ def _mobile_page(browser: Browser) -> Page:
def _wait_settled_anonymous(page: Page) -> None:
"""Wait until whoami has resolved for the anonymous visitor (Sign in
visible — the phase-16 settled state the header pins)."""
expect(page.locator("#sign-in-link")).to_be_visible(timeout=10_000)
"""Wait until whoami has resolved for the anonymous visitor: the bar
Sign in copy (``#sign-in-link``) loses its ship-hidden attribute —
the phase-16 settled state, probed by attribute (not visibility):
at ≤640px the bar copy is CSS-hidden behind the ``#sign-in-link-mobile``
dropdown copy (phase 46), so visibility is viewport-dependent."""
page.wait_for_function(
"() => !document.querySelector('#sign-in-link').hasAttribute('hidden')",
timeout=10_000,
)
def _wait_settled_admin(page: Page) -> None:
"""Wait until whoami has resolved for the admin (Sign out visible)
AND the whoami reveal has un-hidden the admin-only nav links (the
menu-contents assertions must run on a settled auth state)."""
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=10_000)
"""Wait until whoami has resolved for the admin: the whoami reveal
has un-hidden the admin-only nav links (the menu-contents assertions
must run on a settled auth state). The nav link is the
viewport-independent settled signal — the sign-out control is the
bar copy on desktop but the #sign-out-btn-mobile dropdown copy at
≤640px (phase-46 UX revision), so it is not a cross-viewport
probe."""
page.wait_for_function(
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
timeout=10_000,
@@ -205,7 +214,7 @@ def test_admin_menu_contents(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC2 (admin): at 375px the opened menu shows ALL FOUR links —
Chat / Sources / Git sources / Tuning — i.e. the whoami reveal
Chat / RAG / Sources / Tuning — i.e. the whoami reveal
works inside the menu exactly as it does inline (one <nav>, one
set of links, the same hidden attributes header.js drives)."""
page = _mobile_page(browser)
+52 -24
View File
@@ -19,19 +19,22 @@ sources, document viewer, global tuning, login): one shared markup block
Per role, the VISIBLE inventory:
* admin: brand + nav [Chat, #nav-sources, #nav-git-sources, #nav-tuning]
(four links, that order — the Git sources link joined in phase 35,
owner permission 2026-08-26) + #sync-btn + #new-chat-btn +
#sign-out-btn (with #sign-in-link hidden) — on all five pages, same
id+class inventory, same DOM order. The #steering-toggle was removed
from the navbar at owner request (2026-08-28); note management lives
on /tuning.html;
(four links, that order — the "Sources" link joined in phase 35 as
"Git sources", owner permission 2026-08-26) + #sign-out-btn (with
#sign-in-link
hidden) — on all five pages, same id+class inventory, same DOM order.
The #sync-btn (Sources page only) and the #new-chat-btn (chat page
only) left the shared bar at owner request (2026-08-28 — they are
page-specific now, so the per-page visible inventory differs for
exactly those two); the #steering-toggle was removed from the navbar
the same day; note management lives on /tuning.html;
* anonymous: brand + nav [Chat] (#nav-sources / #nav-git-sources /
#nav-tuning hidden — locked A10 UI revision) + #new-chat-btn +
#sign-in-link (with #sync-btn hidden, #sign-out-btn hidden) on all
five pages — and the steering toggle (removed at owner request,
2026-08-28) + panel are ABSENT from the DOM (the panel via the phase
16 "absent, not hidden" treatment, carried into phase 34 task 01;
test_admin_auth pins it).
#nav-tuning hidden — locked A10 UI revision) + #sign-in-link (with
#sign-out-btn hidden; the Sources page's #sync-btn stays ship-hidden)
on all five pages — and the steering toggle (removed at owner
request, 2026-08-28) + panel are ABSENT from the DOM (the panel via
the phase 16 "absent, not hidden" treatment, carried into phase 34
task 01; test_admin_auth pins it).
Normalization for the inventory comparison: the current-page ``is-active``
nav marker and the sign-in ``?next=`` value legitimately differ per page,
@@ -47,9 +50,10 @@ Steering off-chat: on /tuning.html (admin, zero notes) the navbar
carries no steering toggle (removed at owner request, 2026-08-28) — the
header #steering-panel section still ships hidden and the Tuning
page's own note list shows the empty state — no chat needed. Sync is
present, not triggered: #sync-btn is visible on /tuning.html but is
never clicked here (a real sync clones real repos — the full state
machine is test_sync_button.py's job).
present, not triggered: #sync-btn is visible on /sources.html (its
home since the owner rework 2026-08-28) but is never clicked here (a
real sync clones real repos — the full state machine is
test_sync_button.py's job).
Determinism note: every assertion is settled-state — each page visit
first waits for the whoami toggle to land (exactly one of Sign in /
@@ -65,7 +69,7 @@ Test → story mapping (Playwright Mapping Rule):
3. ``test_viewer_row1_height_matches_chat_and_titlebar_present``
4. ``test_viewer_back_link_honors_back_param``
5. ``test_steering_surface_off_chat_on_tuning_page``
6. ``test_sync_button_present_on_tuning_page_without_triggering``
6. ``test_sync_button_present_on_sources_page_without_triggering``
"""
from __future__ import annotations
@@ -177,7 +181,11 @@ def _header_inventory(page: Page) -> list[str]:
``page`` is showing (normalized — see _INVENTORY_JS)."""
inv = page.evaluate(_INVENTORY_JS)
assert inv is not None, "no `header .header-inner` on this page"
assert len(inv) >= 8, f"header control inventory unexpectedly short: {inv}"
# Shared bar: brand + the four nav links + Sign in + Sign out = 7
# (the #sync-btn / #new-chat-btn selectors keep matching nothing —
# they left the bar with the owner rework 2026-08-28; the steering
# toggle was removed the same day).
assert len(inv) >= 7, f"header control inventory unexpectedly short: {inv}"
return inv
@@ -220,8 +228,9 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
expect(page.locator(".app-nav a[href='/']")).to_be_visible() # Chat
if admin:
expect(page.locator("#nav-sources")).to_be_visible()
# Phase 35: the fourth admin-only nav link (Git sources) is
# revealed on every page, between Sources and Tuning.
# Phase 35: the fourth admin-only nav link (now "Sources",
# shipped as "Git sources") is revealed on every page, between
# RAG and Tuning.
expect(page.locator("#nav-git-sources")).to_be_visible()
expect(page.locator("#nav-tuning")).to_be_visible()
# The steering toggle was removed from the navbar at owner
@@ -229,7 +238,15 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
assert page.locator("#steering-toggle").count() == 0, (
f"{name}: the steering toggle was removed from the navbar"
)
# The Sync button is a page-specific control (Sources page only
# — owner rework 2026-08-28): visible on sources, absent from
# the shared bar everywhere else.
if name == "sources":
expect(page.locator("#sync-btn")).to_be_visible()
else:
assert page.locator("#sync-btn").count() == 0, (
f"{name}: #sync-btn left the shared bar (Sources page only)"
)
expect(page.locator("#sign-out-btn")).to_be_visible()
expect(page.locator("#sign-in-link")).to_be_hidden()
else:
@@ -252,7 +269,14 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
assert page.locator("#steering-panel").count() == 0, (
f"{name}: the steering panel must be absent for anonymous"
)
# The New chat button is chat-page only (moved from the shared bar
# to index.html's .chat-shell at owner request, 2026-08-28).
if name == "chat":
expect(page.locator("#new-chat-btn")).to_be_visible()
else:
assert page.locator("#new-chat-btn").count() == 0, (
f"{name}: #new-chat-btn left the shared bar (chat page only)"
)
_assert_landmarks(page, name)
return _header_inventory(page)
@@ -302,10 +326,12 @@ def _admin_login_page_inventory(page: Page, app_url: str) -> list[str]:
assert page.locator("#steering-toggle").count() == 0, (
"login: the steering toggle was removed from the navbar"
)
expect(page.locator("#sync-btn")).to_be_visible()
# Page-specific controls are NOT on the auth page (owner rework
# 2026-08-28: sync → Sources page, new chat → chat page).
assert page.locator("#sync-btn").count() == 0
assert page.locator("#new-chat-btn").count() == 0
expect(page.locator("#sign-out-btn")).to_be_visible()
expect(page.locator("#sign-in-link")).to_be_hidden()
expect(page.locator("#new-chat-btn")).to_be_visible()
_assert_landmarks(page, "login")
return _header_inventory(page)
finally:
@@ -482,14 +508,16 @@ def test_steering_surface_off_chat_on_tuning_page(
# ---------------------------------------------------------------------------
def test_sync_button_present_on_tuning_page_without_triggering(
def test_sync_button_present_on_sources_page_without_triggering(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
login(page, app_url, next=TUNING_URL)
expect(page).to_have_url(app_url + TUNING_URL, timeout=30_000)
# The button's home is the Sources page (owner rework 2026-08-28 —
# it left the shared navbar).
login(page, app_url, next=SOURCES_URL)
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
btn = page.locator("#sync-btn")
+400
View File
@@ -0,0 +1,400 @@
"""Phase 48 story E2E (Playwright): the nav rename — "Sources" becomes
"RAG", "Git sources" becomes "Sources".
Story: ``.agent/user_stories/nav-sources-rag-rename.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_nav_rename_sources.py -v --no-cov
Owner request (2026-08-28): the two admin-only nav items read like the
same thing, so they are relabeled — the document-catalog link
(``#nav-sources`` → /sources.html) becomes **"RAG"** and the
source-manager link (``#nav-git-sources`` → /git-sources.html) becomes
**"Sources"**. Everything else is UNCHANGED (the phase's locked
decision, label-only): element ids, hrefs, the physical nav order, the
phase-16/19/35 ship-hidden/reveal contract, ``header.js`` behavior, and
every other label on the pages — the document viewer's "Sources" back
button (a different control, phase 13) and the "Sync sources" button
(phase 32) in particular.
The six pages under test (all carry the one shared header, phase
19/34): chat (/), the RAG catalog (/sources.html), the Sources manager
(/git-sources.html), Tuning (/tuning.html), the login page
(/login.html), and the document viewer (/document.html — seeded with
one fixture document row first, the test_nav_consistency.py viewer
pattern; #doc-title must settle before any bar assertion).
Contract under test (desktop viewport 1280×800, settled whoami state —
every assertion waits for the initSharedHeader pass to land first):
* admin: on EACH of the six pages ``#nav-sources`` is visible with the
exact text "RAG" and href /sources.html, ``#nav-git-sources`` is
visible with the exact text "Sources" and href /git-sources.html,
and the nav DOM order reads Chat, RAG, Sources, Tuning; the
current page's link is the ONLY one carrying is-active +
aria-current="page" (the login and viewer pages mark none —
neither is a nav page).
* from the chat page: clicking "RAG" (``#nav-sources``) lands on
/sources.html with that link active; clicking "Sources"
(``#nav-git-sources``) lands on /git-sources.html with that link
active.
* anonymous: on / and /login.html both links are PRESENT in the DOM
(the ship-hidden contract — header.js toggles the hidden attribute,
the markup is never removed) but hidden, and #sign-in-link is
visible.
* the rename did not leak: on /sources.html the Sync button still
reads "Sync sources" (``#sync-label``), and on the settled viewer
page the back button's span still reads "Sources" (href
/sources.html).
Determinism note: the seed truncates documents/chunks/query_log/
steering_notes and re-imports the fixture docs (mock embeddings) so the
viewer URL resolves to "Kubernetes Homelab Cluster" on every run. No
chat turn is submitted and #sync-btn is never clicked.
Test → story mapping (Playwright Mapping Rule):
1. ``test_admin_labels_on_all_six_pages``
2. ``test_click_navigates_with_marker``
3. ``test_anonymous_sees_neither``
4. ``test_untouched_controls_stay``
"""
from __future__ import annotations
import asyncio
import re
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"
CHAT_URL = "/"
SOURCES_URL = "/sources.html"
GIT_SOURCES_URL = "/git-sources.html"
TUNING_URL = "/tuning.html"
LOGIN_URL = "/login.html"
#: 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"
DOC_TITLE = "Kubernetes Homelab Cluster"
#: The six pages of the app, in the story's order.
SIX_PAGES = (
("chat", CHAT_URL),
("sources", SOURCES_URL),
("git-sources", GIT_SOURCES_URL),
("tuning", TUNING_URL),
("login", LOGIN_URL),
("viewer", VIEWER_URL),
)
#: The locator of the link that carries the current-page marker on each
#: page (None — login and viewer are not nav pages — marks none).
CURRENT_LINK: dict[str, str | None] = {
"chat": ".app-nav a[href='/']",
"sources": "#nav-sources",
"git-sources": "#nav-git-sources",
"tuning": "#nav-tuning",
"login": None,
"viewer": None,
}
#: The four primary nav links, in their physical DOM order — the labels
#: after the phase-48 swap (ids/hrefs unchanged).
NAV_LABELS = ("Chat", "RAG", "Sources", "Tuning")
#: The login.js script — route pattern for the redirect suppression.
LOGIN_JS_ROUTE = re.compile(r"/assets/login\.js(\?.*)?$")
#: is-active as a word-boundary regex (to_have_class matches against the
#: whole class string — the test_git_sources_admin.py convention).
IS_ACTIVE = re.compile(r"\bis-active\b")
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 + the fixture docs so the viewer URL resolves (the
test_nav_consistency.py seeding pattern)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
db.commit()
_run_in_thread(_import_fixtures(mock_port))
def _wait_settled_admin(page: Page) -> None:
"""Wait for initSharedHeader's whoami toggle to land for a signed-in
admin: the whoami reveal has un-hidden the admin-only nav links. The
nav link is the viewport-independent settled signal (the auth pair is
the bar copy on desktop but the dropdown copy at ≤640px, phase 46) —
and the sign-in/out state settles in the SAME initSharedHeader pass.
"""
page.wait_for_function(
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
timeout=15_000,
)
def _wait_settled_anonymous(page: Page) -> None:
"""Wait for the whoami toggle to land for an anonymous visitor: the
bar Sign in copy (``#sign-in-link``) loses its ship-hidden attribute
(probed by attribute — at ≤640px the bar copy is CSS-hidden behind
the dropdown copy, phase 46)."""
page.wait_for_function(
"() => !document.querySelector('#sign-in-link').hasAttribute('hidden')",
timeout=15_000,
)
def _assert_renamed_labels(page: Page, name: str) -> None:
"""AC1 on one page: the swapped labels, the unchanged hrefs, and the
nav DOM order (Chat, RAG, Sources, Tuning) on a settled admin bar."""
rag = page.locator("#nav-sources")
expect(rag).to_be_visible(timeout=15_000)
expect(rag).to_have_text("RAG")
expect(rag).to_have_attribute("href", "/sources.html")
git = page.locator("#nav-git-sources")
expect(git).to_be_visible(timeout=15_000)
expect(git).to_have_text("Sources")
expect(git).to_have_attribute("href", "/git-sources.html")
# The four primary nav links (class nav-link) in physical DOM order.
nav_texts = page.eval_on_selector_all(
".app-nav a.nav-link", "els => els.map(e => e.textContent.trim())"
)
assert nav_texts == list(NAV_LABELS), (
f"{name}: nav link order/labels are {nav_texts}, expected {list(NAV_LABELS)}"
)
# …and the full anchor sequence of the nav (it also carries the
# phase-46 mobile sign-in copy) opens with the same four, in order.
all_texts = page.eval_on_selector_all(
".app-nav a", "els => els.map(e => e.textContent.trim())"
)
assert all_texts[:4] == list(NAV_LABELS), (
f"{name}: .app-nav anchor sequence {all_texts} does not open with "
f"Chat, RAG, Sources, Tuning"
)
def _assert_current_marker(page: Page, name: str) -> None:
"""AC1 on one page: the current page's link carries is-active +
aria-current="page" — and ONLY it does (login/viewer mark none)."""
current = CURRENT_LINK[name]
if current is None:
assert page.locator(".app-nav a.is-active").count() == 0, (
f"{name}: no nav page is current — no link may carry is-active"
)
assert page.locator('.app-nav a[aria-current="page"]').count() == 0, (
f"{name}: no nav page is current — no link may carry aria-current"
)
return
expect(page.locator(current)).to_have_class(IS_ACTIVE)
expect(page.locator(current)).to_have_attribute("aria-current", "page")
assert page.locator(".app-nav a.is-active").count() == 1, (
f"{name}: exactly one nav link may carry is-active"
)
assert page.locator('.app-nav a[aria-current="page"]').count() == 1, (
f"{name}: exactly one nav link may carry aria-current"
)
def _visit_admin_page(page: Page, app_url: str, name: str, url: str) -> None:
"""Goto a page as the signed-in admin, wait for the settled header
(and the document title on the viewer), assert the rename contract."""
page.goto(app_url + url)
_wait_settled_admin(page)
if name == "viewer":
# The document itself has settled (rendered, not Loading…/
# not-found) before any bar assertion — the test_nav_consistency
# viewer-pass pattern.
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
_assert_renamed_labels(page, name)
_assert_current_marker(page, name)
def _visit_login_as_admin(page: Page, app_url: str) -> None:
"""The login page redirects a signed-in admin away (login.js —
phase 16), so this ONE visit serves login.js with the redirect lines
suppressed (a test-local route, the test_nav_consistency.py pattern;
the page's header — settled by the same initSharedHeader pass — is
what gets measured, and the page stays put).
The browser cache is cleared first: phase 33 caches ``/assets/*``
``immutable`` for a year, and the earlier form login already fetched
the (unmodified) login.js — a cache hit would bypass the route.
"""
login_js = (REPO / "frontend" / "assets" / "login.js").read_text(encoding="utf-8")
assert "window.location.replace(safeNext())" in login_js
suppressed = login_js.replace(
"window.location.replace(safeNext())",
"window.__e2e_redirectSuppressed = true; // test: observe the header",
)
page.route(
LOGIN_JS_ROUTE,
lambda route: route.fulfill(
status=200, content_type="text/javascript", body=suppressed
),
)
try:
cdp = page.context.new_cdp_session(page)
try:
cdp.send("Network.clearBrowserCache")
finally:
cdp.detach()
page.goto(app_url + LOGIN_URL)
expect(page).to_have_url(app_url + LOGIN_URL, timeout=15_000)
_visit_admin_page(page, app_url, "login", LOGIN_URL)
finally:
page.unroute(LOGIN_JS_ROUTE)
# ---------------------------------------------------------------------------
# 1. Admin: the swapped labels, unchanged hrefs/order/markers, on all
# six pages
# ---------------------------------------------------------------------------
def test_admin_labels_on_all_six_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)
login(page, app_url, next=CHAT_URL)
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
for name, url in SIX_PAGES:
if name == "login":
_visit_login_as_admin(page, app_url)
else:
_visit_admin_page(page, app_url, name, url)
# ---------------------------------------------------------------------------
# 2. The renamed links navigate: "RAG" → /sources.html (active),
# "Sources" → /git-sources.html (active)
# ---------------------------------------------------------------------------
def test_click_navigates_with_marker(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
login(page, app_url, next=CHAT_URL)
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
_wait_settled_admin(page)
# "RAG" (the renamed catalog label) → the RAG catalog page, where
# #nav-sources is the active link.
expect(page.locator("#nav-sources")).to_have_text("RAG")
page.click("#nav-sources")
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
_wait_settled_admin(page)
_assert_current_marker(page, "sources")
# "Sources" (the renamed manager label) → the Sources manager page,
# where #nav-git-sources is the active link.
page.goto(app_url + CHAT_URL)
_wait_settled_admin(page)
expect(page.locator("#nav-git-sources")).to_have_text("Sources")
page.click("#nav-git-sources")
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
_wait_settled_admin(page)
_assert_current_marker(page, "git-sources")
# ---------------------------------------------------------------------------
# 3. Anonymous: both links present in the DOM (ship-hidden contract) but
# hidden — #sign-in-link visible
# ---------------------------------------------------------------------------
def test_anonymous_sees_neither(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
# No login: a fresh context is anonymous by construction.
for name, url in (("chat", CHAT_URL), ("login", LOGIN_URL)):
page.goto(app_url + url)
_wait_settled_anonymous(page)
rag = page.locator("#nav-sources")
git = page.locator("#nav-git-sources")
# Present in the DOM (the markup ships, header.js toggles the
# hidden attribute)…
assert rag.count() == 1, f"{name}: #nav-sources must be in the DOM"
assert git.count() == 1, f"{name}: #nav-git-sources must be in the DOM"
# …and hidden for anonymous (the ship-hidden contract, unchanged
# by the rename).
expect(rag).to_be_hidden()
expect(git).to_be_hidden()
# The reduced bar's settled sign-in control is visible.
expect(page.locator("#sign-in-link")).to_be_visible()
# ---------------------------------------------------------------------------
# 4. The rename did not leak: the Sync button label and the viewer back
# button label (different controls) are untouched
# ---------------------------------------------------------------------------
def test_untouched_controls_stay(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
# On /sources.html (admin — the button ships hidden and the page
# boot reveals it on the same cached whoami) the Sync button still
# reads "Sync sources".
login(page, app_url, next=SOURCES_URL)
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
_wait_settled_admin(page)
expect(page.locator("#sync-btn")).to_be_visible(timeout=15_000)
expect(page.locator("#sync-label")).to_have_text("Sync sources")
# Never clicked — a real sync is test_sync_button.py's job.
# On the settled viewer page the back button's span still reads
# "Sources" (the viewer back link is a different control — phase 13;
# the rename only touched the nav items).
page.goto(app_url + VIEWER_URL)
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
back = page.locator("#doc-back")
expect(back).to_have_attribute("href", "/sources.html")
expect(back.locator("span")).to_have_text("Sources")
+41 -48
View File
@@ -8,13 +8,15 @@ 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 / viewer: brand + nav [Chat, Sources — admin only] +
New Chat + Sign in / Sign out;
* chat / sources / viewer: brand + nav [Chat, RAG — admin only, + the
"Sources" link (phase 35, shipped as "Git sources")] + Sign in /
Sign out. The New Chat button is chat-page only — it left the shared
bar at owner request (2026-08-28, moved to index.html's .chat-shell);
* 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
* the "RAG" nav link (``#nav-sources``) is HIDDEN for anonymous
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);
@@ -33,7 +35,7 @@ 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``
4. ``test_new_chat_button_is_chat_page_only``
5. ``test_sign_out_from_viewer_returns_to_anonymous``
6. ``test_mobile_bar_fits_and_heights_held``
"""
@@ -136,17 +138,33 @@ def assert_shared_bar(page: Page, admin: bool, page_kind: str, mobile: bool = Fa
pinned by ``test_mobile_hamburger_nav.py`` (phase 46, task 03); this
helper pins the bar-level contract only.
"""
# Settled auth state: exactly one of Sign in / Sign out is visible
# (phase-16 semantics, now owned by the shared module).
# Settled auth state: exactly one of Sign in / Sign out is
# revealed (phase-16 semantics, now owned by the shared module).
# Both probes are viewport-independent attribute checks — at ≤640px
# the bar auth copies are CSS-hidden behind the #sign-in-link-mobile
# / #sign-out-btn-mobile dropdown copies (phase-46 UX revision), so
# visibility is not a cross-viewport probe.
if admin:
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
page.wait_for_function(
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
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)
page.wait_for_function(
"() => !document.querySelector('#sign-in-link').hasAttribute('hidden')",
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).
# The New chat button is chat-page only (moved from the shared bar
# to index.html's .chat-shell at owner request, 2026-08-28).
if page_kind == "chat":
expect(page.locator("#new-chat-btn")).to_be_visible()
else:
assert page.locator("#new-chat-btn").count() == 0, (
f"{page_kind}: the New chat button is chat-page only"
)
# Phase 34: EVERY page kind — viewer included — carries the SAME
# nav contract: the Chat link always visible; the admin-only
@@ -246,7 +264,7 @@ def test_admin_bar_on_all_pages(
# ---------------------------------------------------------------------------
# 3. The Sources nav link: hidden for anonymous everywhere, revealed
# 3. The RAG nav link: hidden for anonymous everywhere, revealed
# after a real login (a toggle, not just initial state)
# ---------------------------------------------------------------------------
@@ -282,51 +300,26 @@ def test_sources_nav_hidden_for_anonymous_everywhere(
# ---------------------------------------------------------------------------
# 4. New Chat from a non-chat page: clear the conversation, land on the
# chat empty state
# 4. The New chat button is chat-page only (owner rework 2026-08-28 —
# it left the shared bar with the 'go to the chat, fresh' behavior)
# ---------------------------------------------------------------------------
def test_new_chat_from_sources_clears_and_navigates(
page: Page, app_url: str, db_ready: None
def test_new_chat_button_is_chat_page_only(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
# 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 {}
})();"""
)
# No non-chat page carries the button anymore…
for path in (SOURCES_URL, VIEWER_URL, "/tuning.html", "/login.html", "/git-sources.html"):
page.goto(app_url + path)
expect(page.locator("#new-chat-btn")).to_have_count(0)
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"
)
# …and the chat page has exactly one (visible, inside .chat-shell).
page.goto(app_url + "/")
expect(page.locator("#new-chat-btn")).to_have_count(1)
expect(page.locator("#new-chat-btn")).to_be_visible()
# ---------------------------------------------------------------------------