chore(agent): phase roadmap from TODO.md, 3 phases (34-36)

This commit is contained in:
2026-08-26 09:41:32 -04:00
parent 8fabb7efda
commit 0a46f07fa8
20 changed files with 799 additions and 0 deletions
@@ -0,0 +1,46 @@
# Phase 34 — One Navbar on Every Page
**Source:** `TODO.md` L3 — "I want the navbar to be consistent between every page. I don't want buttons to pop in and out of existance. Just keep all those buttons active across all tabs."
**Story:** `.agent/user_stories/nav-consistency.md`
**Context:** The shared header (phase 19, `frontend/assets/header.js`) + the Tuning nav link (phase 29) already standardize nav + auth on chat / sources / tuning — but `document.html` still uses the separate `.doc-header` variant (back + title + actions, **no nav at all**), `login.html` misses the Tuning link, and two functional controls remain page-scoped: the Tuning steering toggle + panel (chat only, logic in `app.js`) and the Sync sources button (Sources only, logic in `sources.js`). Owner confirmation (2026-08-26): the bar must be identical on **all** pages — nav, Tuning toggle, Sync, New chat, and the auth pair all present everywhere; the locked A10 UI revision stays (admin-only controls hidden for anonymous, active for the admin on every tab).
## Objective
Make the header bar **identical on all five pages** (chat, sources, document viewer, tuning, login): one shared markup block, one owner of all functional control behavior (`header.js`), the viewer's back link + title preserved in a second titlebar row, and the phase-12/19 height contract (64px desktop / 58px ≤640px) applied to the standard row on every page.
## Dependencies
- `19_shared_header` (complete) — the `header.js` module, the nav/auth markup + ids, the cached-one-whoami contract, the ship-hidden/reveal-for-admin pattern.
- `29_tuning_nav_link` (complete) — the admin-only `#nav-tuning` reveal pattern this phase completes on the remaining pages.
- `15_steering_notes` + `27_global_tuning` (complete) — the steering toggle/panel logic being moved into the shared module; chat-page behavior must not change.
- `32_admin_sync_button` (complete) — the sync button state machine + `GET/POST /api/sync` being moved into the shared module; Sources-page behavior (result line + error banner) must not change.
- `13_document_back_navigation` (complete) — the `#doc-back` target-resolution behavior the viewer titlebar must preserve.
- `16_admin_auth` (complete) — the whoami gate, the soft-gate pages, the sign-out binding.
## Tasks
1. `01_steering_moves_to_module.md` — the steering toggle + panel logic moves from `app.js` into `header.js` (exported `refreshSteering()`); the chat per-bubble Tune form keeps working.
2. `02_sync_and_chat_moves_to_module.md` — the sync state machine moves from `sources.js` into `header.js` (`bor:sync-status` event); one module-owned New chat binding; the sign-in `?next=` rewrite.
3. `03_full_header_all_pages.md` — all five pages ship the identical header block; `#steering-panel` exists on every page; the viewer becomes standard row + titlebar row; login gains the full header.
4. `04_viewer_titlebar_styles.md` — the two-row viewer header styles, the sync button's failed state on non-Sources pages, theme/contrast/focus preserved.
5. `05_e2e_and_contract_update.md` — the story E2E suite `test_nav_consistency.py`; `test_header_consistency.py` + `test_shared_header.py` updated to the new viewer contract; regression pass; commit.
## Testing & Quality
- Unit/integration: frontend-only — no new `app/` logic; the no-CDN integration test (`tests/integration/test_api.py::test_index_html_served_locally`) must still pass (all new markup is same-origin, no new tags).
- Coverage: **>90%** on `app/` — unchanged by this phase (no Python change).
- E2E (mandatory, A16): `tests/e2e/test_nav_consistency.py` — the story gate, run in isolation; plus the two contract suites updated in task 05 and the regression list below.
## Completion Criteria
- [ ] The same visible header controls appear on **all five pages** in the same order — brand, nav [Chat, Sources, Tuning], Tuning toggle, Sync sources (admin), New chat, exactly one of Sign in / Sign out — verified in `test_nav_consistency.py` for both the admin and the anonymous role.
- [ ] The document viewer shows the standard bar (row 1) + back link and title (row 2); `#doc-back` target resolution (phase 13) unchanged.
- [ ] The login page carries the full header (nav incl. Tuning, Tuning toggle, Sync, New chat, auth pair).
- [ ] Chat page: the steering panel + per-bubble Tune + inline form behave exactly as before; Sources page: the sync button state machine + `#sync-result` line + `#sync-error-banner` behave exactly as before.
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged (>90%).
- [ ] Regressions green in isolation: `test_header_consistency.py`, `test_shared_header.py`, `test_document_back_navigation.py`, `test_document_viewer.py`, `test_steering.py`, `test_global_tuning.py`, `test_sync_button.py`, `test_tuning_nav_link.py`, `test_smoke.py`, `test_chat_rag.py`, `test_admin_auth.py`.
- [ ] `uv run ruff check . && uv run pyright` clean (no Python change, but run the gate).
- [ ] UI Structure Check (AGENTS.md rule 5): landmarks / labels / contrast ≥4.5:1 / focus-visible preserved; no CDN (rule 6).
- [ ] One `--no-gpg-sign` commit staging only this phase's files; `.agent/phases/todo/34_consistent_navbar/` moved to `.agent/phases/complete/`.
## Locked decisions
- **A10 UI revision preserved** — admin-only controls (Sources / Tuning nav links, Sync button) ship hidden and are revealed only for the signed-in admin; anonymous visitors get the reduced bar, identically on every page (owner confirmation 2026-08-26 — "hidden for anon, visible for admin").
- **A11 untouched** — vanilla HTML/CSS/JS, no CDN, no new packages.
- **Phase 19 module contract extended, not replaced** — `header.js` keeps the cached one-whoami-per-page promise; it gains ownership of the controls' behavior, not a second whoami.
- **Viewer bar superseded** — the phase-19 single-row viewer bar (PLAN.md §7.1 "the viewer bar = back + title + the same actions") is replaced by the two-row layout at the owner's request (this TODO). `PLAN.md` is not edited (Protocol B); this phase directory records the revision.
- **A16 / A17 honoured** — one new story E2E suite + one atomic `--no-gpg-sign` commit.
@@ -0,0 +1,33 @@
# Task 01 — Steering toggle + panel move into header.js
**Phase:** `34_consistent_navbar` · **Source:** `TODO.md:3 — "I want the navbar to be consistent between every page. I don't want buttons to pop in and out of existance. Just keep all those buttons active across all tabs."`
**Story:** `.agent/user_stories/nav-consistency.md`
## Objective
Make `frontend/assets/header.js` the owner of the steering toggle + panel behavior (today in `frontend/assets/app.js`), so the toggle can sit in every page's header (task 03) with zero page-script duplication. The chat page's behavior — panel open/close, list, count badge, per-note delete, per-bubble Tune form — must be byte-for-byte the same from the user's perspective.
## Work
1. `frontend/assets/header.js` — add the steering logic (runs at module import, like the existing sign-out binding):
- `loadSteering()` — `GET /api/steering`; non-2xx (the anonymous 403, unreachable API) → empty list (the current chat-page anonymous state); render via `renderSteeringPanel(notes)`.
- `renderSteeringPanel(notes)` — newest-first `<li class="steering-note">` rows: the note as `textContent` in a `span.steering-note-text` (XSS contract unchanged — never innerHTML for the note), a per-note Remove `button.steering-delete` with `aria-label="Delete tuning note: …"`; toggle `#steering-empty`'s `hidden` on `notes.length`; set the `#steering-count` badge text.
- `deleteSteeringNote(id, btn)` — disable the row button, `DELETE /api/steering/{id}`, re-load the list, announce through `#steering-announcer` (`role="status"`).
- The `#steering-toggle` click binding — open/close `#steering-panel`, flip `aria-expanded`, move focus into the panel on open (the chat-page a11y contract; read `app.js`'s current implementation first and mirror it exactly, including any close-on-Esc / outside-click behavior it has).
- **Export `refreshSteering()`** (fetch + render) — task 01's `app.js` change wires the per-bubble Tune form's success path to it.
- Update the file's header comment (it now owns the steering controls).
2. `frontend/assets/app.js` — remove the steering **panel** section (the `#steering-toggle` / `#steering-count` / `#steering-panel` / `#steering-list` / `#steering-empty` / `#steering-announcer` refs, `loadSteering`, `renderSteeringPanel`, `deleteSteeringNote`, `announceSteering`, the toggle binding) — **keep** the per-bubble `appendTuneButton` + `openTuneForm` (a chat-specific feature): the inline form's success path calls `refreshSteering()` imported from `./header.js` instead of the removed `loadSteering()`. Keep `TUNE_ICON` and the form's fetch/error handling untouched.
3. Update the comments that describe the panel as chat-page-owned (app.js header comment, index.html steering comments) — the panel now belongs to the shared module; index.html's markup stays for now (task 03 copies it to the other pages).
Notes:
- All elements are looked up null-safe (`querySelector` + guard) — a page that (still) lacks the panel markup is a no-op, mirroring how `initSharedHeader()` already works. This keeps the app functional between tasks.
- Do not change the steering API (`app/api/steering.py`), the panel markup in `index.html`, or the `#steering-panel` styles.
## Testing & Quality
- No Python change; the no-CDN integration test is unaffected.
- Coverage: `app/` gate unaffected (no Python change).
- The moved logic is behavior-verified by the regression suites in task 05 (`test_steering.py`, `test_global_tuning.py`); until then `uv run pytest` (unit + integration) must stay green.
## Completion Criteria
- [ ] `header.js` exports `refreshSteering()` and owns the toggle binding, panel render, per-note delete, count badge, and announcer.
- [ ] `app.js` no longer contains the panel logic; the per-bubble Tune button + inline form remain and call `refreshSteering()` on save.
- [ ] The chat page (`/`) still loads, opens, lists, and deletes steering notes exactly as before (manual smoke via the dev server or the regression suites in task 05).
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,37 @@
# Task 02 — Sync state machine + New chat + sign-in next move into header.js
**Phase:** `34_consistent_navbar` · **Source:** `TODO.md:3 — "I want the navbar to be consistent between every page. I don't want buttons to pop in and out of existance. Just keep all those buttons active across all tabs."`
**Story:** `.agent/user_stories/nav-consistency.md`
## Objective
Make `header.js` the owner of the Sync button state machine (today in `sources.js`), the single New chat binding (today duplicated across `app.js` / `sources.js` / `tuning.js` / `document.js`), and the sign-in `?next=` derivation — so the same markup on any page (task 03) behaves identically.
## Work
1. `frontend/assets/header.js` — add the sync state machine (read `sources.js`'s sync section first and mirror its contract exactly):
- **Boot (admin only):** `await fetchIsAdmin()` on the cached whoami — non-admins never poll (the status endpoint is admin-only). One `GET /api/sync/status`: `running` → enter the running state + start polling (the phase-32 reload-mid-sync re-attach); terminal → render the last result.
- **Click `#sync-btn`:** `POST /api/sync` → 202 enters running; 409 attaches to the running state (one sync at a time).
- **Poll** `GET /api/sync/status` every 2000 ms — one live timer, stopped on a terminal state. **No client-side hard timeout** (phase-32 locked decision — a sync can outlive the page; the state machine simply keeps polling).
- **Button states (§7.4 never-stale):** idle → label "Sync"; running → `disabled` + `aria-busy="true"` + spinner class (`.sync-icon.is-spinning`) + label "Syncing…"; success → label "Synced HH:MM"; failed → error state with the sanitized error string in the button's `title` + `aria-label` (on non-Sources pages that is where the failure is visible — the Sources page's own banner is driven by the event below).
- **On every state change** dispatch `window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: <status> }))` where `detail` is the `GET /api/sync/status` object — step 2 points the Sources page's banner/result line at it.
2. `frontend/assets/header.js` — **one** New chat binding (module scope, null-safe): if `#messages` exists (chat page) → `window.dispatchEvent(new CustomEvent("bor:new-chat"))` and let the page script act; otherwise `clearChatStorage()` + `location.href = "/"` (the existing non-chat behavior — "new chat" means go to the chat, fresh).
3. `frontend/assets/header.js` — **sign-in `?next=` rewrite:** in `initSharedHeader()` (or the module-scope boot), set `#sign-in-link`'s `href` to `/login.html?next=<current pathname>` (default `/`) — the admin lands back on the page they signed in from.
- ASSUMPTION: on the chat page this changes the static fallback `?next=/sources.html` to `/` at runtime — landing on the page you signed in from ("return to where you were"). The page markup keeps its current href as the no-JS fallback.
4. `frontend/assets/sources.js` — remove the sync state machine (the `#sync-btn` click handler, the 2 s poll loop, the button-state helpers, the boot re-attach). **Keep** `#sync-result` + `#sync-error-banner` rendering, now driven by a `window.addEventListener("bor:sync-status", …)` subscription: `running` → clear the result line, hide the banner; `success` → render the last-result counts in `#sync-result` (reuse the existing formatting, "added" always shown); `failed` → show `#sync-error-banner` with the error text; `idle` → hide the banner, clear the result.
5. `frontend/assets/app.js` — replace the direct `#new-chat-btn` click binding with `window.addEventListener("bor:new-chat", startNewChat)` (the `startNewChat` function itself is unchanged).
6. `frontend/assets/sources.js`, `frontend/assets/tuning.js`, `frontend/assets/document.js` — remove their `#new-chat-btn` click bindings (the module owns them). Update the file-header comments (document.js: the module now owns New chat; sources.js: sync is module-owned, the banner is event-driven).
Notes:
- Null-safe element lookups throughout (a page that doesn't (yet) have `#sync-btn` is a no-op — the app stays functional between tasks).
- The module must keep exactly **one** whoami per page load (the cached promise) — the sync boot may await it but must not add a fetch.
- Do not touch `app/api/sync.py` — the API contract is unchanged.
## Testing & Quality
- No Python change; the no-CDN integration test is unaffected.
- Coverage: `app/` gate unaffected.
- Behavior parity is verified by the regression suites in task 05 (`test_sync_button.py`, `test_shared_header.py`, `test_chat_rag.py`); until then `uv run pytest` must stay green.
## Completion Criteria
- [ ] `header.js` owns: the sync state machine (+ `bor:sync-status` event), the single New chat binding (`bor:new-chat` on chat, clear+navigate elsewhere), and the sign-in `next` rewrite.
- [ ] `sources.js` no longer contains the sync state machine — `#sync-result` / `#sync-error-banner` render off the event; no `#new-chat-btn` binding remains in any page script.
- [ ] On the Sources page the full phase-32 cycle (click → polling → success counts / failure banner, reload re-attach) still works — confirmed in task 05 via `test_sync_button.py`.
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,39 @@
# Task 03 — The identical full header on all five pages
**Phase:** `34_consistent_navbar` · **Source:** `TODO.md:3 — "I want the navbar to be consistent between every page. I don't want buttons to pop in and out of existance. Just keep all those buttons active across all tabs."`
**Story:** `.agent/user_stories/nav-consistency.md`
## Objective
Ship the **identical header block** on all five pages — brand, nav [Chat, Sources, Tuning], Tuning toggle, Sync sources, New chat, Sign in / Sign out — and the `#steering-panel` section on every page; the document viewer keeps back + title in a second titlebar row; the login page finally carries the full header.
## Work
The canonical block is `index.html`'s current header **plus** the `#sync-btn` copied verbatim from `sources.html` (hidden by default, `#sync-label` + `.sync-icon` inside). Place the Sync button **after** the Tuning toggle and **before** the New chat button on every page.
1. `frontend/index.html` (chat) — add the `#sync-btn` block to the header (the only missing control); everything else already ships. `#steering-panel` stays where it is (after `#kb-banner` in `<main>`).
2. `frontend/sources.html` — add the Tuning toggle block (copied from `index.html`: `#steering-toggle` + `#steering-count`) after the nav; add the `#steering-panel` section (copied from `index.html`, incl. the `#steering-announcer` paragraph) as the **first child of `<main>`**; keep the existing `#sync-btn` where it is.
3. `frontend/tuning.html` — add the Tuning toggle block + the `#sync-btn` block to the header (same order as chat); add the `#steering-panel` section as the first child of `<main>`.
4. `frontend/document.html` — restructure the header:
- **Row 1** becomes the standard `.app-header` / `.header-inner` bar, byte-for-byte the same block as the other pages: brand, `<nav class="app-nav">` with `Chat` + `#nav-sources` (hidden) + `#nav-tuning` (hidden), Tuning toggle, `#sync-btn` (hidden), New chat, Sign in (`?next=/document.html` static fallback) / Sign out.
- **Row 2** — a new `.doc-titlebar` container inside the same `<header>`, carrying the existing `#doc-back` link + `#doc-title` + `#doc-meta` (moved out of the old `.doc-header-inner` title block, markup otherwise unchanged — `renderDocument` addresses them by id, so `document.js` needs no render change).
- ASSUMPTION: **no nav link gets `is-active` / `aria-current` on the viewer** — a document is a detail view reachable from chat or Sources (phase 13's `back` param), so no single nav target is "current". The back link carries the navigation affordance.
- The old `.doc-header-actions` wrapper is dropped — its buttons now live in row 1's standard `.header-inner`.
5. `frontend/login.html` — full header: the nav gains the `#nav-tuning` link (after `#nav-sources`, same hidden-by-default markup as the other pages); add the Tuning toggle + `#sync-btn` + New chat + the Sign in / Sign out pair (sign-in static fallback `?next=/login.html`); add the `#steering-panel` section as the first child of `<main>`.
6. `frontend/assets/header.js` — comment updates only: the viewer now has a nav (its "the viewer has no nav" notes are stale); the module's "missing element is a no-op" contract still holds for any page missing an element. No behavior change — the reveal code already handles `#nav-sources` / `#nav-tuning` / `#sync-btn` / the auth pair wherever they exist.
7. Update the stale HTML comments in the touched headers (phase-19/29 comments describing the old page-specific layouts) to reference this phase + the owner confirmation (2026-08-26).
Rules:
- Keep every existing id / class / aria attribute exactly as it exists today (the E2E suites key off them); only ADD missing blocks and move the viewer's title elements.
- Ship-hidden stays ship-hidden: `#nav-sources`, `#nav-tuning`, `#sync-btn`, and exactly one of the auth pair are `hidden` in the markup on every page — `header.js` reveals at load (one whoami, cached).
- Preserve indentation/markup style so the five headers stay diff-identical (that identity is what task 05's E2E asserts).
## Testing & Quality
- No Python change; the no-CDN integration test must still pass (same-origin markup only).
- Coverage: `app/` gate unaffected.
- Manual smoke before task 05: with the dev server, as admin and as anonymous, each of the five pages shows the full bar (admin) / reduced bar (anonymous) with no console errors.
## Completion Criteria
- [ ] All five page headers contain the same control inventory in the same order: brand, nav [Chat, Sources, Tuning], `#steering-toggle`, `#sync-btn`, `#new-chat-btn`, `#sign-in-link` + `#sign-out-btn`.
- [ ] `#steering-panel` (+ `#steering-announcer`) exists on all five pages (chat: after `#kb-banner`; others: first child of `<main>`).
- [ ] `document.html` = standard row + `.doc-titlebar` row with `#doc-back` / `#doc-title` / `#doc-meta`; no nav link carries `is-active` there.
- [ ] `login.html` carries the full header incl. the `#nav-tuning` link and the auth pair.
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,29 @@
# Task 04 — Two-row viewer header styles + sync failed state
**Phase:** `34_consistent_navbar` · **Source:** `TODO.md:3 — "I want the navbar to be consistent between every page. I don't want buttons to pop in and out of existance. Just keep all those buttons active across all tabs."`
**Story:** `.agent/user_stories/nav-consistency.md`
## Objective
Style the document viewer's new two-row header (standard row + titlebar row) so row 1 is visually indistinguishable from the other pages' bars, and give the Sync button a visible failed state on pages that have no error banner (every page except Sources).
## Work
1. `frontend/assets/styles.css` — **viewer header:**
- The viewer's `<header>` keeps the sticky app-frame behavior; **row 1** reuses the existing `.app-header` / `.header-inner` rules verbatim (64px desktop, 58px at ≤640px — the phase-12/19 pinned heights apply because row 1 *is* the standard bar).
- New `.doc-titlebar` rules for row 2: `.container`-width inner row with `#doc-back` + the title block; its own height (title line + meta line), `border-top` separator in the existing hairline color, same surface color (`#121a2e`/`#0a0e17` family per the phase-08 palette); `#doc-title` truncates with an ellipsis + `title` attribute instead of the old pill-clipping rule.
- The old `.doc-header` / `.doc-header-inner` / `.doc-header-actions` / title-clipping rules are removed or reduced to the two-row structure (keep class names that task 05's updated contract suites reference — check which selectors the suites use before deleting: `.doc-header` may remain as the header element's class wrapping both rows).
- The `.steering-panel` positioning rules must work from the new placement on the non-chat pages (first child of `<main>`) — the panel is an in-flow section, so this is expected to be a no-op; verify visually and in the E2E.
2. `frontend/assets/styles.css` — **sync failed state (non-Sources pages):** the failed `#sync-btn` gets an error treatment from the phase-08 palette (error ink `#fca5a5` on the error surface `#2d1318`, border `#f59e0b`-free — the error chip uses `#fca5a5`/`#2d1318`, ≈9.1:1) so a failed sync is visible on every page, complementing the `title`/`aria-label` error text set by `header.js`. Reuse the existing `.sync-btn` state classes/styles if phase 32 already defines a failed look; otherwise add it.
3. `frontend/assets/styles.css` — **login page:** the full header needs no new rules (it reuses `.app-header`), but confirm the login card layout still centers correctly with the full bar (no header-height regression at ≤640px).
4. Accessibility checks (AGENTS.md rule 5): `:focus-visible` 3px outline on the new titlebar back link (it already has the existing `.doc-back` styles — preserve); contrast ≥4.5:1 for title/meta text; `prefers-reduced-motion` still stills the sync spinner.
## Testing & Quality
- CSS-only — no Python change; the no-CDN integration test is unaffected.
- Coverage: `app/` gate unaffected.
- Visual pass (dev server, admin + anonymous, desktop + 640px): all five pages, viewer row 1 identical to chat's bar; viewer row 2 shows back + title + meta; the sync button's failed state is visible and readable.
## Completion Criteria
- [ ] The viewer's row 1 renders pixel-consistent with the chat/sources bars (same height 64px / 58px, same paddings, same controls).
- [ ] `.doc-titlebar` renders back + title (ellipsis) + meta badges on one or two tidy lines; sticky with the header.
- [ ] A failed sync shows an error-colored `#sync-btn` on non-Sources pages with the sanitized error in `title` / `aria-label`.
- [ ] No console layout breakage on the login page (card still centered, 58px bar at ≤640px).
- [ ] `uv run pytest` green (no-CDN test included); `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,33 @@
# Task 05 — Story E2E + contract-suite updates + regression pass
**Phase:** `34_consistent_navbar` · **Source:** `TODO.md:3 — "I want the navbar to be consistent between every page. I don't want buttons to pop in and out of existance. Just keep all those buttons active across all tabs."`
**Story:** `.agent/user_stories/nav-consistency.md`
## Objective
Prove the contract with the story's dedicated Playwright suite — identical visible header control inventory on all five pages for each role — update the two pre-existing contract suites that encoded the old viewer bar, and run the full regression list.
## Work
1. `tests/e2e/test_nav_consistency.py` (NEW — the story gate, run in isolation). Fixtures: the standard E2E app + DB (see `tests/e2e/conftest.py`); a fixture document for the viewer URL (the `source=docs&path=homelab%2Fkubernetes.md` pattern from `test_shared_header.py`); admin session via `tests/e2e/auth_helpers.py`.
- **Admin inventory (all five pages):** `/`, `/sources.html`, `/document.html?source=…&path=…`, `/tuning.html`, `/login.html` — each header contains, visible: the three nav links (Chat, `#nav-sources`, `#nav-tuning`), `#steering-toggle`, `#sync-btn`, `#new-chat-btn`, and `#sign-out-btn` visible with `#sign-in-link` hidden. Assert the same **id + class inventory and DOM order** of the header controls on every page (normalize: the current-page `is-active` nav marker and the sign-in `?next=` value legitimately differ per page).
- **Anonymous inventory (all five pages):** nav present with Chat visible and `#nav-sources` / `#nav-tuning` hidden (locked A10 UI revision); `#sync-btn` hidden; `#sign-in-link` visible, `#sign-out-btn` hidden; `#steering-toggle` visible.
- **Viewer specifics:** row 1 height equals the chat page's header height (64px desktop / 58px ≤640px); the titlebar row is visible with `#doc-back` + `#doc-title` (rendered document title) + `#doc-meta` badges; clicking `#doc-back` honors the `back` param (phase 13 — one positive + one rejection case).
- **Steering works off-chat:** as admin, on `/tuning.html` — seed zero notes (truncate `steering_notes` via a `SessionLocal` like the other suites), click `#steering-toggle` → `#steering-panel` visible + `aria-expanded="true"` + empty state shown; add a note through the panel? (the panel has no add form — it lists notes; assert the toggle open/close cycle + the count badge reads 0) — keep this deterministic, no chat needed.
- **Sync present, not triggered:** as admin on `/tuning.html` assert `#sync-btn` is visible (do NOT click it — a real sync would clone real repos; the full state machine is `test_sync_button.py`'s job).
2. `tests/e2e/test_header_consistency.py` (UPDATE to the new contract): the viewer assertions change — `.doc-header` is now the two-row header; assert **row 1** (the standard bar) is 64px desktop / 58px ≤640px and identical to the chat/sources bars (the existing `_box_height(page, ".doc-header")` measurement must be pointed at the standard row — use the row-1 selector, e.g. `.app-header .header-inner` inside the viewer header), and assert the titlebar row is present (height > 0). The chat/sources assertions are unchanged.
3. `tests/e2e/test_shared_header.py` (UPDATE to the new contract): the "the viewer has no nav — no Sources link in the DOM" assertions flip — the viewer now carries the same nav contract (`.app-nav` with Chat + hidden `#nav-sources` + hidden `#nav-tuning`, revealed for admin). The auth-pair + New chat assertions for the viewer stay (they move from `.doc-header-actions` to the standard bar — update the selectors).
4. **Regression pass — each in isolation** (`uv run pytest tests/e2e/<file>.py -v --no-cov`): `test_header_consistency.py`, `test_shared_header.py`, `test_document_back_navigation.py`, `test_document_viewer.py`, `test_steering.py`, `test_global_tuning.py`, `test_sync_button.py`, `test_tuning_nav_link.py`, `test_smoke.py`, `test_chat_rag.py`, `test_admin_auth.py`. Fix fallout in the suites above where the old contract is encoded; fix app code where behavior genuinely changed.
5. Full gate: `uv run pytest` (unit + integration), `uv run pytest --cov=app --cov-report=term-missing` (TOTAL unchanged, >90%), `uv run ruff check . && uv run pyright`.
6. **UI Structure Check** (AGENTS.md rule 5) on the five headers + the new titlebar: landmarks (`<header>`, `<nav aria-label>`, `<main>`), labels, contrast ≥4.5:1, focus-visible, no CDN (rule 6 — the no-CDN integration test covers it).
7. **Commit** (A17): stage only this phase's files (`frontend/**`, `tests/e2e/**`), message `feat(ui): one consistent navbar on every page (TODO.md L3)`, always `--no-gpg-sign`. Move `.agent/phases/todo/34_consistent_navbar/` to `.agent/phases/complete/`.
## Testing & Quality
- E2E: `tests/e2e/test_nav_consistency.py` green **in isolation** — the story gate (A16: one story, one file).
- Unit/integration: no new `app/` logic — the existing suite (incl. the no-CDN test) stays green.
- Coverage: **>90%** on `app/` — unchanged (no Python change).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_nav_consistency.py -v --no-cov` green in isolation.
- [ ] `test_header_consistency.py` + `test_shared_header.py` updated and green; every suite in the task 05 regression list green in isolation.
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged (>90%).
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] One `--no-gpg-sign` commit; phase directory moved to `.agent/phases/complete/`.
@@ -0,0 +1,47 @@
# Phase 35 — Admin Page to Add / Remove Git Sources
**Source:** `TODO.md` L4 — "I need a page only the admin can access where I can add and remove git sources for docs"
**Story:** `.agent/user_stories/git-sources-admin.md`
**Context:** Phase 28 introduced git-based sources (`BOR_GIT_SOURCES` env var + `scripts/git_sync.clone_or_pull`) and phase 32 the one-click Sync button (`POST /api/sync`) — but the *list itself* can only be changed by editing `.env` and restarting. This phase makes the list admin-managed: a Postgres-backed table, an admin-only CRUD API, and a dedicated admin page, with the sync pipeline and `import_docs` resolving the effective list from the DB (env var demoted to an empty-table fallback).
## Objective
Deliver a page **only the admin can access** (`/git-sources.html`, soft-gated like Sources) to **add and remove git sources**, stored in a new `git_sources` table; the Sync button (phase 32) and `import_docs` (phase 28) use the stored list, `BOR_GIT_SOURCES` remains a fallback while the table is empty, and phase 32's fail-loud "no git sources configured" is preserved when both are empty.
## Dependencies
- `28_git_based_sources` (complete) — `scripts/git_sync.clone_or_pull`, `repo_name`, the `BOR_GIT_SOURCES` settings + `git_source_list`, the import resolution order (`--source` wins).
- `32_admin_sync_button` (complete) — the `POST /api/sync` / `GET /api/sync/status` pipeline this phase re-points at the DB list; the Sync button the page's hint refers to.
- `16_admin_auth` (complete) — `require_admin` (the router-level pattern from `app/api/sync.py`), the soft-gate page pattern (`sources.html`), the `fetchIsAdmin()` frontend gate.
- `34_consistent_navbar` (todo) — the identical five-page header this phase's admin-only "Git sources" nav link plugs into (phase 29 pattern).
- `29_tuning_nav_link` (complete) — the admin-only ship-hidden nav-link pattern to copy.
## Tasks
1. `01_model_and_migration.md` — `GitSource` model + migration `0006_git_sources.py` (reversible).
2. `02_git_sources_api.md` — admin-only `GET/POST /api/git-sources` + `DELETE /api/git-sources/{id}` with validation, the env-fallback listing, and the integration suite.
3. `03_sync_and_importer_use_db.md` — `effective_git_sources()` shared by `app/api/sync.py` and `scripts/import_docs.py` (DB wins, env fallback, fail-loud unchanged) + test updates.
4. `04_admin_page.md` — `/git-sources.html` + `git-sources.js` (soft-gated, list / add / remove, env note, sync hint) + styles.
5. `05_nav_link.md` — the admin-only "Git sources" nav link on all five pages + the `header.js` reveal.
6. `06_e2e_and_docs.md` — the story E2E suite `test_git_sources_admin.py`, `test_nav_consistency.py` nav-inventory update, README/`.env.example` notes, regressions, commit.
## Testing & Quality
- Unit/integration: `tests/unit/` for `effective_git_sources` (DB-wins / env-fallback / both-empty); `tests/integration/test_git_sources_api.py` for the CRUD contract (403/201/409/422/404, env fallback); the migration up/down test following the 0004/0005 pattern; the existing `test_sync_api.py` + `test_import_docs_git.py` suites stay green with the resolution indirection.
- Coverage: **>90%** on `app/` for the new module + API.
- E2E (mandatory, A16): `tests/e2e/test_git_sources_admin.py` — the story gate, run in isolation.
## Completion Criteria
- [ ] Migration 0006 applied (`uv run alembic upgrade head`); `git_sources` table exists with `url` unique.
- [ ] `GET /api/git-sources` (admin) lists DB rows; while the table is empty it returns the env list with `from_env: true`; anonymous gets 403 on all three routes.
- [ ] `POST` creates (201, trimmed, shape-validated, 409 duplicate without echoing the URL); `DELETE` removes (204/404).
- [ ] `POST /api/sync` and `import_docs` resolve the list via `effective_git_sources` (origin logged `db|env`); both-empty still fails loudly; `--source` override unchanged.
- [ ] `/git-sources.html`: anonymous sees the sign-in gate; the admin sees list + add + remove with a never-stale button and inline errors; the admin-only "Git sources" nav link is visible on all five pages for the admin and hidden for anonymous.
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run pytest tests/e2e/test_git_sources_admin.py -v --no-cov` green in isolation; regressions (task 06 list) green.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] UI Structure Check (AGENTS.md rule 5) + no CDN (rule 6).
- [ ] One `--no-gpg-sign` commit; phase directory moved to `.agent/phases/complete/`.
## Locked decisions
- **A3 / A13 honoured** — the list lives in Postgres via Alembic (no JSON file, no new store).
- **A10 extended per the phase-16 pattern** — a new admin-only router behind `require_admin`; the public API surface stays stateless; no new auth mechanism.
- **A11 untouched** — vanilla HTML/CSS/JS, no CDN, no new packages.
- **Env var demoted, not removed** — `BOR_GIT_SOURCES` keeps working exactly as today while the table is empty (the fallback); once the table has rows it is ignored (the UI is the source of truth). Phase 32's fail-loud empty-config behavior is preserved.
- **Scope boundary** — adding/removing a repo does NOT immediately clone, import, or prune: the existing Sync button performs that (removal prunes on the next sync, `prune=True`). The page's hint says so.
- **A16 / A17 honoured** — one new story E2E suite + one atomic `--no-gpg-sign` commit.
@@ -0,0 +1,46 @@
# Task 01 — GitSource model + migration 0006
**Phase:** `35_git_sources_admin` · **Source:** `TODO.md:4 — "I need a page only the admin can access where I can add and remove git sources for docs"`
**Story:** `.agent/user_stories/git-sources-admin.md`
## Objective
Add the `git_sources` table (one row per admin-managed repo URL) via the model + a reversible Alembic migration, following the exact conventions of migrations 0003–0005.
## Work
1. `app/models.py` — add the model (next to `SteeringNote`, docstring citing this phase + the A13 convention):
```python
class GitSource(Base):
"""One admin-managed git source (phase 35).
The UI-maintained list of repo URLs the Sync button (phase 32) and
import_docs (phase 28) clone/pull. DB rows win over the
BOR_GIT_SOURCES env var, which is a fallback while this table is
empty (see app.rag.git_sources.effective_git_sources).
"""
__tablename__ = "git_sources"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
url: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
added_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
```
(Import `Text` — already imported in the file; verify.)
2. `alembic/versions/0006_git_sources.py` — new migration:
- Read `alembic/versions/0005_kb_overview.py` first and chain from its actual `revision` id (the filenames are not the revision ids).
- `upgrade()`: `CREATE TABLE git_sources (id UUID PRIMARY KEY, url TEXT NOT NULL, added_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL)` + `CREATE UNIQUE INDEX uq_git_sources_url ON git_sources (url)` (use `sa.Uuid` / the same column types the other migrations use — mirror their style, including `op.create_table` kwargs and the `UniqueConstraint`-vs-index choice 0003/0004 made).
- `downgrade()`: drop the index + table.
3. Apply it to the dev database: `podman compose up -d db` (if needed) then `uv run alembic upgrade head`.
4. Migration test — follow the existing pattern (see how 0004/0005 are integration-tested — `tests/integration/` migration suite): assert 0006 up creates the table + unique constraint and down drops it (round-trip on the test DB).
## Testing & Quality
- Unit/integration: the migration up/down test above; `uv run pytest` green overall.
- Coverage: model-only for now — the `app/` gate stays >90% (models are thin).
## Completion Criteria
- [ ] `alembic/versions/0006_git_sources.py` exists, chains off 0005's real revision id, and is reversible.
- [ ] `uv run alembic upgrade head` applies cleanly on the dev DB; `git_sources` visible (`\d git_sources` equivalent).
- [ ] The 0006 up/down integration test passes; full `uv run pytest` green.
- [ ] `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,39 @@
# Task 02 — Admin-only git sources CRUD API
**Phase:** `35_git_sources_admin` · **Source:** `TODO.md:4 — "I need a page only the admin can access where I can add and remove git sources for docs"`
**Story:** `.agent/user_stories/git-sources-admin.md`
## Objective
The admin CRUD contract for the stored list: `GET /api/git-sources` (DB rows, or the env fallback while the table is empty), `POST /api/git-sources` (validated create), `DELETE /api/git-sources/{id}` — all behind `require_admin`, exactly like `app/api/sync.py`.
## Work
1. `app/schemas.py` — add (mirroring the steering schemas' style):
- `GitSourceIn` — `url: str = Field(min_length=1, max_length=500)` + a `mode="before"` trim validator (whitespace-only → 422, same trick as `SteeringNoteIn`).
- `GitSourceOut` — `id: uuid.UUID | None`, `url: str`, `added_at: datetime | None` (both nullable: env-fallback rows carry neither).
- `GitSourceList` — `sources: list[GitSourceOut]`, `from_env: bool` (`True` only when the table is empty and the list comes from `BOR_GIT_SOURCES`).
2. `app/api/git_sources.py` (NEW) — `router = APIRouter(prefix="/git-sources", tags=["git-sources"], dependencies=[Depends(require_admin)])` (copy the sync.py pattern + docstring style):
- `GET ""` → `GitSourceList`: DB rows ordered by `(added_at, id)`; if the table is empty → the `get_settings().git_source_list` env URLs as rows with `id=None, added_at=None` and `from_env=True`; `from_env=False` whenever DB rows exist (the env var is then ignored — the phase's locked decision).
- `POST ""` (201) → create:
- Shape validation (module-level `URL_RE = re.compile(r"^(https?://|ssh://|git@)")` with a docstring): the trimmed URL must match — covers the phase-28 real URLs (HTTPS + `git@` SSH); scp-style `host:repo` is deliberately rejected (422).
- ASSUMPTION: the accepted shapes are exactly `http://`, `https://`, `ssh://`, `git@…`; the 422 detail is generic ("not a valid git URL (expected https://, ssh:// or git@…)") and never echoes the input.
- Duplicate (same trimmed URL already stored) → 409 with a generic detail ("a git source with this URL already exists") — **never echo the URL** (URLs may embed `user:pass@` credentials; phase 32's masking discipline).
- Success → insert, commit, return the created `GitSourceOut`.
- `DELETE "/{source_id}"` → 204; unknown id → 404 `git source not found`.
3. `app/main.py` — register the router alongside the existing `include_router` calls (check how `sync` is included and mirror it).
4. `tests/integration/test_git_sources_api.py` (NEW) — follow `tests/integration/test_sync_api.py`'s auth pattern (`_login` via `POST /api/login` with the fixture admin password, admin client as context manager):
- Anonymous → 403 `{"detail": "admin only"}` on GET, POST, and DELETE.
- Admin + empty table + env set (monkeypatch the settings `git_sources`) → GET returns the env rows, `from_env=True`, null ids.
- Admin + empty table + env empty → GET returns `sources=[]`, `from_env=True`.
- POST: valid `https://…` → 201 + the row appears in GET with `from_env` now `False`; a duplicate → 409 and the detail contains no URL; an invalid shape (`not a url`, `host:repo`) → 422; whitespace-only / >500 chars → 422; a `git@github.com:…` URL → 201 (accepted).
- DB rows win over env: seed a row AND set the env → GET returns only the DB rows, `from_env=False`.
- DELETE: known id → 204 + gone from GET (back to env fallback if the table is now empty); unknown id → 404.
## Testing & Quality
- Integration: the suite above — the full CRUD contract, auth split, fallback semantics.
- Coverage: **>90%** on the new module (`app/api/git_sources.py` + schemas).
## Completion Criteria
- [ ] All three routes exist under `/api/git-sources`, admin-only (403 anonymous), registered in `app/main.py`.
- [ ] `tests/integration/test_git_sources_api.py` green; `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%.
- [ ] No credential-echo path: 409/422 details never contain the submitted URL (a test asserts this).
- [ ] `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,38 @@
# Task 03 — Sync + import_docs resolve the effective list (DB wins, env fallback)
**Phase:** `35_git_sources_admin` · **Source:** `TODO.md:4 — "I need a page only the admin can access where I can add and remove git sources for docs"`
**Story:** `.agent/user_stories/git-sources-admin.md`
## Objective
One shared resolver — DB rows win, `BOR_GIT_SOURCES` is a fallback only while the table is empty, fail-loud unchanged when both are empty — used by **both** the in-app sync pipeline (`app/api/sync.py::_run_sync`) and the CLI (`scripts/import_docs.py`), so the admin page's list is what actually gets cloned and indexed.
## Work
1. `app/rag/git_sources.py` (NEW) — the single resolver (importable by both the app and the CLI — `scripts` already imports `app.rag.*`):
```python
def effective_git_sources(db: Session) -> tuple[list[str], Literal["db", "env"]]:
"""(urls, origin) — DB rows in (added_at, id) order win; while the
table is empty the BOR_GIT_SOURCES env list is the fallback; both
empty → ([], "env")."""
```
- DB rows: `select(GitSource).order_by(GitSource.added_at, GitSource.id)`.
- Fallback: `get_settings().git_source_list` (the phase-28 CSV parse — reuse, don't re-implement).
2. `app/api/sync.py` — `_run_sync` replaces `settings.git_source_list` with the resolver:
- Open a `SessionLocal()` (close in `finally`) around the resolution — the background task has no request session.
- Log the origin: the existing `sync: started repos=N` line gains `origin=db|env`.
- Both-empty: keep the fail-loud `GitSyncError("no git sources configured …")` (extend the message to mention both origins — e.g. `(git_sources table empty and BOR_GIT_SOURCES unset)`; if `tests/integration/test_sync_api.py::test_no_git_sources_configured_fails_loudly` asserts the old text, update that expectation — it is this phase's file to update).
3. `scripts/import_docs.py` — the git-URL resolution branch (today `settings.git_source_list`) resolves via the same function: open a short `SessionLocal()` at resolution time (the import needs the DB anyway — no DB-down fallback to design). The `--source` override still wins (manual mode), the log line records the origin (`git sources: N repo(s) origin=db|env` before the clone loop).
4. **Test updates:**
- `tests/unit/` — NEW unit tests for `effective_git_sources`: DB rows win (seeded table + env set → DB list, origin `db`); env fallback (empty table + env set); both empty → `([], "env")`. (Use a test DB session or a stubbed session following the existing unit-test conventions.)
- `tests/integration/test_sync_api.py` — where it stubs `settings.git_source_list` to drive sync scenarios, keep those scenarios working through the new indirection: either seed the `git_sources` table or monkeypatch `effective_git_sources` (whichever the file's existing fixture style favors); add one scenario asserting a DB row is used over the env when both are set (clone/import mocked — the file already mocks them).
- `tests/integration/test_import_docs_git.py` — same treatment for the CLI path (`--source` override scenario untouched); add the DB-over-env scenario at the CLI level.
## Testing & Quality
- Unit: `effective_git_sources` — all three branches.
- Integration: sync + import_docs suites green with the resolver in the path, incl. the new DB-over-env scenarios and the updated fail-loud expectation.
- Coverage: **>90%** on the new module + the modified call sites.
## Completion Criteria
- [ ] `effective_git_sources` exists in `app/rag/git_sources.py` and is the ONLY place (besides the API's GET fallback, which may call it too) that combines DB + env.
- [ ] `_run_sync` and `import_docs` both resolve through it; origin visible in their logs.
- [ ] Both-empty still raises the fail-loud error (sync) / the CLI's existing no-sources behavior (import_docs) — assertions kept/updated.
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,41 @@
# Task 04 — The /git-sources.html admin page
**Phase:** `35_git_sources_admin` · **Source:** `TODO.md:4 — "I need a page only the admin can access where I can add and remove git sources for docs"`
**Story:** `.agent/user_stories/git-sources-admin.md`
## Objective
The page itself: a soft-gated (anonymous → sign-in gate, exactly like `sources.html`) full-width manager with an add form, a sources list with per-row Remove, an env-fallback note, and a hint pointing at the Sync button. Never-stale buttons, inline errors, WCAG 2.1 AA basics, dark tech theme.
## Work
1. `frontend/git-sources.html` (NEW) — modeled on `frontend/sources.html` (same frame, same gate pattern, the standard full header from phase 34):
- Head: same meta/favicon/stylesheet pattern; `<title>Git sources · Brain of Reese</title>`; skip-link; the identical header block (nav [Chat, Sources, Tuning] + Tuning toggle + `#sync-btn` + New chat + auth pair — the "Git sources" nav link itself arrives in task 05, so this file lands without it for now).
- `<main id="main">` — `#steering-panel` first (phase 34 contract), then:
- **Gate** `#git-sources-gate` — the `#sources-gate` soft-gate markup pattern (sign-in card + link `/login.html?next=/git-sources.html`), visible for anonymous, hidden for admin.
- **Content** `#git-sources-content` (hidden until admin):
- `.page-head` — `<h1>Git sources</h1>` + sub: "The repositories the Sync button clones and indexes. Add or remove them here — no `.env`, no restart."
- **Env note** `#git-sources-env-note` (hidden by default; shown when the API returns `from_env: true`): "These sources currently come from `BOR_GIT_SOURCES` in `.env` — adding or removing one here switches management to the database."
- **Add form** `#git-source-form` — visible label (or visually-hidden label per the tuning-page pattern — use a visible `<label for="git-source-url">Add a git source</label>`), input `#git-source-url` (type text, `maxlength="500"`, `autocomplete="off"`, placeholder `https://github.com/you/homelab.git`, `required`), submit button `#git-source-add` ("Add source"), error line `#git-source-error` (`role="alert"`, hidden) — §7.4 never-stale: the button disables + label changes while the POST is in flight, re-enables on success/failure (the form is kept on failure, same as the tuning forms).
- **List** `#git-sources-list` (a full-width table or list per §7.1 — **no skinny single-column list**: use the Sources-page table pattern — columns: URL (mono `<code>`), Added, actions) + empty state `#git-sources-empty` ("No git sources stored yet." — and, with `from_env`, the env note already explains where the active list comes from).
- **Hint box** (`role="note"`): "Use the **Sync sources** button in the header (or on the Sources page) to clone the repos and refresh the index — removing a repository prunes its documents from the index on the next sync."
2. `frontend/assets/git-sources.js` (NEW) — the page module (loaded `type="module"`, imports `./header.js` like its siblings):
- Boot: `const admin = await initSharedHeader()` (one cached whoami) — anonymous → show the gate, stop; admin → hide the gate, `loadSources()`.
- `loadSources()` — `GET /api/git-sources` → render the table rows (`textContent` only — URLs may contain credentials; never innerHTML the URL), the added date (localized, `—` for null), the per-row Remove button (`.git-source-remove`, `aria-label="Remove git source: <url>"`), the env note's `hidden` on `from_env`, the empty state. Non-2xx → the content area shows a `role="alert"` error state with a retry (never a stuck page).
- Add submit — client-side non-empty check; disable `#git-source-add` (label "Adding…"); `POST /api/git-sources` with `{url}`; success → clear the input, re-enable (label "Add source"), `loadSources()`, focus the new row (a11y); failure → `#git-source-error` with the server detail (422 shape-aware like the tuning forms), re-enable, input kept.
- Remove click — `window.confirm("Remove this git source from the list? Its documents stay indexed until the next sync prunes them.")` — cancel → nothing; ok → disable the row button, `DELETE /api/git-sources/{id}`, `loadSources()`; failure → row error state + re-enable.
- Focus management + keyboard: all controls focus-visible (theme CSS covers it), the list rows' buttons are real `<button>`s.
3. `frontend/assets/styles.css` — the `.git-source-*` rules + gate reuse:
- The table: full-width in the 72rem container (the Sources-page table styles are a good starting point — reuse classes where they fit), mono URL cells with horizontal scroll on overflow (long URLs with credentials), rows ≥44px touch targets, `:focus-visible` 3px outline.
- Env note: an info chip in the theme palette (brand-soft `#232b52` surface, brand-ink `#a5b4fc` text ≈6.9:1); hint box: the page-sub styling family; error/alert states reuse the existing `#fca5a5`/`#2d1318` error treatment.
- Gate: reuse the `#sources-gate` styles (the page is the same shape as Sources — one gate visual language).
4. `frontend/assets/header.js` — no change (the page's controls are the standard shared ones; `initSharedHeader` already handles everything that ships in the header).
## Testing & Quality
- Frontend-only — no Python change this task; the no-CDN integration test must still pass (same-origin markup; the E2E in task 06 exercises the page).
- Coverage: `app/` gate unaffected.
- Manual smoke (dev server, signed in): add a real-looking URL → row appears; remove → confirm → row gone; invalid URL → inline 422 error, button re-enabled; signed out → gate only.
## Completion Criteria
- [ ] `/git-sources.html` served at the route; anonymous sees only the gate (list/form absent or inert); admin sees list + form + env note + hint.
- [ ] Add / remove round-trip works against the task-02 API; every in-flight state disables its control and re-enables on resolution (never stale); errors are inline `role="alert"`.
- [ ] UI Structure Check (AGENTS.md rule 5): landmarks, labeled controls, contrast ≥4.5:1, focus-visible; full-width table (no skinny list); no CDN (rule 6).
- [ ] `uv run pytest` green (no-CDN test); `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,32 @@
# Task 05 — Admin-only "Git sources" nav link on all five pages
**Phase:** `35_git_sources_admin` · **Source:** `TODO.md:4 — "I need a page only the admin can access where I can add and remove git sources for docs"`
**Story:** `.agent/user_stories/git-sources-admin.md`
## Objective
Make the new page reachable from everywhere: the admin-only **"Git sources"** nav link in all five identical headers (phase 34's contract), revealed for the admin by `header.js` — the exact phase-29 pattern.
## Work
1. The five page headers — `frontend/index.html`, `frontend/sources.html`, `frontend/document.html`, `frontend/tuning.html`, `frontend/git-sources.html` — inside `<nav class="app-nav" aria-label="Primary">`, **immediately after** the `#nav-sources` link, add (mirroring the `#nav-tuning` markup, comment citing this phase + owner permission 2026-08-26):
```html
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
```
- Rules (phase-29 contract): `hidden` by default on every page (anonymous-safe ship-hidden); NO `is-active` / `aria-current` on the four pages that aren't the Git sources page.
- **Exception:** on `frontend/git-sources.html` the link carries `class="nav-link is-active"` + `aria-current="page"` (the current page, like Tuning on `tuning.html`).
- Nav order on every page becomes: Chat, Sources, **Git sources**, Tuning.
2. `frontend/assets/header.js` — next to the `navTuning` reveal block, add the same ship-hidden/reveal-for-admin contract:
```js
const navGitSources = document.querySelector("#nav-git-sources");
if (navGitSources) navGitSources.hidden = !admin;
```
Update the file-header comment (the admin-only link list now includes Git sources).
## Testing & Quality
- Frontend-only — no Python change; the no-CDN integration test is unaffected (same-origin `<a>`).
- Coverage: `app/` gate unaffected.
- Manual smoke: admin sees "Git sources" on all five pages → each navigates to `/git-sources.html` (with `is-active` there); anonymous never sees it (ships hidden, no flash).
## Completion Criteria
- [ ] All five headers contain `#nav-git-sources` after `#nav-sources`, `hidden` by default; `git-sources.html`'s carries `is-active` + `aria-current="page"`.
- [ ] `header.js` reveals it for the admin on the cached whoami (no extra request) and hides it for anonymous on every page.
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,37 @@
# Task 06 — Story E2E + nav-inventory update + docs + commit
**Phase:** `35_git_sources_admin` · **Source:** `TODO.md:4 — "I need a page only the admin can access where I can add and remove git sources for docs"`
**Story:** `.agent/user_stories/git-sources-admin.md`
## Objective
Prove the story end-to-end with its dedicated Playwright suite, keep phase 34's nav-consistency contract in sync with the new link, document the env-var demotion, and close the phase with the full gate + one commit.
## Work
1. `tests/e2e/test_git_sources_admin.py` (NEW — the story gate, run in isolation). Fixtures: the standard E2E app + DB (`tests/e2e/conftest.py`); admin session via `tests/e2e/auth_helpers.py`; **no git, no network** — this suite is UI + API only (the clone/import path is mocked at the integration level, task 03).
- **Anonymous:** `/git-sources.html` shows the sign-in gate (list + add form absent/inert); `#nav-git-sources` hidden on the five pages; the API 403s (assert via the page context's `fetch` or `context.request` — follow `test_admin_auth.py`'s pattern for anonymous API assertions).
- **Admin — nav:** `#nav-git-sources` visible on all five pages; clicking it from `/` lands on `/git-sources.html` with the link `is-active`.
- **Admin — list:** seed two rows via the API (or `SessionLocal`) before load → both rows render (mono URL text, added date); the env note is hidden (DB rows exist).
- **Admin — add:** submit `https://example.com/reese/new-repo.git` → the row appears, the input clears, the button re-enables (never stale); submit a duplicate → inline `role="alert"` error, no new row, button re-enabled; submit `not a valid url` → inline 422 error, button re-enabled.
- **Admin — remove:** click a row's Remove → accept the confirm dialog (Playwright `page.on("dialog")`) → the row disappears; cancel a second removal → the row stays.
- **Admin — env fallback:** truncate `git_sources`, set the E2E app's `BOR_GIT_SOURCES` (follow how `test_sync_button.py` controls the env on the app fixture), reload → the env rows render + `#git-sources-env-note` visible.
- The sync-origin behavior (DB over env in the pipeline) is integration-level (task 03) — do not trigger a real sync in this suite.
2. `tests/e2e/test_nav_consistency.py` (phase 34 — UPDATE): the admin nav inventory now includes **"Git sources"** (four links, order Chat, Sources, Git sources, Tuning); the anonymous hidden set gains `#nav-git-sources`; the per-page inventory comparison stays order-sensitive.
3. **Docs:**
- `.env.example` — the `BOR_GIT_SOURCES` comment: now the **empty-table fallback**; the primary management UI is the admin Git sources page (phase 35).
- `README.md` — the import/update workflow section: the git-sources list is managed on the admin page (stored in Postgres); `BOR_GIT_SOURCES` only applies while that list is empty; `--source` still overrides for manual runs.
4. **Regression pass — each in isolation** (`uv run pytest tests/e2e/<file>.py -v --no-cov`): `test_git_sources_admin.py` (new), `test_nav_consistency.py` (updated), `test_sync_button.py`, `test_shared_header.py`, `test_header_consistency.py`, `test_tuning_nav_link.py`, `test_smoke.py`.
5. Full gate: `uv run pytest`, `uv run pytest --cov=app --cov-report=term-missing` (>90%), `uv run ruff check . && uv run pyright`.
6. **UI Structure Check** (AGENTS.md rule 5) on the new page (full-width table, labels, contrast, focus-visible, aria-live on the list updates) + no CDN (rule 6).
7. **Commit** (A17): stage this phase's files (`app/**`, `alembic/**`, `frontend/**`, `tests/**`, `README.md`, `.env.example`), message `feat(sources): admin page to add and remove git sources (TODO.md L4)`, always `--no-gpg-sign`. Move `.agent/phases/todo/35_git_sources_admin/` to `.agent/phases/complete/`.
## Testing & Quality
- E2E: `tests/e2e/test_git_sources_admin.py` green **in isolation** (A16: one story, one file).
- Unit/integration: from tasks 01–03 — all green under `uv run pytest`.
- Coverage: **>90%** on `app/` (new API module + resolver fully covered).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_git_sources_admin.py -v --no-cov` green in isolation.
- [ ] `test_nav_consistency.py` updated for the fourth nav link and green; every suite in the task 06 regression list green in isolation.
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
- [ ] README + `.env.example` document the fallback semantics.
- [ ] One `--no-gpg-sign` commit; phase directory moved to `.agent/phases/complete/`.
@@ -0,0 +1,40 @@
# Phase 36 — Document Summary Shown Together With the Original
**Source:** `TODO.md` L5 — "When I click on a document with a summary I should be able to see the summary and the original document together."
**Story:** `.agent/user_stories/summary-in-viewer.md`
**Context:** Phase 30 stores a lite-model summary on `documents.summary` for every non-markdown document (plus an indexed `is_summary` chunk) — but the viewer never shows it: `GET /api/documents/content` omits the field and the shared renderer `renderDocument` (`frontend/assets/document.js`, used by BOTH the full-page viewer `document.html` and the chat/sources modal, phase 26) only renders the raw content. Markdown documents carry no summary (phase 30) and must render exactly as before.
## Objective
When a document **has** a summary, show it and the original content together — a labeled Summary panel above the content, on both viewer surfaces at once (shared renderer); documents without a summary are unchanged.
## Dependencies
- `30_document_summaries` (complete) — the `documents.summary` column (migration 0004), the summarizer, the `summary_kb` E2E fixture + the deterministic mock `SUMMARY_MODE` digest.
- `10_story_document_viewer` + `26_document_modal_viewer` (complete) — the viewer page, the modal, and the shared `renderDocument(doc, {titleEl, metaEl, contentEl})` core both surfaces render through.
- `16_admin_auth` (complete) — the soft rule this phase must not touch: the content endpoint stays public + stateless (catalog gated, viewer public).
## Tasks
1. `01_content_api_summary_field.md` — `DocContent.summary` + the endpoint returns it; integration tests.
2. `02_viewer_summary_panel.md` — the shared renderer draws the `.doc-summary` panel (both surfaces) + theme-matched styles.
3. `03_e2e_and_regression.md` — the story E2E suite `test_summary_in_viewer.py`; regressions; full gate; commit.
## Testing & Quality
- Unit/integration: the content endpoint returns the summary for a summarized non-markdown doc and `null` for a markdown doc; anonymous access unchanged.
- Coverage: **>90%** on `app/` (the touched endpoint stays covered).
- E2E (mandatory, A16): `tests/e2e/test_summary_in_viewer.py` — the story gate, run in isolation.
## Completion Criteria
- [ ] `GET /api/documents/content` returns `summary` (string or null); no auth/shape change beyond the added nullable field; the endpoint is still public.
- [ ] A summarized document shows the labeled Summary panel **above** the original content in the full-page viewer AND the modal; the original content (including content the summary digest doesn't contain) is fully visible.
- [ ] A markdown document (no summary) renders exactly as before on both surfaces — no empty panel.
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run pytest tests/e2e/test_summary_in_viewer.py -v --no-cov` green in isolation; regressions (task 03 list) green.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] UI Structure Check (AGENTS.md rule 5): the panel is a labeled section, contrast ≥4.5:1, no CDN (rule 6).
- [ ] One `--no-gpg-sign` commit; phase directory moved to `.agent/phases/complete/`.
## Locked decisions
- **A7 / A15 untouched** — retrieval, context assembly, and the SSE contract are unchanged; this is a display + API-field phase.
- **Phase 16 soft rule untouched** — `GET /api/documents/content` stays public + stateless (anyone who can open a document sees its summary; the catalog stays admin-gated).
- **Phase 30 untouched** — summaries are still generated at import, still markdown-excluded, still fail-soft (NULL possible); this phase only surfaces the existing field.
- **Shared-renderer principle (phase 26)** — the panel is drawn in `renderDocument`, so the page and the modal can never drift.
- **A11 untouched** — vanilla HTML/CSS/JS, no CDN, no new packages; summary text rendered with `textContent` (XSS contract unchanged).
- **A16 / A17 honoured** — one new story E2E suite + one atomic `--no-gpg-sign` commit.
@@ -0,0 +1,32 @@
# Task 01 — The content endpoint returns the summary
**Phase:** `36_summary_in_viewer` · **Source:** `TODO.md:5 — "When I click on a document with a summary I should be able to see the summary and the original document together."`
**Story:** `.agent/user_stories/summary-in-viewer.md`
## Objective
Surface the existing `documents.summary` field (phase 30) on the viewer's data contract: `DocContent` gains a nullable `summary` and `GET /api/documents/content` returns it — endpoint stays public, stateless, and otherwise byte-identical.
## Work
1. `app/schemas.py` — `DocContent` gains:
```python
#: Lite-model summary (phase 30) — non-markdown A9 docs only; None for
#: markdown documents, pre-phase-30 rows, and the fail-soft path where
#: summary generation failed but the document was still indexed.
summary: str | None = None
```
(Place it after `format` / before `content`, with the docstring mirroring `Document.summary`'s.)
2. `app/api/docs.py` — `get_document_content` returns `summary=doc.summary` in the `DocContent(...)` construction. Nothing else changes (no auth, no query change — `Document` is already selected in full).
3. Integration tests — extend the existing `/api/documents/content` test module (find it in `tests/integration/` — the phase-10 content-endpoint tests):
- A non-markdown document row seeded with `summary="…"` → response JSON carries `summary` verbatim.
- A markdown document row with `summary=None` → `"summary": null`.
- Anonymous (no admin cookie) still gets 200 (the phase-16 soft rule — public viewer) for both.
- The existing assertions (404 on unknown pair, content/format fields) stay green unmodified.
## Testing & Quality
- Integration: the cases above; `uv run pytest` green overall.
- Coverage: **>90%** on `app/` — the touched endpoint stays covered (the new field is exercised by the new assertions).
## Completion Criteria
- [ ] `GET /api/documents/content?source=…&path=…` returns the summary for a summarized doc and `null` for a markdown doc; anonymous access unchanged (200).
- [ ] No other field, status code, or auth behavior of the endpoint changed.
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,44 @@
# Task 02 — The shared renderer draws the Summary panel (both surfaces)
**Phase:** `36_summary_in_viewer` · **Source:** `TODO.md:5 — "When I click on a document with a summary I should be able to see the summary and the original document together."`
**Story:** `.agent/user_stories/summary-in-viewer.md`
## Objective
`renderDocument` — the single rendering core both the full-page viewer (`document.html`) and the chat/sources modal (`document-modal.js`) go through (phase 26) — draws a labeled Summary panel above the original content whenever `doc.summary` is non-empty; `null`/empty renders nothing, so markdown documents and fail-soft rows are byte-for-byte unchanged.
## Work
1. `frontend/assets/document.js` — in `renderDocument(doc, { titleEl, metaEl, contentEl })`, **after** the meta row is populated and **before** the content is built, append the summary section to `contentEl` (which is then filled with the usual `.doc-md` / `<pre class="doc-raw">` content after it):
```js
if (doc.summary && doc.summary.trim() !== "") {
const section = document.createElement("section");
section.className = "doc-summary";
section.setAttribute("aria-label", "Summary");
const title = document.createElement("h2");
title.className = "doc-summary-title";
title.textContent = "Summary";
const body = document.createElement("p");
body.className = "doc-summary-text";
body.textContent = doc.summary; // text node — XSS contract unchanged
section.append(title, body);
contentEl.appendChild(section);
}
```
- The meta badge row (`#doc-meta` / `#doc-modal-meta`) is untouched; the panel sits between meta and content on **both** surfaces because both call this one function.
- Update the file-header comment (the renderer now also owns the optional summary panel) and the `renderDocument` doc comment.
2. `frontend/assets/styles.css` — `.doc-summary` (dark tech theme, phase-08 palette):
- A clearly-distinct "summary, not content" look: surface `#121a2e` with a 3px brand left border (`#6d78f2`) or a brand-soft (`#232b52`) header strip — pick one and keep it simple; `border-radius` matching the existing content cards; padding ~1rem; `margin-bottom` separating it from the content.
- `.doc-summary-title` — small-caps/label treatment: `#a5b4fc` (brand-ink on the brand-soft chip, ≈6.9:1) or `#a5b4fc` on surface (verify ≥4.5:1 — if short, use the chip).
- `.doc-summary-text` — `var(--ink)` (`#e8ebf4`) on the surface (≈14.5:1); wraps inside the same width the content uses (the ≤46rem centered column for md docs, the raw-content width otherwise — the panel is a child of `contentEl`, so it inherits the column; verify for the `<pre class="doc-raw">` case where the content is wider).
- No animation (nothing for `prefers-reduced-motion` to still); the section is static content — no focusability needed (it carries `aria-label` + heading).
3. Do NOT touch `document-modal.js` (it calls `renderDocument` — the panel comes for free), `markdown.js`, or the page scripts.
## Testing & Quality
- Frontend-only — no Python change; the no-CDN integration test is unaffected.
- Coverage: `app/` gate unaffected.
- Manual smoke (dev server with an imported non-md doc, e.g. a yaml from the fixture KB via the importer + mock, or a hand-seeded `documents.summary` row): modal from the Sources table AND the full page both show panel + content; a markdown doc shows no panel.
## Completion Criteria
- [ ] A document with a summary shows the labeled panel above the original content in **both** the modal and the full-page viewer; the original content is fully visible (nothing hidden/collapsed).
- [ ] A document without a summary (`null` or empty/whitespace) renders exactly as before on both surfaces — no panel, no empty box.
- [ ] The summary text is written with `textContent` (XSS contract); the meta row is unchanged.
- [ ] `uv run pytest` green (no-CDN test); `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,31 @@
# Task 03 — Story E2E + regression pass + commit
**Phase:** `36_summary_in_viewer` · **Source:** `TODO.md:5 — "When I click on a document with a summary I should be able to see the summary and the original document together."`
**Story:** `.agent/user_stories/summary-in-viewer.md`
## Objective
Prove the story end-to-end with its dedicated Playwright suite — summary panel + original content visible together on both surfaces, no panel for markdown docs — then the full gate + one commit.
## Work
1. `tests/e2e/test_summary_in_viewer.py` (NEW — the story gate, run in isolation). Reuse `tests/e2e/test_document_summaries.py`'s machinery as closely as possible (its fixture KB + import helpers are the reference):
- Fixtures: the standard E2E app + DB + the deterministic mock LLM (`tests/e2e/mock_llm.py` — its `SUMMARY_MODE` answer is a byte-stable 24-token digest); import `tests/fixtures/summary_kb/` so `quadlet/qwen-llamacpp.yaml` gets its stored summary + `notes/qwen-llamacpp-notes.md` stays summary-less (the existing tail sentinel `RESE-SUMMARY-SENTINEL-7f3a` sits on the yaml's LAST line — **outside** the 24-token digest, so it is a marker for "the original, not the summary").
- **Full page:** open `/document.html?source=summary_kb&path=quadlet%2Fqwen-llamacpp.yaml` → `.doc-summary` visible with the deterministic digest text AND the original content visible with the sentinel (`RESE-SUMMARY-SENTINEL-7f3a` present in the rendered content) — summary and original **together**.
- **Modal:** from the Sources table (admin session via `tests/e2e/auth_helpers.py`), click the yaml's row → the modal shows the same panel + content (sentinel present, digest present); then "Full page" still lands on the dedicated page with the panel (the two surfaces agree).
- **No-summary control:** the markdown doc (`notes/qwen-llamacpp-notes.md`) → no `.doc-summary` element on the full page and in the modal; the content renders as before.
- **API shape (cheap, via the page context's `fetch` or `context.request`):** `GET /api/documents/content` for the yaml carries `summary` (string), for the md doc `null`; anonymous fetch → 200 (soft rule unchanged).
- The E2E must not depend on a real LLM (the mock's digest is deterministic — the same pattern `test_document_summaries.py` relies on).
2. **Regression pass — each in isolation** (`uv run pytest tests/e2e/<file>.py -v --no-cov`): `test_summary_in_viewer.py` (new), `test_document_viewer.py`, `test_document_summaries.py`, `test_document_back_navigation.py`, `test_chat_rag.py` (the source-chip modal path), `test_smoke.py`.
3. Full gate: `uv run pytest`, `uv run pytest --cov=app --cov-report=term-missing` (>90%), `uv run ruff check . && uv run pyright`.
4. **UI Structure Check** (AGENTS.md rule 5): the panel is a labeled section (`aria-label` + heading), theme contrast ≥4.5:1, it does not break the centered 46rem chat-column-width content layout, no CDN (rule 6 — the no-CDN integration test covers it).
5. **Commit** (A17): stage this phase's files (`app/schemas.py`, `app/api/docs.py`, `frontend/assets/document.js`, `frontend/assets/styles.css`, `tests/**`), message `feat(viewer): show document summary together with the original (TODO.md L5)`, always `--no-gpg-sign`. Move `.agent/phases/todo/36_summary_in_viewer/` to `.agent/phases/complete/`.
## Testing & Quality
- E2E: `tests/e2e/test_summary_in_viewer.py` green **in isolation** (A16: one story, one file).
- Unit/integration: from task 01 — all green under `uv run pytest`.
- Coverage: **>90%** on `app/` (the endpoint change is covered).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_summary_in_viewer.py -v --no-cov` green in isolation.
- [ ] Every suite in the task 03 regression list green in isolation.
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
- [ ] One `--no-gpg-sign` commit; phase directory moved to `.agent/phases/complete/`.
+53
View File
@@ -0,0 +1,53 @@
# Story: Admin Page to Add / Remove Git Sources
**Phase:** `35_git_sources_admin` · **E2E:** `tests/e2e/test_git_sources_admin.py`
## Narrative
As **the admin (owner)**, today I manage the git repos that feed the
knowledge base by editing `BOR_GIT_SOURCES` in `.env` and restarting. I
want a page only I can access where I can **add and remove git
sources** — no env file, no restart — and have the Sync button
(phase 32) and `import_docs` (phase 28) use that stored list.
- **Given** I am signed in as admin
- **When** I open the Git sources page and add a repo URL (or remove
one)
- **Then** the change is stored in Postgres (`git_sources` table) and
the next sync / import uses the stored list — the
`BOR_GIT_SOURCES` env var remains only a fallback while the table is
empty, and phase 32's fail-loud "no git sources configured" is
preserved when both are empty.
## Acceptance criteria
1. Migration 0006: `git_sources` (id UUID PK, url TEXT UNIQUE NOT
NULL, added_at TIMESTAMPTZ) — reversible, integration-tested
up/down (existing migration-test pattern).
2. Admin-only API (phase 16 pattern — 403 `admin only` anonymous):
`GET /api/git-sources` (DB rows in added order; while the table is
empty, the env list with `from_env: true`), `POST /api/git-sources`
{url} (trimmed, 1–500 chars, `https?://` / `ssh://` / `git@`
shape, 409 on duplicate with a detail that never echoes the URL),
`DELETE /api/git-sources/{id}` (204 / 404).
3. `effective_git_sources()` shared by `app/api/sync.py::_run_sync`
and `scripts/import_docs.py`: DB rows win, env fallback only while
the table is empty, fail-loud unchanged when both are empty; the
sync log line records the list origin (`origin=db|env`).
4. `/git-sources.html` — soft-gated like the Sources page (anonymous:
sign-in gate) with the standard header (phase 34) and an
admin-only "Git sources" nav link on all five pages (phase 29
pattern): sources list (mono URL + added date + per-row Remove),
an add form (labeled input + Add, inline error, never-stale button
per §7.4), an env-fallback note while the table is empty, and a
hint that the KB itself updates via the Sync button.
5. `.env.example` + README: `BOR_GIT_SOURCES` documented as the
empty-table fallback; the admin page is the primary management UI.
6. Unit + integration green, `app/` coverage >90%, story E2E green in
isolation, ruff + pyright clean, one `--no-gpg-sign` commit.
## Playwright Mapping Rule
`tests/e2e/test_git_sources_admin.py` — one story, one file, run in
isolation: anonymous gets the sign-in gate (and 403s on the API); the
admin adds/removes sources on the page (the list updates live, invalid
input gets an inline error) and reaches the page via the "Git
sources" nav link on every page.
+57
View File
@@ -0,0 +1,57 @@
# Story: One Navbar on Every Page
**Phase:** `34_consistent_navbar` · **E2E:** `tests/e2e/test_nav_consistency.py`
## Narrative
As **any user (admin or anonymous)**, I move between the Chat, Sources,
Document viewer, Global Tuning, and Sign in pages. Today the header bar
is only similar between pages: the document viewer replaces it with a
back + title bar (every nav button vanishes), the login page misses the
Tuning link, and the functional controls are page-scoped — the Tuning
steering toggle lives on chat only, the Sync sources button on Sources
only — so buttons pop in and out as I switch tabs. I want ONE identical
bar on every page.
- **Given** any of the app's five pages
- **When** I look at the header
- **Then** I see the same controls in the same order — brand, nav
[Chat, Sources, Tuning], Tuning toggle, Sync sources (admin), New
chat, and exactly one of Sign in / Sign out — with none missing
because of which page I happen to be on.
## Acceptance criteria
1. `frontend/assets/header.js` is the single owner of the functional
header controls: the steering toggle + panel logic (moved from
`app.js`, exported `refreshSteering()`), the Sync button state
machine (moved from `sources.js`, dispatching `bor:sync-status`),
one New chat binding (chat page: `bor:new-chat` event; other pages:
clear + navigate), and the sign-in `?next=` rewrite to the current
page. No control logic remains in the page scripts.
2. All five pages (`index.html`, `sources.html`, `document.html`,
`tuning.html`, `login.html`) ship the identical header markup; the
`#steering-panel` section exists on every page (chat: after
`#kb-banner` as today; other pages: first child of `<main>`).
3. The document viewer keeps its back link + title in a second
titlebar row below the standard bar (phase 13 back-target behavior
unchanged); the pinned 64px / 58px height contract applies to the
standard row on every page.
4. The locked A10 UI revision is preserved: admin-only controls
(Sources / Tuning nav links, Sync button) ship hidden and are
revealed only for a signed-in admin — for the admin every button is
active on every page; anonymous visitors get the reduced bar,
identically on every page (owner confirmation 2026-08-26).
5. The chat page's per-bubble Tune button + inline form (phases 15/27)
and the Sources page's sync result line + error banner (phase 32)
keep working exactly as before.
6. `tests/e2e/test_nav_consistency.py` green in isolation;
`test_header_consistency.py` / `test_shared_header.py` updated to the
new viewer contract; regressions green; `app/` coverage >90%
(frontend-only phase — unchanged); ruff + pyright clean; one
`--no-gpg-sign` commit.
## Playwright Mapping Rule
`tests/e2e/test_nav_consistency.py` — one story, one file, run in
isolation. It asserts, per role (admin, anonymous), that the visible
header control inventory is identical on all five pages, and that the
viewer's back link + titlebar row survive the change.
+45
View File
@@ -0,0 +1,45 @@
# Story: Summary + Original Document Together in the Viewer
**Phase:** `36_summary_in_viewer` · **E2E:** `tests/e2e/test_summary_in_viewer.py`
## Narrative
As **any user**, phase 30 gives every non-markdown document a
lite-model summary (stored on `documents.summary`, indexed as an
`is_summary` chunk, used for retrieval). When I open such a document —
from the Sources table, a chat source chip, or a direct URL — I only
see the raw original. I want to see **the summary and the original
document together**.
- **Given** a document that has a summary (phase 30: non-markdown A9
documents)
- **When** I open it in the document viewer (the modal or the full
page)
- **Then** a labeled Summary panel sits above the original content —
both visible at once. Documents without a summary (markdown docs,
pre-phase-30 rows, fail-soft rows) render exactly as before.
## Acceptance criteria
1. `DocContent` gains `summary: str | None`; `GET
/api/documents/content` returns `documents.summary`. The endpoint
stays public + stateless — the phase 16 soft rule (catalog gated,
viewer public) is unchanged.
2. The shared renderer `renderDocument` (`assets/document.js`)
renders the summary panel for **both** surfaces at once (the
full-page viewer + the chat/sources modal); null/empty summary →
no panel; the summary text is written with `textContent` (XSS
contract unchanged).
3. `.doc-summary` styling matches the dark tech theme (phase 08
palette, contrast ≥4.5:1) and reads as a summary, not as document
content.
4. Integration tests: a summarized non-markdown doc returns its
summary; a markdown doc returns null; anonymous access unchanged.
5. Story E2E green in isolation (the `summary_kb` fixture: the
deterministic digest panel + the full original with its tail
sentinel visible together; the markdown control doc → no panel);
regressions green; `app/` coverage >90%; ruff + pyright clean; one
`--no-gpg-sign` commit.
## Playwright Mapping Rule
`tests/e2e/test_summary_in_viewer.py` — one story, one file, run in
isolation.