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

This commit is contained in:
2026-08-26 15:39:42 -04:00
parent 0a46f07fa8
commit b2d8696741
19 changed files with 2122 additions and 678 deletions
+42 -107
View File
@@ -37,18 +37,20 @@
* collapsed Thinking block is restored with it; records without it (old
* sessions) restore exactly as before, so no version bump. A10 is
* untouched: the API stays stateless, nothing is stored server-side.
* "New chat" (#new-chat-btn) clears the key + the list back to the empty
* state.
* "New chat" (#new-chat-btn — bound by the shared header module,
* phase 34 task 02) clears the key + the list back to the empty state.
*
* Steering notes (phase 15) let the owner tune how Brain answers: a
* "Tune" button under every completed brain bubble (deflected included)
* opens an inline form → POST /api/steering → the note is stored in
* Postgres and injected into the system prompt of every subsequent turn
* (the <tuning> section). Notes are listed newest-first in the header
* "Tuning" panel (#steering-panel), where each can be deleted. Note text
* is always rendered with textContent (XSS-safe), save/delete are
* announced through a polite live region (#steering-announcer), and the
* panel + count badge update on every change.
* (the <tuning> section). The header "Tuning" panel (#steering-panel)
* — toggle, list, per-note delete, count badge, announcer — is owned by
* the shared header module (assets/header.js, phase 34); this file keeps
* only the chat-specific per-bubble Tune button + inline form, whose
* success path refreshes the panel (refreshSteering()) and announces
* (announceSteering()) through the module. Note text is always rendered
* with textContent (XSS-safe) in both places.
*
* Scroll (phase 18, owner choice 2026-08-23): the page auto-scrolls only
* while the user is pinned to the bottom. NEAR_BOTTOM_PX (200px) covers
@@ -72,7 +74,12 @@
* All DOM ids match frontend/index.html.
*/
import { fetchIsAdmin, initSharedHeader } from "./header.js";
import {
fetchIsAdmin,
initSharedHeader,
refreshSteering,
announceSteering,
} from "./header.js";
import { openDocumentModal } from "./document-modal.js"; // phase 26: chips open the same-page modal
const messagesEl = document.querySelector("#messages");
@@ -164,30 +171,24 @@ export function documentUrl(source, path, back = "/") {
return url;
}
/* ---------- steering notes (phase 15) ----------
/* ---------- steering notes (phase 15; panel module-owned from phase 34) ----------
*
* The owner's tuning notes steer every future answer: they live in
* Postgres (stateless API, A10) and the chat turn reads them into the
* system prompt. UI contract: Tune button → inline form → save →
* confirmation (or inline error, form kept); the header panel lists the
* notes (newest first) with per-note delete.
* confirmation (or inline error, form kept). The header panel — toggle,
* list, per-note delete, count badge, announcer — is owned by the shared
* header module (assets/header.js, phase 34 task 01); this page keeps
* only the chat-specific part: the Tune button under every completed
* brain bubble and its inline form. Save success refreshes the panel
* through refreshSteering() and announces through announceSteering() —
* both imported from header.js.
*/
const steeringToggle = document.querySelector("#steering-toggle");
const steeringCount = document.querySelector("#steering-count");
const steeringPanel = document.querySelector("#steering-panel");
const steeringList = document.querySelector("#steering-list");
const steeringEmpty = document.querySelector("#steering-empty");
const steeringAnnouncer = document.querySelector("#steering-announcer");
const TUNE_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>';
let tuneSeq = 0; // unique ids for one open tune form's inputs
function announceSteering(message) {
if (steeringAnnouncer) steeringAnnouncer.textContent = message;
}
/* "Tune" button in the meta row of a completed brain bubble. Reuses the
sources' .msg-meta row when it exists (role=list → the button joins as
a listitem so ARIA stays valid); otherwise creates a plain meta row.
@@ -279,7 +280,7 @@ function openTuneForm(wrap, toggleBtn) {
saved.textContent = "Saved — future answers will follow this.";
form.replaceWith(saved);
announceSteering("Tuning note saved. Future answers will follow it.");
await loadSteering(); // panel + count badge update
await refreshSteering(); // header.js: panel + count badge update
} catch {
status.textContent = "Could not save the note — is the app reachable?";
status.hidden = false;
@@ -294,76 +295,6 @@ function openTuneForm(wrap, toggleBtn) {
form.querySelector("textarea").focus();
}
/* Panel: newest-first list (textContent — XSS-safe), per-note delete,
empty text, and the header count badge. */
async function loadSteering() {
let notes = [];
try {
const r = await fetch("/api/steering");
if (r.ok) notes = (await r.json()).notes || [];
} catch { /* API unreachable: keep the last rendered list */ }
renderSteeringPanel(notes);
return notes;
}
function renderSteeringPanel(notes) {
if (!steeringList) return;
steeringList.textContent = "";
for (const n of notes) {
const li = document.createElement("li");
li.className = "steering-note";
const text = document.createElement("span");
text.className = "steering-note-text";
text.textContent = n.note; // rendered as text, never as HTML
li.appendChild(text);
const del = document.createElement("button");
del.type = "button";
del.className = "steering-delete";
del.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
del.innerHTML =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
del.addEventListener("click", () => deleteSteeringNote(n.id, del));
li.appendChild(del);
steeringList.appendChild(li);
}
if (steeringEmpty) steeringEmpty.hidden = notes.length > 0;
if (steeringCount) steeringCount.textContent = String(notes.length);
}
async function deleteSteeringNote(id, btn) {
btn.disabled = true;
try {
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
if (r.status === 404) {
announceSteering("That note was already removed.");
await loadSteering();
return;
}
if (!r.ok) {
announceSteering("Could not delete the note — try again.");
btn.disabled = false;
return;
}
await loadSteering();
announceSteering("Tuning note deleted.");
} catch {
announceSteering("Could not delete the note — is the app reachable?");
btn.disabled = false;
}
}
function setSteeringPanel(open) {
if (!steeringPanel || !steeringToggle) return;
steeringPanel.hidden = !open;
steeringToggle.setAttribute("aria-expanded", open ? "true" : "false");
}
if (steeringToggle && steeringPanel) {
steeringToggle.addEventListener("click", () => {
setSteeringPanel(steeringPanel.hidden);
if (!steeringPanel.hidden) loadSteering(); // refresh when (re)opened
});
}
/* ---------- avatar glyphs (phase 08: emoji-free chrome) ----------
* Inline SVG as string constants so the message renderer and the typing
* indicator share exactly the same marks. currentColor lets the CSS theme
@@ -819,10 +750,13 @@ function rememberBrainTurn(rawText, meta) {
* reload) all moved to the shared header module (assets/header.js) —
* initSharedHeader() does the header toggling on every page, and
* fetchIsAdmin() is the single cached whoami, so this page still makes
* exactly one request per load. applyAuthState keeps only the
* chat-page-specific work (removing the tuning surface for anonymous
* visitors) — idempotent alongside the header module's own link/button
* toggling.
* exactly one request per load.
*
* Phase 34: removing the tuning surface for anonymous visitors (toggle
* + panel, "absent not hidden") also moved into initSharedHeader() —
* the module owns the controls, so the module gates them. applyAuthState
* keeps only the auth-pair toggling — idempotent alongside the header
* module's own link/button toggling.
*/
const signInLink = document.querySelector("#sign-in-link");
const signOutBtn = document.querySelector("#sign-out-btn");
@@ -831,13 +765,8 @@ let isAdmin = false;
function applyAuthState() {
if (signInLink) signInLink.hidden = isAdmin;
if (signOutBtn) signOutBtn.hidden = !isAdmin;
if (!isAdmin && steeringToggle) {
steeringToggle.remove();
steeringPanel?.remove();
}
}
const newChatBtn = document.querySelector("#new-chat-btn");
function startNewChat() {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
conversation = [];
@@ -852,7 +781,11 @@ function startNewChat() {
input.focus();
sendStatus.textContent = "New chat started — previous conversation cleared.";
}
if (newChatBtn) newChatBtn.addEventListener("click", startNewChat);
/* Phase 34 task 02: the #new-chat-btn binding is module-owned
* (header.js, the SINGLE New chat binding) — on the chat page the
* module dispatches window "bor:new-chat" and this page acts through
* its own in-flight-turn guard + list reset. */
window.addEventListener("bor:new-chat", startNewChat);
function showErrorBanner(detail) {
banner.hidden = false;
@@ -1052,13 +985,15 @@ window.addEventListener("pagehide", () => {
Phase 14: the conversation then comes back exactly as left. Phase 19:
the shared header module runs the whoami (cached — exactly one
request per page load) and toggles the Sign in/out pair + the Sources
nav link; applyAuthState() then applies the chat-page-only gating. */
nav link; applyAuthState() then applies the chat-page-only gating.
Phase 34: the steering panel's admin boot refresh (count badge) and
the anonymous removal of the tuning surface both happen inside
initSharedHeader() now. */
(async () => {
await initSharedHeader(); // header.js: whoami + Sign in/out + #nav-sources
await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate
isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami
applyAuthState(); // chat page: the admin-only tuning surface
applyAuthState(); // chat page: the auth pair (idempotent with header.js)
restoreConversation();
loadSuggestions();
loadHealth();
if (isAdmin) loadSteering(); // phase 15: panel + count badge (admin only)
})();
+18 -18
View File
@@ -29,9 +29,10 @@
*
* Phase 19: the viewer joins the shared header (assets/header.js) — the
* whoami fetch is the module's cached promise (one request per page,
* shared with initSharedHeader's toggling), and the bar gains the New
* chat button: on a non-chat page "new chat" means going to the chat,
* fresh (clear the phase-14 conversation key, then navigate to "/").
* shared with initSharedHeader's toggling). Phase 34 task 02: the New
* chat binding is module-owned (assets/header.js, the SINGLE one) — on
* this non-chat page it clears the phase-14 conversation key and
* navigates to the chat's empty state.
*
* Phase 26 (import safety): the modal module imports renderDocument
* from THIS file on the chat/sources pages, so everything
@@ -41,7 +42,7 @@
* fetch, no New Chat binding.
*/
import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "./header.js";
import { fetchIsAdmin, initSharedHeader } from "./header.js";
function fmtDate(iso) {
try {
@@ -71,6 +72,9 @@ function metaBadge(cls, text) {
* document-derived string is a text node. */
export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
titleEl.textContent = doc.title;
// Phase 34 task 04: the titlebar title ellipsizes — the full title
// stays reachable on hover via the `title` attribute.
titleEl.setAttribute("title", doc.title);
const pathCode = document.createElement("code");
pathCode.className = "doc-path";
@@ -149,16 +153,19 @@ if (document.querySelector("#doc-title")) {
function showNotFound() {
titleEl.textContent = "Document not found";
titleEl.removeAttribute("title"); // no stale full-title tooltip
document.title = "Document not found · Brain of Reese";
metaEl.replaceChildren();
contentEl.replaceChildren();
notFoundEl.hidden = false;
}
/* Phase 19: the shared header controls (Sign in / Sign out — exactly
* one visible) are toggled here; the viewer has no nav, so there is
* no #nav-sources for the module to touch. Independent of the doc
* fetch (its own IIFE — load() below never waits on it).
/* Phase 19 (phase 34 task 03, owner confirmation 2026-08-26): the
* viewer now carries the SAME standard bar as every other page —
* nav incl. the admin-only links, Tuning toggle, Sync, New chat, the
* auth pair — all toggled here on the module's cached whoami.
* Independent of the doc fetch (its own IIFE — load() below never
* waits on it).
* (fetchIsAdmin is imported for parity with the other header
* consumers — the module's cached promise is the single whoami per
* page either way.) */
@@ -166,16 +173,9 @@ if (document.querySelector("#doc-title")) {
await initSharedHeader();
})();
/* Phase 19: New chat on a non-chat page means "go to the chat,
* fresh": clear the phase-14 conversation key, then land on the chat
* page — its empty state, since the conversation is gone from storage. */
const newChatBtn = document.querySelector("#new-chat-btn");
if (newChatBtn) {
newChatBtn.addEventListener("click", () => {
clearChatStorage();
window.location.href = "/";
});
}
/* The New chat binding is module-owned (assets/header.js, phase 34
* task 02 — the SINGLE binding): on this non-chat page it clears the
* phase-14 conversation key and navigates to the chat's empty state. */
async function load() {
try {
+503 -20
View File
@@ -6,28 +6,60 @@
*
* • 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) and "Tuning"
* (#nav-tuning, on the Global Tuning page, phase 27) — phase 19 UX
* revision (owner permission 2026-08-23): hidden for anonymous on
* every page that has a nav (chat, sources, tuning, login),
* revealed for admin. The links SHIP hidden in the HTML
* • the admin-only nav links — "Sources" (#nav-sources, phase 19)
* 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
* 2026-08-26): the SAME nav ships on all five pages (chat,
* sources, document viewer, tuning, login) — the viewer's
* "no nav" bar is gone. The links SHIP hidden in the HTML
* (anonymous-safe default — the phase-16 "absent, not hidden"
* spirit), so no anonymous user ever sees one for a frame; and the
* Sources page's "Sync sources" button (#sync-btn, phase 32) —
* the same ship-hidden / reveal-for-admin contract on the SAME
* cached whoami (one fetch, no extra request);
* spirit), so no anonymous user ever sees one for a frame; and
* the "Sync sources" button (#sync-btn, phase 32 — every page
* from phase 34 task 03) — the same ship-hidden / reveal-for-admin
* contract on the SAME cached whoami (one fetch, no extra request);
* • the sign-out click binding (POST /api/logout → reload) — moved
* here from app.js so there is exactly one implementation;
* • the steering-notes controls (phase 15, moved here from app.js in
* phase 34) — the #steering-toggle open/close + the #steering-panel
* list (newest-first, textContent-rendered, per-note delete, count
* badge, the #steering-announcer live region) — so the toggle can
* sit in every page's header with zero page-script duplication.
* refreshSteering() / announceSteering() are exported for the chat
* page's per-bubble Tune form (which stays in app.js); anonymous
* visitors get the phase-16 "absent, not hidden" treatment (toggle
* + panel removed from the DOM, /api/steering never fetched);
* • the Sync sources state machine (phase 32, moved here from
* sources.js in phase 34 task 02) — the §7.4 never-stale lifecycle
* for #sync-btn (idle → running → success | failed): admin-only
* boot re-attach on the SAME cached whoami (non-admins never poll),
* POST /api/sync (202 start / 409 adopt), the 2 s
* GET /api/sync/status poll (one live timer, NO client-side hard
* timeout — the server state is authoritative). Every state change
* dispatches window "bor:sync-status" (detail = the status object)
* so the Sources page renders its #sync-result line +
* #sync-error-banner off the event; on non-Sources pages the
* failed state is visible in the button's title + aria-label;
* • the SINGLE New chat binding (phase 34 task 02 — it was
* duplicated across app.js / sources.js / tuning.js / document.js):
* on the chat page (#messages exists) the module dispatches
* window "bor:new-chat" and app.js acts (it owns the in-flight-turn
* guard + the list reset); on every other page it means "go to the
* chat, fresh" — clearChatStorage() + navigate to "/";
* • the sign-in ?next= rewrite (phase 34 task 02) — initSharedHeader
* points #sign-in-link at /login.html?next=<current pathname>
* (default "/"), so the admin lands back on the page they signed in
* from; the page markup keeps its own href as the no-JS fallback;
* • clearChatStorage() — the phase-14 conversation key, for the
* New Chat buttons on the NON-CHAT pages (sources / document
* viewer / tuning): a new chat means going to the chat, fresh.
* New chat action on the NON-CHAT pages (sources / document viewer /
* tuning / login): a new chat means going to the chat, fresh.
*
* Every page loads this module (type="module", before its page script)
* and its page script calls initSharedHeader() once at boot. init…
* toggles ONLY the controls that exist on the page — a missing element
* is a no-op, which is how the login page reuses the module without
* gaining chat controls (no #new-chat-btn / #sign-in-link /
* #sign-out-btn in its markup → none appear).
* is a no-op. Phase 34 task 03 ships the SAME full header block on all
* five pages (the login page included), so every control resolves on
* every page; a page that lacks one simply skips it.
*
* whoami is fetched at most ONCE per page load: the promise is cached in
* the module-level `adminPromise`, so app.js's tuning gate, the sources
@@ -61,20 +93,42 @@ export function fetchIsAdmin() {
cached promise makes both awaits the same single request). */
export async function initSharedHeader() {
const admin = await fetchIsAdmin();
// The Sign in link: hidden for the admin, visible otherwise — and its
// href is rewritten to return the admin to THIS page after login
// (phase 34 task 02: "return to where you were"). The markup keeps its
// own static ?next= as the no-JS fallback. location.pathname is always
// 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;
if (signIn) {
signIn.hidden = admin;
signIn.href = "/login.html?next=" + (window.location.pathname || "/");
}
const signOut = document.querySelector("#sign-out-btn");
if (signOut) signOut.hidden = !admin;
const navSources = document.querySelector("#nav-sources");
if (navSources) navSources.hidden = !admin;
// Phase 27: the Global Tuning page's own nav link — admin-only, the
// same ship-hidden / reveal-for-admin contract as the Sources link.
// Phase 29: the Global Tuning nav link (every page from phase 34
// task 03) — admin-only, the same ship-hidden / reveal-for-admin
// contract as the Sources link.
const navTuning = document.querySelector("#nav-tuning");
if (navTuning) navTuning.hidden = !admin;
// Phase 32: the Sources page's "Sync sources" button — admin-only,
// revealed on this same cached whoami (anonymous users never see it).
const syncBtn = document.querySelector("#sync-btn");
// Phase 32: the "Sync sources" button — admin-only, revealed on this
// same cached whoami (anonymous users never see it).
if (syncBtn) syncBtn.hidden = !admin;
// Phase 34: the steering controls (phase 15) are module-owned. Admin:
// refresh the list so the count badge is right before the panel is
// ever opened (fire-and-forget, as the chat page did before the move).
// Anonymous: the toggle + panel are REMOVED from the DOM entirely —
// the phase-16 contract says "absent", not just hidden — and
// /api/steering is never fetched.
if (admin) {
if (steeringPanel) refreshSteering();
} else {
steeringToggle?.remove();
steeringPanel?.remove();
}
return admin;
}
@@ -106,3 +160,432 @@ if (signOutBtn) {
window.location.reload();
});
}
/* ---------- steering notes (phase 15; module-owned from phase 34) ----------
*
* The owner's tuning notes steer every future answer: they live in
* Postgres (stateless API, A10) and the chat turn reads them into the
* system prompt. The header panel — toggle, list, per-note delete, count
* badge, announcer — is owned by THIS module: every page that ships the
* panel markup gets exactly this behavior, with zero page-script
* duplication. The chat page keeps only its per-bubble Tune form
* (app.js), which refreshes the panel through refreshSteering() and
* announces through announceSteering().
*
* All elements are looked up null-safe (querySelector + guard): a page
* that lacks the panel markup is a no-op — the same contract as
* initSharedHeader().
*/
const steeringToggle = document.querySelector("#steering-toggle");
const steeringCount = document.querySelector("#steering-count");
const steeringPanel = document.querySelector("#steering-panel");
const steeringList = document.querySelector("#steering-list");
const steeringEmpty = document.querySelector("#steering-empty");
const steeringAnnouncer = document.querySelector("#steering-announcer");
/* Announce a steering change through the polite live region
(role="status", aria-live="polite") — exported so the chat page's
per-bubble Tune form (app.js) announces on the exact same channel. */
export function announceSteering(message) {
if (steeringAnnouncer) steeringAnnouncer.textContent = message;
}
/* Fetch + render the note list (exported — the chat page's per-bubble
Tune form calls it on save, so the panel + count badge update without
owning the fetch itself). Non-2xx (the anonymous 403) or an
unreachable API render the empty state: count badge 0, the "no notes
yet" text visible — the safe fallback in either case. */
export async function refreshSteering() {
let notes = [];
try {
const r = await fetch("/api/steering");
if (r.ok) notes = (await r.json()).notes || [];
} catch {
/* API unreachable: the empty list state is the safe fallback */
}
renderSteeringPanel(notes);
return notes;
}
/* Newest-first list — the note is ALWAYS rendered with textContent
(XSS-safe, never innerHTML), a per-note Remove button with a labeled
aria-label, the empty text toggled on notes.length, and the header
count badge. */
function renderSteeringPanel(notes) {
if (!steeringList) return;
steeringList.textContent = "";
for (const n of notes) {
const li = document.createElement("li");
li.className = "steering-note";
const text = document.createElement("span");
text.className = "steering-note-text";
text.textContent = n.note; // rendered as text, never as HTML
li.appendChild(text);
const del = document.createElement("button");
del.type = "button";
del.className = "steering-delete";
del.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
del.innerHTML =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
del.addEventListener("click", () => deleteSteeringNote(n.id, del));
li.appendChild(del);
steeringList.appendChild(li);
}
if (steeringEmpty) steeringEmpty.hidden = notes.length > 0;
if (steeringCount) steeringCount.textContent = String(notes.length);
}
/* Per-note delete: disable the row button (no double-fire), DELETE
/api/steering/{id}, re-load the list, announce through
#steering-announcer. A 404 means the note was already gone — say so
and still refresh; any other failure re-enables the button so the
user can retry. */
async function deleteSteeringNote(id, btn) {
btn.disabled = true;
try {
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
if (r.status === 404) {
announceSteering("That note was already removed.");
await refreshSteering();
return;
}
if (!r.ok) {
announceSteering("Could not delete the note — try again.");
btn.disabled = false;
return;
}
await refreshSteering();
announceSteering("Tuning note deleted.");
} catch {
announceSteering("Could not delete the note — is the app reachable?");
btn.disabled = false;
}
}
/* Open/close the panel, keeping the toggle's aria-expanded in sync —
the exact phase-15 chat-page contract (open on click, close on click;
the panel itself is a plain region — no Esc / outside-click close in
the original, so none here). Re-opening refreshes the list, so notes
changed elsewhere (the Tuning page, another tab) show up. */
function setSteeringPanel(open) {
if (!steeringPanel || !steeringToggle) return;
steeringPanel.hidden = !open;
steeringToggle.setAttribute("aria-expanded", open ? "true" : "false");
}
/* Toggle binding (module-owned, like the sign-out binding): runs at
module import, so a page with the toggle markup gets exactly one
implementation. */
if (steeringToggle && steeringPanel) {
steeringToggle.addEventListener("click", () => {
setSteeringPanel(steeringPanel.hidden);
if (!steeringPanel.hidden) refreshSteering(); // refresh when (re)opened
});
}
/* ---------- New chat (the SINGLE binding — module-owned from phase 34
* task 02) ----------
*
* The binding used to be duplicated across app.js / sources.js /
* tuning.js / document.js with the same page-kind branch. It lives here
* exactly once (module import, like the sign-out binding): on the chat
* page (#messages exists) the module dispatches window "bor:new-chat"
* and app.js acts — the chat script owns the in-flight-turn guard and
* the rendered-list reset; on every other page "new chat" means go to
* the chat, fresh: clear the phase-14 conversation key, then navigate
* to "/" (its empty state, since the conversation is gone from storage).
*/
const newChatBtn = document.querySelector("#new-chat-btn");
if (newChatBtn) {
newChatBtn.addEventListener("click", () => {
if (document.querySelector("#messages")) {
window.dispatchEvent(new CustomEvent("bor:new-chat"));
return;
}
clearChatStorage();
window.location.href = "/";
});
}
/* ---------- sync sources (phase 32; module-owned from phase 34 task 02) ----------
*
* The "never stale" lifecycle for the long background sync job, moved
* here from sources.js so the SAME #sync-btn markup on ANY page (phase
* 34 task 03) behaves identically. The button is the module's; the
* Sources page's #sync-result line + #sync-error-banner render off the
* "bor:sync-status" event this machine dispatches (sources.js
* subscribes):
*
* idle → click → POST /api/sync
* 202 → running (disabled, aria-busy, spinning icon, "Syncing…")
* + a 2 s poll of GET /api/sync/status;
* 409 → the in-flight run is ADOPTED the same way (one sync
* at a time, one poll loop at a time);
* success → "Synced HH:MM"; failed → retry-ready "Sync sources"
* + the sanitized error in the button's title +
* aria-label (on non-Sources pages that IS where the
* failure is visible; the Sources banner is the event).
*
* Boot (admin only — non-admins never poll, the status endpoint is
* admin-only): one GET /api/sync/status on the SAME cached whoami —
* running re-enters the running state (reload mid-sync), a terminal
* state renders its last result. NO client-side hard timeout (phase 32
* locked decision): a sync can legitimately outlive the page, so the
* 2 s poll is the feedback loop and the server state is authoritative.
*
* All elements are looked up null-safe: a page that doesn't (yet) carry
* the #sync-btn markup is a complete no-op, exactly like the rest of
* this module.
*/
const syncBtn = document.querySelector("#sync-btn");
const syncLabel = document.querySelector("#sync-label");
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
const SYNC_POLL_MS = 2000; // the 2 s status poll (phase 32 contract)
let syncPollTimer = null; // at most ONE live poll loop
let lastSyncState = null; // the last state emitted on bor:sync-status
/* The module → page channel: detail is the GET /api/sync/status object
(or the synthetic { state: "running" } frame the click path emits
before the first poll tick — the Sources handlers only need the
state, the next real object carries the full fields). */
function emitSyncStatus(status) {
lastSyncState = status.state;
window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }));
}
function stopSyncPolling() {
if (syncPollTimer !== null) {
clearTimeout(syncPollTimer);
syncPollTimer = null;
}
}
/* The local HH:MM of finished_at — 24-hour, locale-independent, so the
* "Synced 14:32" last-result label is deterministic. */
function fmtSyncTime(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/* The last-result line for the Sources page's #sync-result (aria-live).
* "added" is ALWAYS announced (the run's headline term); "updated" /
* "pruned" only when they happened (zero terms omitted); "unchanged"
* whenever it is non-zero — or whenever nothing was added or updated,
* so a no-op re-sync reads "0 added · 1 unchanged" instead of an empty
* live region (the story gate's idempotency check). Exported so the
* Sources page renders the counts from ONE implementation. */
export function fmtSyncResult(detail) {
const d = detail || {};
const added = d.added || 0;
const updated = d.updated || 0;
const parts = [`${added} added`];
if (updated > 0) parts.push(`${updated} updated`);
if ((d.unchanged || 0) > 0 || (added === 0 && updated === 0)) {
parts.push(`${d.unchanged || 0} unchanged`);
}
if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`);
return parts.join(" · ");
}
/* The failed-state affordance text for the button's title + aria-label
* (non-Sources pages: that is where the failure is visible). The server
* already masks any embedded credentials (sync.py _sanitize_error);
* here the string is collapsed to a single line and capped so a chatty
* git stderr can't bloat the attributes. */
function sanitizeSyncError(message) {
const text = String(message || "The sync failed.").replace(/\s+/g, " ").trim();
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
}
/* §7.4 running state: disabled + aria-busy + spinning icon + the
* "Syncing…" label — and a fresh run starts clean: the previous
* failure's affordances (title / aria-label / .is-error) come off NOW,
* not when the run settles. The button only — the Sources page's
* result line / banner clear off the matching "running" event (no
* 2 s lag). */
function enterSyncRunningState() {
if (!syncBtn) return;
syncBtn.disabled = true;
syncBtn.setAttribute("aria-busy", "true");
syncBtn.removeAttribute("title");
syncBtn.setAttribute("aria-label", "Sync sources");
syncBtn.classList.remove("is-error");
if (syncIcon) syncIcon.classList.add("is-spinning");
if (syncLabel) syncLabel.textContent = "Syncing…";
}
/* Settle the button back to clickable + un-spun with the given label,
* dropping the failed-state affordances (a fresh run starts clean). */
function settleSyncButton(label) {
if (!syncBtn) return;
syncBtn.disabled = false;
syncBtn.removeAttribute("aria-busy");
syncBtn.removeAttribute("title");
syncBtn.setAttribute("aria-label", "Sync sources");
syncBtn.classList.remove("is-error");
if (syncIcon) syncIcon.classList.remove("is-spinning");
if (syncLabel) syncLabel.textContent = label;
}
function applySyncSuccess(status) {
const time = fmtSyncTime(status.finished_at);
settleSyncButton(time ? `Synced ${time}` : "Synced");
emitSyncStatus(status);
}
function applySyncFailure(status) {
const error = sanitizeSyncError(status.error);
settleSyncButton("Sync sources"); // retry-ready
if (syncBtn) {
// The failed look: error text in title + aria-label (and the
// .is-error class for the non-Sources pages' visible error state).
syncBtn.title = error;
syncBtn.setAttribute("aria-label", error);
syncBtn.classList.add("is-error");
}
emitSyncStatus(status);
}
/* A run can only vanish with a server restart mid-sync (status resets
* to idle — the phase-accepted behavior): retry-ready, no error to
* name. Also the post-403 cleanup. */
function applySyncIdle(status) {
settleSyncButton("Sync sources");
emitSyncStatus(status || { state: "idle" });
}
/* The 2 s poll loop — the ONLY feedback timer (no client-side hard
* timeout, phase 32 locked decision). One tick at a time (re-scheduled
* only while the run is still live, so an in-flight fetch can never
* overlap the next tick), and startSyncPolling refuses to run a second
* loop (a 409 adoption or a reload never doubles the polling). */
function startSyncPolling() {
if (syncPollTimer !== null) return;
const tick = async () => {
let status = null;
let notAdmin = false;
try {
const r = await fetch("/api/sync/status");
if (r.status === 403) notAdmin = true;
else if (r.ok) status = await r.json();
} catch {
/* network blip — the next tick retries (no client timeout to trip) */
}
if (notAdmin) {
// Session lost mid-sync: defense in depth — hide the button.
stopSyncPolling();
if (syncBtn) syncBtn.hidden = true;
applySyncIdle();
return;
}
if (!status) {
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
return;
}
if (status.state === "success") {
stopSyncPolling();
applySyncSuccess(status);
return;
}
if (status.state === "failed") {
stopSyncPolling();
applySyncFailure(status);
return;
}
if (status.state === "idle") {
// The run died with a server restart — retry-ready, no banner.
stopSyncPolling();
applySyncIdle(status);
return;
}
// Still running: keep the button state honest (idempotent) and
// re-schedule. No event — the running frame was already emitted
// when the state entered (click / boot), and the Sources handlers
// are no-ops for repeated running frames anyway.
enterSyncRunningState();
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
};
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
}
/* Click → POST /api/sync. 202 starts the run; 409 adopts the in-flight
* one (started elsewhere — e.g. a second tab); 403 hides the button
* (defense in depth); anything else names the failure (banner on
* Sources via the event, button affordance everywhere). */
async function startSync() {
let r;
try {
r = await fetch("/api/sync", { method: "POST" });
} catch {
applySyncFailure({
state: "failed",
error: "Could not reach the server to start the sync — try again.",
});
return;
}
if (r.status === 403) {
stopSyncPolling();
if (syncBtn) syncBtn.hidden = true;
applySyncIdle();
return;
}
if (r.status === 202 || r.status === 409) {
enterSyncRunningState();
// The synthetic running frame clears the Sources result line /
// banner IMMEDIATELY (before the first poll tick, 2 s away) — the
// exact sources.js enterRunningState behavior, now event-driven.
if (lastSyncState !== "running") emitSyncStatus({ state: "running" });
startSyncPolling();
return;
}
let detail = "";
try {
detail = (await r.json()).detail || "";
} catch {
/* non-JSON error body */
}
applySyncFailure({
state: "failed",
error: detail || `The server refused to start the sync (${r.status}).`,
});
}
/* Load-time re-attach (ADMIN ONLY — non-admins never poll, the status
* endpoint is admin-only): a running run re-enters the running state
* (the user may have reloaded mid-sync), a terminal run renders its
* last result, idle settles nothing visible. Awaits the SAME cached
* whoami promise — exactly one /api/whoami per page load, unchanged. */
async function initSyncButton() {
if (!syncBtn) return;
if (!(await fetchIsAdmin())) return; // anonymous: the button stays hidden
let status;
try {
const r = await fetch("/api/sync/status");
if (r.status === 403) {
syncBtn.hidden = true; // defense in depth
return;
}
if (!r.ok) return;
status = await r.json();
} catch {
return; // network blip — the button stays idle and clickable
}
if (status.state === "running") {
enterSyncRunningState();
emitSyncStatus(status);
startSyncPolling();
} else if (status.state === "success") {
applySyncSuccess(status);
} else if (status.state === "failed") {
applySyncFailure(status);
} else {
applySyncIdle(status); // idle: settle + the idle frame
}
}
if (syncBtn) {
syncBtn.addEventListener("click", startSync);
initSyncButton(); // re-attach to a running / last sync run (admin only)
}
+17 -11
View File
@@ -7,11 +7,12 @@
* role=alert error region. On load, /api/whoami already says admin →
* straight to `next`, no form.
*
* Phase 19: the whoami check runs on the shared header module's cached
* promise (assets/header.js) — one request per page, and the module's
* initSharedHeader() toggles the (admin-only) Sources nav link. The
* login page carries no chat controls, so the module's missing-element
* no-op keeps this page control-free.
* Phase 19 (phase 34 task 03, owner confirmation 2026-08-26): the
* whoami check runs on the shared header module's cached promise
* (assets/header.js) — one request per page, and the module's
* initSharedHeader() settles the login page's FULL shared header —
* the SAME bar as every other page (nav incl. the admin-only links,
* Tuning toggle, Sync, New chat, the auth pair).
*
* No CDN, no state in this file: the signed cookie is the whole session.
* All DOM ids match frontend/login.html.
@@ -76,15 +77,20 @@ form.addEventListener("submit", async (e) => {
}
});
/* Already the admin? Skip the form and go straight to the target.
* (For anonymous visitors, initSharedHeader toggles the Sources nav
* link — the only shared control this page carries; for the signed-in
* case the redirect above makes the toggle moot.) */
/* The bar settles in BOTH branches (phase 34 task 05 — the login page
* carries the FULL shared header, so a signed-in admin who lands here
* gets the settled admin bar for the frame before the redirect, not
* the ship-hidden state): the whoami promise is already settled by
* this point, so initSharedHeader adds no request and no delay, and
* the phase-16 redirect itself is unchanged. For anonymous visitors it
* settles the reduced bar: Sign in visible, the admin-only controls
* stay hidden, the steering toggle + panel removed. */
(async () => {
if (await alreadySignedIn()) {
const admin = await alreadySignedIn();
await initSharedHeader();
if (admin) {
window.location.replace(safeNext());
return;
}
await initSharedHeader(); // phase 19: Sources link toggle (no chat controls here)
passwordInput.focus();
})();
+44 -218
View File
@@ -7,9 +7,15 @@
*
* Phase 19: the page joins the shared header (assets/header.js) — the
* whoami gate below runs on the module's cached promise (one request per
* page, shared with the header toggling), and the header gains the New
* chat button: on a non-chat page "new chat" means going to the chat,
* fresh (clear the phase-14 conversation key, then navigate to "/").
* page, shared with the header toggling).
*
* Phase 34 task 02: the header's functional controls are module-owned
* (assets/header.js): the Sync sources state machine (#sync-btn's
* §7.4 lifecycle — the page only renders #sync-result +
* #sync-error-banner off the module's "bor:sync-status" event) and the
* New chat binding (on a non-chat page "new chat" means going to the
* chat, fresh — the module clears the phase-14 conversation key and
* navigates to "/").
*
* Phase 26: the table's path links open the document in the
* almost-fullscreen modal overlay (assets/document-modal.js) on the
@@ -21,7 +27,7 @@
* tag; esbuild inlines it into the page bundle).
*/
import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "./header.js";
import { fetchIsAdmin, fmtSyncResult, initSharedHeader } from "./header.js";
import { openDocumentModal } from "./document-modal.js"; // phase 26: row links open the same-page modal
const tbody = document.querySelector("#docs-tbody");
@@ -43,17 +49,6 @@ function isAdmin() {
return fetchIsAdmin();
}
/* Phase 19: New chat on a non-chat page means "go to the chat, fresh":
* clear the phase-14 conversation key, then land on the chat page — its
* empty state, since the conversation is gone from storage. */
const newChatBtn = document.querySelector("#new-chat-btn");
if (newChatBtn) {
newChatBtn.addEventListener("click", () => {
clearChatStorage();
window.location.href = "/";
});
}
function fmtDate(iso) {
try {
return new Date(iso).toLocaleString();
@@ -146,46 +141,33 @@ function showEmpty() {
if (tableWrap) tableWrap.hidden = true;
}
/* ---------- Phase 32: the admin "Sync sources" button (§7.4) ----------
* The "never stale" lifecycle for a long background job:
/* ---------- Phase 32 (state machine module-owned from phase 34
* task 02): the #sync-result line + #sync-error-banner ----------
*
* idle → click → POST /api/sync
* 202 → "Syncing…" (disabled, aria-busy, spinning icon) + a
* 2 s poll of GET /api/sync/status — the feedback loop;
* 409 adopts the in-flight run the same way (one poll
* loop at a time, never two);
* success → "Synced HH:MM" + last-result counts in #sync-result
* (aria-live — announced to screen readers) + the
* catalog re-fetches live (never a stale table);
* failed → "Sync sources" (retry-ready) + the role="alert"
* banner naming the error.
* The #sync-btn state machine itself (click → POST /api/sync, the 2 s
* GET /api/sync/status poll, the running / success / failed button
* states, the admin-only load re-attach) lives in the shared header
* module (assets/header.js) — so the SAME button markup on ANY page
* behaves identically. This page keeps only the page-specific
* rendering: the aria-live last-result line and the role="alert" error
* banner, driven by the module's "bor:sync-status" event (detail = the
* GET /api/sync/status object):
*
* NO client-side hard timeout (phase locked decision): a sync can
* legitimately run for minutes (clone + embed), so the 2 s poll is the
* feedback loop and the server state is authoritative — the button is
* disabled until the run reaches a terminal state, so it can never sit
* stale OR stuck. On load (admin only) the page re-attaches: a running
* run re-enters the running state (reload mid-sync), a terminal run
* renders its last result. A 403 anywhere hides the button (defense in
* depth — header.js's whoami reveal is the primary gate).
* running → clear the result line, hide the banner (a new run starts
* clean — the module emits the frame immediately on
* click/boot, no 2 s poll lag);
* success → the last-result counts in #sync-result (fmtSyncResult —
* "added" always shown, zero terms omitted) + the catalog
* re-fetches live (the KB just changed — never a stale
* table) + the banner hidden;
* failed → #sync-error-banner with the error text, result cleared;
* idle → hide the banner, clear the result (a run vanishing with
* a server restart, or the post-403 cleanup).
*/
const syncBtn = document.querySelector("#sync-btn");
const syncLabel = document.querySelector("#sync-label");
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
const syncResult = document.querySelector("#sync-result");
const syncErrorBanner = document.querySelector("#sync-error-banner");
const syncErrorText = document.querySelector("#sync-error-text");
const SYNC_POLL_MS = 2000; // the 2 s status poll (task 02)
let syncPollTimer = null; // at most ONE live poll loop
function stopSyncPolling() {
if (syncPollTimer !== null) {
clearTimeout(syncPollTimer);
syncPollTimer = null;
}
}
function showSyncError(detail) {
if (syncErrorText) syncErrorText.textContent = detail || "The sync failed.";
if (syncErrorBanner) syncErrorBanner.hidden = false;
@@ -196,185 +178,28 @@ function hideSyncError() {
if (syncErrorBanner) syncErrorBanner.hidden = true;
}
/* The local HH:MM of finished_at — 24-hour, locale-independent, so the
* "Synced 14:32" last-result label is deterministic. */
function fmtSyncTime(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/* The last-result line for #sync-result (aria-live). "added" is ALWAYS
* announced (the run's headline term); "updated" / "pruned" only when
* they happened (zero terms omitted); "unchanged" whenever it is
* non-zero — or whenever nothing was added or updated, so a no-op
* re-sync reads "0 added · 1 unchanged" instead of an empty live
* region (the story gate's idempotency check). */
function fmtSyncResult(detail) {
const d = detail || {};
const added = d.added || 0;
const updated = d.updated || 0;
const parts = [`${added} added`];
if (updated > 0) parts.push(`${updated} updated`);
if ((d.unchanged || 0) > 0 || (added === 0 && updated === 0)) {
parts.push(`${d.unchanged || 0} unchanged`);
}
if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`);
return parts.join(" · ");
}
function enterRunningState() {
syncBtn.disabled = true;
syncBtn.setAttribute("aria-busy", "true");
if (syncIcon) syncIcon.classList.add("is-spinning");
syncLabel.textContent = "Syncing…";
window.addEventListener("bor:sync-status", (e) => {
const status = e.detail || {};
if (status.state === "running") {
if (syncResult) syncResult.textContent = "";
hideSyncError();
}
/* Settle the button back to clickable + un-spun with the given label. */
function settleSyncButton(label) {
syncBtn.disabled = false;
syncBtn.removeAttribute("aria-busy");
if (syncIcon) syncIcon.classList.remove("is-spinning");
syncLabel.textContent = label;
}
function applySyncSuccess(status) {
const time = fmtSyncTime(status.finished_at);
settleSyncButton(time ? `Synced ${time}` : "Synced");
} else if (status.state === "success") {
if (syncResult) syncResult.textContent = fmtSyncResult(status.detail);
hideSyncError();
// The KB just changed — refresh the catalog live so the table, stats,
// and empty state never sit stale under the "Synced" label (the sync is
// the page's own action; a reload should not be needed to see it).
// The KB just changed — refresh the catalog live so the table,
// stats, and empty state never sit stale under the "Synced" label
// (the sync is the page's own action; a reload should not be
// needed to see it).
loadDocs();
}
function applySyncFailure(status) {
settleSyncButton("Sync sources"); // retry-ready
} else if (status.state === "failed") {
if (syncResult) syncResult.textContent = "";
showSyncError(status.error);
}
/* A run can only vanish with a server restart mid-sync (status resets
* to idle — the phase-accepted behavior): re-enable retry-ready with no
* banner (there is no error to name; the next click re-syncs).
* Idempotent — also the post-403 cleanup. */
function applySyncIdle() {
settleSyncButton("Sync sources");
} else {
// idle
if (syncResult) syncResult.textContent = "";
hideSyncError();
}
/* The 2 s poll loop — the ONLY feedback timer (no client-side hard
* timeout, phase locked decision). One tick at a time (re-scheduled
* only while the run is still live, so an in-flight fetch can never
* overlap the next tick), and startSyncPolling refuses to run a second
* loop (a 409 adoption or a reload never doubles the polling). */
function startSyncPolling() {
if (syncPollTimer !== null) return;
const tick = async () => {
let status = null;
let notAdmin = false;
try {
const r = await fetch("/api/sync/status");
if (r.status === 403) notAdmin = true;
else if (r.ok) status = await r.json();
} catch {
/* network blip — the next tick retries (no client timeout to trip) */
}
if (notAdmin) {
// Session lost mid-sync: defense in depth — hide the button.
stopSyncPolling();
syncBtn.hidden = true;
applySyncIdle();
return;
}
if (status && status.state === "success") {
stopSyncPolling();
applySyncSuccess(status);
return;
}
if (status && status.state === "failed") {
stopSyncPolling();
applySyncFailure(status);
return;
}
if (status && status.state === "idle") {
// The run died with a server restart — retry-ready, no banner.
stopSyncPolling();
applySyncIdle();
return;
}
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
};
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
}
/* Click → POST /api/sync. 202 starts the run; 409 adopts the in-flight
* one (started elsewhere — e.g. a second tab); 403 hides the button
* (defense in depth); anything else names the failure in the banner and
* leaves the button retry-ready (the never-stale contract). */
async function startSync() {
let r;
try {
r = await fetch("/api/sync", { method: "POST" });
} catch {
showSyncError("Could not reach the server to start the sync — try again.");
return;
}
if (r.status === 403) {
stopSyncPolling();
syncBtn.hidden = true;
applySyncIdle();
return;
}
if (r.status === 202 || r.status === 409) {
enterRunningState();
startSyncPolling();
return;
}
let detail = "";
try {
detail = (await r.json()).detail || "";
} catch {
/* non-JSON error body */
}
showSyncError(detail || `The server refused to start the sync (${r.status}).`);
}
/* Load-time re-attach (admin only — the IIFE runs this after the
* whoami gate): a running run re-enters the running state (the user may
* have reloaded mid-sync), a terminal run renders its last result, idle
* renders nothing. */
async function initSyncButton() {
if (!syncBtn) return;
let status;
try {
const r = await fetch("/api/sync/status");
if (r.status === 403) {
syncBtn.hidden = true; // defense in depth
return;
}
if (!r.ok) return;
status = await r.json();
} catch {
return; // network blip — the button stays idle and clickable
}
if (status.state === "running") {
enterRunningState();
startSyncPolling();
} else if (status.state === "success") {
applySyncSuccess(status);
} else if (status.state === "failed") {
applySyncFailure(status);
}
/* idle → nothing to render */
}
if (syncBtn) syncBtn.addEventListener("click", startSync);
});
(async () => {
await initSharedHeader(); // phase 19: Sign in/out + Sources link in the shared bar
@@ -388,5 +213,6 @@ if (syncBtn) syncBtn.addEventListener("click", startSync);
}
if (gateEl) gateEl.hidden = true;
loadDocs();
initSyncButton(); // phase 32: re-attach to a running / last sync run
// Phase 34 task 02: the sync re-attach is module-owned (header.js
// boots it on the same cached whoami) — nothing to start here.
})();
+109 -40
View File
@@ -203,7 +203,10 @@ html::after {
flex-shrink: 0;
}
/* 2px brand→cyan gradient hairline under the sticky header (phase 08;
shared by the app header and the document-viewer header, phase 10). */
shared by the app header and the document-viewer header, phase 10).
In the viewer's two-row header (phase 34) this lands at the BOTTOM
edge of the whole header — row 1's own copy is suppressed there (see
the .doc-header rules below). */
.app-header::after,
.doc-header::after {
content: "";
@@ -235,12 +238,23 @@ html::after {
font-size: 1.125rem;
color: var(--ink);
text-decoration: none;
/* Phase 34 task 04 (visual pass): the bar must fit the FULL admin
control set at every width — the wordmark is the designated
squeeze target (min-width:0 lets flex shrink it; overflow:hidden
clips it clean instead of letting it overlap the nav). */
min-width: 0;
overflow: hidden;
}
/* Mono wordmark with letter-spacing — the "technical" touch (phase 08). */
/* Mono wordmark with letter-spacing — the "technical" touch (phase 08);
ellipsizes as the squeeze target (phase 34 task 04). */
.brand-text {
font-family: var(--mono);
font-size: 0.95rem;
letter-spacing: 0.08em;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand-mark { width: 22px; height: 22px; flex: 0 0 auto; display: block; }
.brand-text strong { color: var(--brand-ink); font-weight: 700; }
@@ -339,6 +353,18 @@ html::after {
}
.sync-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
.sync-btn:disabled { opacity: 0.6; cursor: wait; }
/* Phase 34: the failed-sync look — the button now lives on EVERY page
(the non-Sources pages have no error banner, so the button itself is
the visible failure state; the sanitized error text rides in title /
aria-label, set by the header module). Phase-08 error pair: --err-ink
on --err-bg ≈9.1:1, --err-line border (the amber --accent-line is
deflection-only — never on errors). */
.sync-btn.is-error {
background: var(--err-bg);
color: var(--err-ink);
border-color: var(--err-line);
}
.sync-btn.is-error:hover { background: var(--err-bg); color: var(--err-ink); }
.sync-icon { width: 16px; height: 16px; display: block; flex: 0 0 auto; }
/* Running state: the refresh icon spins (reuses the shared spin
keyframes) — the visible half of "Syncing…" while the 2 s poll waits. */
@@ -755,6 +781,16 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.steering-delete:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.steering-delete:disabled { opacity: 0.5; cursor: wait; }
.steering-empty { margin: 0.65rem 0 0; color: var(--ink-soft); font-size: 0.88rem; }
/* Phase 34 task 04 (visual pass): on the non-chat pages the panel is a
direct child of <main> (not inside a .container) — pin it to the same
72rem content column as everything else so it never renders as a
full-viewport band. The chat page's panel sits inside .chat-shell's
container, so this selector only hits the direct-child placement. */
.app-main > .steering-panel {
width: calc(100% - 2.5rem);
max-width: 69.5rem;
margin-inline: auto;
}
/* ---------- Global tuning page (phase 27) ---------- */
/* /tuning.html: create / edit / delete steering notes without a chat
@@ -1239,26 +1275,30 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.docs-table tbody tr:hover { background: var(--bg); }
.docs-table tbody tr:last-child td { border-bottom: 0; }
/* ---------- Document viewer (phase 10) ---------- */
/* Phase 12: the same fixed-height bar as .app-header (--header-h, 64px /
58px mobile) — the header must never change size between chat,
sources, and the document viewer (owner report 2026-08-22). */
/* ---------- Document viewer (phase 10; two-row header since phase 34) ---------- */
/* Phase 34 (owner confirmation 2026-08-26): the viewer header is TWO
rows in one sticky <header> — row 1 reuses the standard .app-header /
.header-inner rules VERBATIM (row 1 IS the standard bar, so the
phase-12/19 pinned heights 64px / 58px apply), row 2 is the
.doc-titlebar (back link + title + meta). The header element itself
is content-sized (row 1 + row 2) and sticky, so BOTH rows stay
pinned while the document scrolls. */
.doc-header {
position: sticky;
top: 0;
z-index: 20;
background: var(--surface);
height: var(--header-h);
/* Phase 12: same guard as .app-header — the bar never shrinks. */
/* Phase 34: no fixed height — the header sizes to row 1 (--header-h)
+ the .doc-titlebar; the phase-12 shrink guard stays. */
flex-shrink: 0;
}
.doc-header-inner {
height: 100%;
display: flex;
align-items: center;
gap: 0.9rem;
}
/* Back link: pill with an SVG arrow + "Sources" (>=44px touch target). */
/* Row 1's own gradient hairline would land BETWEEN the rows; the
separator there is the quiet --line border-top on the titlebar, and
the signature hairline belongs at the bottom edge of the whole
header (the .doc-header::after in the shared rule above). */
.doc-header > .app-header::after { content: none; }
/* Back link: pill with an SVG arrow + "Sources" (>=44px touch target;
:focus-visible via the global 3px outline rule, phase 08). */
.doc-back {
display: inline-flex;
align-items: center;
@@ -1276,31 +1316,37 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
}
.doc-back:hover { background: #2a345f; }
.doc-back svg { width: 16px; height: 16px; display: block; }
.doc-title-block { min-width: 0; }
/* Phase 19: the shared header controls reach the viewer bar (New chat
+ Sign in / Sign out) — margin-left:auto pushes them to the right;
the title block keeps clipping (min-width: 0 above) so the two pills
fit while the bar still measures exactly --header-h. The reused
.new-chat-btn / .auth-link classes already carry the ≤640px icon-only
rules, so at 360px the bar is back pill + clipping title + two icon
pills (no overflow — test_responsive_polish pins scrollWidth). */
.doc-header-actions {
margin-left: auto;
display: flex;
gap: 0.5rem;
align-items: center;
/* Row 2: the titlebar — a .container-width row with the back link +
the title block, its own content-sized height (title line + meta
line), the same surface as row 1, separated from it by the quiet
hairline (var(--line) — the existing 1px border color). */
.doc-titlebar {
border-top: 1px solid var(--line);
padding-block: 0.6rem;
}
.doc-titlebar .container {
display: flex;
align-items: center;
gap: 0.9rem;
}
/* The title block keeps clipping (min-width: 0) so #doc-title /
.doc-meta ellipsize inside the flex row instead of overflowing. */
.doc-title-block { min-width: 0; }
#doc-title {
margin: 0;
font-size: 1.3rem;
line-height: 1.3;
/* Phase 34: ellipsis + the `title` attribute (set by document.js) —
the row has no pills to fit, so the old clipping-for-pills rule is
gone; the ellipsis is the only fit the title needs. */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Meta row: source badge · format badge · mono path · indexed · chunks.
Phase 12: it may clip, but it must NEVER wrap — a wrapped meta row
would grow the header past the shared --header-h bar. */
Phase 12/34: it may clip, but it must NEVER wrap — the meta stays on
one line in the titlebar row (the row is content-sized, so a wrap
would grow it). */
.doc-meta {
display: flex;
flex-wrap: nowrap;
@@ -1606,6 +1652,20 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
font-size: 0.85rem;
}
/* ---------- Responsive (tablet squeeze — phase 34 task 04 visual pass) ----------
The full admin bar (brand + nav [Chat, Sources, Tuning] + Tuning
toggle + Sync sources + New chat + Sign out) outgrows a 768px bar in
the desktop styles. Squeeze the pills/gaps moderately — the 44px
touch floor is held by min-height and the 64px height by --header-h
— and let the brand wordmark (base ellipsis) absorb any remainder.
The ≤640 block below stays tighter and wins at phone widths. */
@media (max-width: 900px) {
.header-inner { gap: 0.65rem; }
.nav-link { padding: 0.4rem 0.6rem; font-size: 0.9rem; }
.app-nav { gap: 0.2rem; }
.new-chat-btn, .auth-link, .sync-btn, .steering-toggle { padding: 0.45rem 0.6rem; }
}
/* ---------- Responsive (mobile-first adjustments) ---------- */
@media (max-width: 640px) {
:root { --header-h: 58px; }
@@ -1619,9 +1679,17 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
the nav pills + gaps instead: tighter pill padding/font and a
tighter inner gap. The 44px touch floor is held by min-height, the
58px bar height is pinned by --header-h, and the bar now fits both
auth states at 375px (and 360px) without horizontal overflow. */
.header-inner { gap: 0.5rem; }
.brand { min-width: 0; }
auth states at 375px (and 360px) without horizontal overflow.
Phase 34 task 04 (visual pass): the full admin bar — nav [Chat,
Sources, Tuning] + Tuning toggle + Sync + New chat + Sign out —
now ships on EVERY page, so the squeeze tightens once more
(0.3rem inner gap, 0.78rem nav pills, 0.35rem pill padding,
18px brand mark) to hold 375px with the brand mark intact and
360px with the brand clipped clean (overflow:hidden — the mark
never overlaps the nav). */
.header-inner { gap: 0.3rem; }
.brand { min-width: 0; overflow: hidden; }
.brand-mark { width: 18px; height: 18px; }
.brand-text {
font-size: 0.8rem;
letter-spacing: 0.04em;
@@ -1630,20 +1698,20 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-link { padding: 0.4rem 0.4rem; font-size: 0.84rem; }
.app-nav { gap: 0.15rem; }
.new-chat-btn { padding: 0.4rem 0.55rem; }
.nav-link { padding: 0.35rem 0.3rem; font-size: 0.78rem; }
.app-nav { gap: 0.1rem; }
.new-chat-btn { padding: 0.4rem 0.35rem; }
.new-chat-label { display: none; }
.new-chat-btn svg { display: block; }
/* Phase 16: the auth pill goes icon-only like New chat — brand text
ellipsizes as the designated squeeze target, no bar overflow. */
.auth-link { padding: 0.4rem 0.55rem; }
.auth-link { padding: 0.4rem 0.35rem; }
.auth-label { display: none; }
.auth-link svg { display: block; }
/* Phase 32: the sync pill goes icon-only like the other pills (the
aria-label keeps the accessible name); the spinning icon is the
visible running state on a touch screen. */
.sync-btn { padding: 0.4rem 0.55rem; }
.sync-btn { padding: 0.4rem 0.35rem; }
.sync-label { display: none; }
/* The last-result counts stay ANNOUNCED (aria-live is untouched) but
go visually hidden — the 58px bar has no room for the text; the
@@ -1657,7 +1725,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
white-space: nowrap;
border: 0;
}
.steering-toggle { padding: 0.4rem 0.55rem; }
.steering-toggle { padding: 0.4rem 0.35rem; }
/* Visually hidden, NOT display:none — the accessible name keeps the
word "Tuning" next to the count badge. */
.steering-label {
@@ -1670,6 +1738,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
border: 0;
}
.steering-note { padding: 0.3rem 0.3rem 0.3rem 0.7rem; }
.app-main > .steering-panel { width: calc(100% - 1.8rem); }
.tune-btn { min-height: 44px; }
/* Phase 27: the tuning page squeezes like the other cards — the row
padding tightens; the icon+label actions keep their 44px floor and
@@ -1682,7 +1751,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.suggestions { flex-wrap: nowrap; overflow-x: auto; justify-content: flex-start;
padding-bottom: 0.4rem; -webkit-overflow-scrolling: touch; scrollbar-width: thin; }
.suggestion-chip { flex: 0 0 auto; }
.doc-header-inner { flex-wrap: nowrap; gap: 0.5rem; } /* phase 12: fixed-height bar — no wrap, no extra padding */
.doc-titlebar .container { gap: 0.5rem; } /* phase 34: row 2 squeezes like row 1 at mobile widths */
#doc-title { font-size: 1.1rem; }
.doc-path { max-width: 16rem; }
.doc-md { padding: 1.1rem 1rem; }
+12 -20
View File
@@ -29,10 +29,10 @@
* the admin-only Sources link, and this page's own admin-only
* "Tuning" nav link (#nav-tuning), all decided by the module's
* cached whoami promise (exactly one /api/whoami request per
* page); plus the non-chat New chat binding — "new chat" means
* going to the chat, fresh (clear the phase-14 conversation key,
* then navigate to "/"), the same contract as sources.js /
* document.js.
* page). Phase 34 task 02: the New chat binding is module-owned
* (assets/header.js, the SINGLE one) — on a non-chat page "new
* chat" means going to the chat, fresh (the module clears the
* phase-14 conversation key and navigates to "/").
*
* Anonymous-safe (task 03): the header already hides the "Tuning" nav
* link for anonymous visitors; a DIRECT anonymous URL still gets a safe
@@ -48,7 +48,7 @@
* inlines it into the page bundle in the image build).
*/
import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "./header.js";
import { fetchIsAdmin, initSharedHeader } from "./header.js";
/* ---------- page elements (tuning.html, task 02) ---------- */
const tuneForm = document.querySelector("#tune-form");
@@ -324,22 +324,14 @@ async function deleteNote(id, btn, li) {
}
}
/* ---------- non-chat New chat + header boot (task 02) ---------- */
/* ---------- header boot (task 02) ---------- */
/* Phase 19 contract: New chat on a non-chat page means "go to the
* chat, fresh": clear the phase-14 conversation key, then land on the
* chat page — its empty state, since the conversation is gone from
* storage (the same contract as sources.js / document.js). */
const newChatBtn = document.querySelector("#new-chat-btn");
if (newChatBtn) {
newChatBtn.addEventListener("click", () => {
clearChatStorage();
window.location.href = "/";
});
}
/* Boot: the shared header FIRST (Sign in/out + the admin-only nav
links — one cached whoami), then the note list — admin data only
/* The New chat binding is module-owned (assets/header.js, phase 34
* task 02 — the SINGLE binding): on this non-chat page it clears the
* phase-14 conversation key and navigates to the chat's empty state.
*
* Boot: the shared header FIRST (Sign in/out + the admin-only nav
* links — one cached whoami), then the note list — admin data only
(the Sources page gate pattern): an anonymous visitor gets the page
frame with the empty state, and the create form 403s gracefully on
submit if one tries. */
+92 -17
View File
@@ -11,28 +11,73 @@
<body>
<a class="skip-link" href="#main">Skip to content</a>
<!-- Phase 34 (owner confirmation 2026-08-26): the viewer carries the
SAME standard bar as every other page — row 1 is the shared
.app-header / .header-inner block (byte-identical controls), and
the back link + title + meta survive in a second .doc-titlebar
row inside the same <header> (the phase-19 single-row viewer bar
is superseded — this phase records the revision, PLAN.md §7.1
unchanged). No nav link is "current" here: a document is a
detail view reachable from chat or Sources, and the phase-13
back link carries the return affordance. -->
<header class="doc-header">
<div class="container doc-header-inner">
<a class="doc-back" id="doc-back" href="/sources.html">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
<span>Sources</span>
</a>
<div class="doc-title-block">
<h1 id="doc-title">Loading…</h1>
<div id="doc-meta" class="doc-meta"></div>
</div>
<!-- Phase 19: the shared header controls reach the viewer bar —
same markup, ids, and aria as the chat header (one consistent
bar on every page). The viewer has no nav, so no Sources link
here. header.js (assets/header.js) reveals exactly one of Sign in /
Sign out after whoami; New chat here means "go to the chat,
fresh" (document.js). The title block clips while the two
pills fit (.doc-header-actions, styles.css). -->
<div class="doc-header-actions">
<div class="app-header">
<div class="container header-inner">
<span class="brand">
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
<span class="brand-text">Brain of <strong>Reese</strong></span>
</span>
<nav class="app-nav" aria-label="Primary">
<a href="/" class="nav-link">Chat</a>
<!-- Phase 19 (now every page — phase 34, owner confirmation
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. The soft-gated page
itself is unchanged. -->
<a href="/sources.html" class="nav-link" id="nav-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>
</nav>
<!-- Phase 15 (now every page — phase 34, owner confirmation
2026-08-26): open the tuning-notes panel (stored in
Postgres, read into every system prompt). The behavior is
owned by the shared header module (assets/header.js); the
#steering-panel section ships in every page's <main>. -->
<button type="button" class="steering-toggle" id="steering-toggle"
aria-expanded="false" aria-controls="steering-panel">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>
<span class="steering-label">Tuning</span>
<span class="steering-count" id="steering-count">0</span>
</button>
<!-- Phase 32 (now every page — phase 34, owner confirmation
2026-08-26): the admin-only "Sync sources" button — SHIPS
hidden (anonymous-safe), header.js reveals it for the admin
on the SAME cached whoami that reveals #nav-sources /
#nav-tuning. The §7.4 "never stale" lifecycle is
module-owned (assets/header.js); the Sources page's
#sync-result line + #sync-error-banner render off the
module's "bor:sync-status" event. -->
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
<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" id="sync-label">Sync sources</span>
</button>
<!-- Phase 14 (now every page — phase 34, owner confirmation
2026-08-26; module-owned since phase 34 task 02): on the
chat page "New chat" resets the local (localStorage)
conversation; on every other page it means "go to the
chat, fresh" (the module clears the key + navigates). -->
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
<span class="new-chat-label">New chat</span>
</button>
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
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>
<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>
@@ -43,9 +88,39 @@
</button>
</div>
</div>
<!-- Row 2: the viewer titlebar — the phase-10 back link + title +
meta row, markup otherwise unchanged (document.js addresses
them by id). -->
<div class="doc-titlebar">
<div class="container">
<a class="doc-back" id="doc-back" href="/sources.html">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
<span>Sources</span>
</a>
<div class="doc-title-block">
<h1 id="doc-title">Loading…</h1>
<div id="doc-meta" class="doc-meta"></div>
</div>
</div>
</div>
</header>
<main id="main" class="app-main" tabindex="-1">
<!-- Phase 15 (now every page — phase 34, owner confirmation
2026-08-26): the tuning-notes panel (stored notes, newest
first) — rendered + driven by assets/header.js (shared), not
the page script. First child of <main> on the non-chat pages;
the chat page keeps it after #kb-banner. -->
<section class="steering-panel" id="steering-panel" role="region"
aria-label="Tuning notes" hidden>
<div class="steering-panel-head">
<h2 class="steering-panel-title">Tuning notes</h2>
<p class="steering-panel-sub">Every note below steers all future answers.</p>
</div>
<ul class="steering-list" id="steering-list"></ul>
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
</section>
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
<!-- aria-live wraps the load → content swap so screen readers hear the
document land (phase 10 a11y contract). -->
<div class="container doc-shell" aria-live="polite">
+41 -14
View File
@@ -19,32 +19,55 @@
</span>
<nav class="app-nav" aria-label="Primary">
<a href="/" class="nav-link is-active" aria-current="page">Chat</a>
<!-- Phase 19: the Sources link is admin-only (owner permission
2026-08-23) — hidden by default, header.js reveals it once
whoami says admin. The soft-gated page itself is unchanged. -->
<!-- Phase 19 (now every page — phase 34, owner confirmation
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. The soft-gated page
itself is unchanged. -->
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
<!-- Phase 29: 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. Points at
the standalone /tuning.html manager (phase 27). -->
<!-- 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>
</nav>
<!-- Phase 15: open the tuning-notes panel (stored in Postgres, read
into every system prompt) — chat page only. -->
<!-- Phase 15 (now every page — phase 34, owner confirmation
2026-08-26): open the tuning-notes panel (stored in
Postgres, read into every system prompt). The behavior is
owned by the shared header module (assets/header.js); the
#steering-panel section ships in every page's <main>. -->
<button type="button" class="steering-toggle" id="steering-toggle"
aria-expanded="false" aria-controls="steering-panel">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>
<span class="steering-label">Tuning</span>
<span class="steering-count" id="steering-count">0</span>
</button>
<!-- Phase 14: reset the local (localStorage) conversation — chat page only. -->
<!-- Phase 32 (now every page — phase 34, owner confirmation
2026-08-26): the admin-only "Sync sources" button — SHIPS
hidden (anonymous-safe), header.js reveals it for the admin
on the SAME cached whoami that reveals #nav-sources /
#nav-tuning. The §7.4 "never stale" lifecycle is
module-owned (assets/header.js); the Sources page's
#sync-result line + #sync-error-banner render off the
module's "bor:sync-status" event. -->
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
<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" id="sync-label">Sync sources</span>
</button>
<!-- Phase 14 (now every page — phase 34, owner confirmation
2026-08-26; module-owned since phase 34 task 02): on the
chat page "New chat" resets the local (localStorage)
conversation; on every other page it means "go to the
chat, fresh" (the module clears the key + navigates). -->
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
<span class="new-chat-label">New chat</span>
</button>
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign out
is visible; /api/whoami decides at load (app.js). Icon-only
below 640px (aria-labels keep the accessible names). -->
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
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>
<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>
@@ -63,7 +86,11 @@
<span id="kb-banner-text"></span>
</div>
<!-- Phase 15: tuning-notes panel (stored notes, newest first). -->
<!-- Phase 15 (now every page — phase 34, owner confirmation
2026-08-26): the tuning-notes panel (stored notes, newest
first) — rendered + driven by assets/header.js (shared), not
the page script. First child of <main> on the non-chat
pages; the chat page keeps it after #kb-banner. -->
<section class="steering-panel" id="steering-panel" role="region"
aria-label="Tuning notes" hidden>
<div class="steering-panel-head">
+77 -9
View File
@@ -20,16 +20,82 @@
</span>
<nav class="app-nav" aria-label="Primary">
<a href="/" class="nav-link">Chat</a>
<!-- Phase 19: the Sources link is admin-only (owner permission
2026-08-23) — hidden by default, header.js reveals it once
whoami says admin. The login page deliberately carries NO
chat controls, so header.js only toggles this link here. -->
<!-- Phase 19 (now every page — phase 34, owner confirmation
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. The soft-gated page
itself is unchanged. -->
<a href="/sources.html" class="nav-link" id="nav-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>
</nav>
<!-- Phase 15 (now every page — phase 34, owner confirmation
2026-08-26): open the tuning-notes panel (stored in
Postgres, read into every system prompt). The behavior is
owned by the shared header module (assets/header.js); the
#steering-panel section ships in every page's <main>. -->
<button type="button" class="steering-toggle" id="steering-toggle"
aria-expanded="false" aria-controls="steering-panel">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>
<span class="steering-label">Tuning</span>
<span class="steering-count" id="steering-count">0</span>
</button>
<!-- Phase 32 (now every page — phase 34, owner confirmation
2026-08-26): the admin-only "Sync sources" button — SHIPS
hidden (anonymous-safe), header.js reveals it for the admin
on the SAME cached whoami that reveals #nav-sources /
#nav-tuning. The §7.4 "never stale" lifecycle is
module-owned (assets/header.js); the Sources page's
#sync-result line + #sync-error-banner render off the
module's "bor:sync-status" event. -->
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
<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" id="sync-label">Sync sources</span>
</button>
<!-- Phase 14 (now every page — phase 34, owner confirmation
2026-08-26; module-owned since phase 34 task 02): on the
chat page "New chat" resets the local (localStorage)
conversation; on every other page it means "go to the
chat, fresh" (the module clears the key + navigates). -->
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
<span class="new-chat-label">New chat</span>
</button>
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
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>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
<span class="auth-label">Sign in</span>
</a>
<button type="button" class="auth-link" id="sign-out-btn" aria-label="Sign out" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
<span class="auth-label">Sign out</span>
</button>
</div>
</header>
<main id="main" class="app-main" tabindex="-1">
<!-- Phase 15 (now every page — phase 34, owner confirmation
2026-08-26): the tuning-notes panel (stored notes, newest
first) — rendered + driven by assets/header.js (shared), not
the page script. First child of <main> on the non-chat pages;
the chat page keeps it after #kb-banner. -->
<section class="steering-panel" id="steering-panel" role="region"
aria-label="Tuning notes" hidden>
<div class="steering-panel-head">
<h2 class="steering-panel-title">Tuning notes</h2>
<p class="steering-panel-sub">Every note below steers all future answers.</p>
</div>
<ul class="steering-list" id="steering-list"></ul>
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
</section>
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
<div class="container login-shell">
<!-- Centered sign-in card (phase 16): one admin, one password. -->
<section class="login-card" aria-labelledby="login-title">
@@ -61,11 +127,13 @@
</div>
</footer>
<!-- Phase 19: shared header module — the login page reuses it for the
Sources-link toggle only (no chat controls in this markup, so
none appear). It loads through the page script's own
`import "./header.js"` (hoisted, evaluated before the page script
body calls initSharedHeader() at boot). -->
<!-- Phase 19 (full header from phase 34 — owner confirmation
2026-08-26): the login page carries the SAME shared header as
every other page (nav incl. the admin-only Tuning link, Tuning
toggle, Sync, New chat, the auth pair) + the #steering-panel in
<main>. The module loads through the page script's own
`import "./header.js"` (hoisted, evaluated before the page
script body calls initSharedHeader() at boot). -->
<script type="module" src="/assets/login.js"></script>
</body>
</html>
+59 -23
View File
@@ -19,38 +19,59 @@
</span>
<nav class="app-nav" aria-label="Primary">
<a href="/" class="nav-link">Chat</a>
<!-- Phase 19: the Sources link is admin-only (owner permission
2026-08-23) — hidden by default, header.js reveals it once
whoami says admin. The soft-gated page itself is unchanged. -->
<!-- Phase 19 (now every page — phase 34, owner confirmation
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. 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>
<!-- Phase 29: 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. -->
<!-- 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>
</nav>
<!-- Phase 19: the shared header controls reach the Sources page —
same markup, ids, and aria as the chat header (one consistent
bar on every page). header.js (assets/header.js) reveals
exactly one of Sign in / Sign out after whoami; the New chat
button here means "go to the chat, fresh" (sources.js). -->
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
<span class="new-chat-label">New chat</span>
<!-- Phase 15 (now every page — phase 34, owner confirmation
2026-08-26): open the tuning-notes panel (stored in
Postgres, read into every system prompt). The behavior is
owned by the shared header module (assets/header.js); the
#steering-panel section ships in every page's <main>. -->
<button type="button" class="steering-toggle" id="steering-toggle"
aria-expanded="false" aria-controls="steering-panel">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>
<span class="steering-label">Tuning</span>
<span class="steering-count" id="steering-count">0</span>
</button>
<!-- Phase 32: the admin-only "Sync sources" button (TODO.md:5) —
SHIPS hidden (anonymous-safe), header.js reveals it for the
admin on the SAME cached whoami that reveals #nav-sources /
#nav-tuning (one fetch, no extra whoami call). sources.js
drives the §7.4 "never stale" lifecycle: idle → "Syncing…"
(disabled + spinning icon + 2 s GET /api/sync/status poll) →
last result ("Synced HH:MM" + counts in #sync-result) or the
role="alert" error banner. #sync-result is the aria-live
announcer for the last result. -->
<!-- Phase 32 (now every page — phase 34, owner confirmation
2026-08-26): the admin-only "Sync sources" button — SHIPS
hidden (anonymous-safe), header.js reveals it for the admin
on the SAME cached whoami that reveals #nav-sources /
#nav-tuning. The §7.4 "never stale" lifecycle is
module-owned (assets/header.js); the Sources page's
#sync-result line + #sync-error-banner render off the
module's "bor:sync-status" event. -->
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
<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" id="sync-label">Sync sources</span>
</button>
<!-- #sync-result is the aria-live announcer for the last sync
result ("N added · …"), sources-page-specific — it renders
off the module's "bor:sync-status" event (sources.js). -->
<span class="sync-result" id="sync-result" role="status" aria-live="polite"></span>
<!-- Phase 14 (now every page — phase 34, owner confirmation
2026-08-26; module-owned since phase 34 task 02): on the
chat page "New chat" resets the local (localStorage)
conversation; on every other page it means "go to the
chat, fresh" (the module clears the key + navigates). -->
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
<span class="new-chat-label">New chat</span>
</button>
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
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>
<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>
@@ -63,6 +84,21 @@
</header>
<main id="main" class="app-main" tabindex="-1">
<!-- Phase 15 (now every page — phase 34, owner confirmation
2026-08-26): the tuning-notes panel (stored notes, newest
first) — rendered + driven by assets/header.js (shared), not
the page script. First child of <main> on the non-chat pages;
the chat page keeps it after #kb-banner. -->
<section class="steering-panel" id="steering-panel" role="region"
aria-label="Tuning notes" hidden>
<div class="steering-panel-head">
<h2 class="steering-panel-title">Tuning notes</h2>
<p class="steering-panel-sub">Every note below steers all future answers.</p>
</div>
<ul class="steering-list" id="steering-list"></ul>
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
</section>
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
<div class="container sources-shell">
<!-- Phase 32: the sync failure banner — the chat error-banner
markup style (kb-banner + is-error), role="alert" so a failed
+58 -17
View File
@@ -19,29 +19,55 @@
</span>
<nav class="app-nav" aria-label="Primary">
<a href="/" class="nav-link">Chat</a>
<!-- Phase 19: the Sources link is admin-only (owner permission
2026-08-23) — hidden by default, header.js reveals it once
whoami says admin. The soft-gated page itself is unchanged. -->
<!-- Phase 19 (now every page — phase 34, owner confirmation
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. The soft-gated page
itself is unchanged. -->
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
<!-- Phase 27: this page's own nav link. Admin-only, exactly like
the Sources link: it SHIPS hidden (anonymous-safe default —
the phase-16 "absent, not hidden" spirit) and header.js
reveals it once whoami says admin, so no anonymous user ever
sees it for a frame. -->
<a href="/tuning.html" class="nav-link is-active" id="nav-tuning" aria-current="page" hidden>Tuning</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>
</nav>
<!-- Phase 19: the shared header controls reach the tuning page —
same markup, ids, and aria as the other pages (one consistent
bar on every page). header.js (assets/header.js) reveals
exactly one of Sign in / Sign out after whoami; the New chat
button here means "go to the chat, fresh" (tuning.js). -->
<!-- Phase 15 (now every page — phase 34, owner confirmation
2026-08-26): open the tuning-notes panel (stored in
Postgres, read into every system prompt). The behavior is
owned by the shared header module (assets/header.js); the
#steering-panel section ships in every page's <main>. -->
<button type="button" class="steering-toggle" id="steering-toggle"
aria-expanded="false" aria-controls="steering-panel">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>
<span class="steering-label">Tuning</span>
<span class="steering-count" id="steering-count">0</span>
</button>
<!-- Phase 32 (now every page — phase 34, owner confirmation
2026-08-26): the admin-only "Sync sources" button — SHIPS
hidden (anonymous-safe), header.js reveals it for the admin
on the SAME cached whoami that reveals #nav-sources /
#nav-tuning. The §7.4 "never stale" lifecycle is
module-owned (assets/header.js); the Sources page's
#sync-result line + #sync-error-banner render off the
module's "bor:sync-status" event. -->
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
<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" id="sync-label">Sync sources</span>
</button>
<!-- Phase 14 (now every page — phase 34, owner confirmation
2026-08-26; module-owned since phase 34 task 02): on the
chat page "New chat" resets the local (localStorage)
conversation; on every other page it means "go to the
chat, fresh" (the module clears the key + navigates). -->
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
<span class="new-chat-label">New chat</span>
</button>
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign out
is visible; /api/whoami decides at load (header.js). Icon-only
below 640px (aria-labels keep the accessible names). -->
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
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>
<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>
@@ -54,6 +80,21 @@
</header>
<main id="main" class="app-main" tabindex="-1">
<!-- Phase 15 (now every page — phase 34, owner confirmation
2026-08-26): the tuning-notes panel (stored notes, newest
first) — rendered + driven by assets/header.js (shared), not
the page script. First child of <main> on the non-chat pages;
the chat page keeps it after #kb-banner. -->
<section class="steering-panel" id="steering-panel" role="region"
aria-label="Tuning notes" hidden>
<div class="steering-panel-head">
<h2 class="steering-panel-title">Tuning notes</h2>
<p class="steering-panel-sub">Every note below steers all future answers.</p>
</div>
<ul class="steering-list" id="steering-list"></ul>
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
</section>
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
<div class="container tuning-shell">
<div class="page-head">
<h1>Global Tuning</h1>
+5 -3
View File
@@ -101,9 +101,11 @@ def test_anonymous_chat_without_tuning(
# Header: Sign in offered, Sign out not.
expect(page.locator("#sign-in-link")).to_be_visible()
expect(page.locator("#sign-in-link")).to_have_attribute(
"href", "/login.html?next=/sources.html"
)
# Phase 34 task 02: the shared header module rewrites the static
# ?next= fallback to the CURRENT pathname ("return to where you
# were") — on the chat page that is "/" (the markup keeps
# ?next=/sources.html as the no-JS fallback only).
expect(page.locator("#sign-in-link")).to_have_attribute("href", "/login.html?next=/")
expect(page.locator("#sign-out-btn")).to_be_hidden()
# Chat still streams a grounded answer (with source chips) for
+20 -3
View File
@@ -18,6 +18,14 @@ Phase 16 adaptation: the auth control (Sign in / Sign out) joins the chat
header's ``.header-inner`` — the desktop test verifies its presence in
both auth states without the bar's height moving (height assertions
unchanged).
Phase 34 adaptation (two-row viewer header, owner confirmation
2026-08-26): the viewer's ``<header>`` is now TWO rows — row 1 is the
standard shared bar (``.doc-header .app-header``, the phase-12/19
``--header-h`` contract) and row 2 is the ``.doc-titlebar`` (back +
title + meta, content-sized). The height assertions are pointed at ROW
1 — the standard bar — which must equal the chat/sources bars exactly;
the titlebar row is asserted present (height > 0), not height-pinned.
"""
from __future__ import annotations
@@ -95,7 +103,9 @@ def _header_heights(page: Page, app_url: str) -> dict[str, float]:
page.goto(app_url + VIEWER_URL)
expect(page.locator("#doc-title")).to_have_text("Kubernetes Homelab Cluster", timeout=15_000)
heights["document"] = _box_height(page, ".doc-header")
# Phase 34: the viewer header is two rows — measure ROW 1 (the
# standard bar), which must equal the other pages' bars exactly.
heights["document"] = _box_height(page, ".doc-header .app-header")
return heights
@@ -158,6 +168,9 @@ def test_header_height_identical_across_pages_mobile(
def test_viewer_header_content_still_fits(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Phase 34: the phase-10 viewer content (title, badges, back link)
survives in the titlebar ROW, and row 1 stays the pinned standard
bar — single-line on both desktop and mobile."""
_seed_db(mock_llm)
for width, expected_h in ((1280, DESKTOP_HEADER_H), (375, MOBILE_HEADER_H)):
@@ -176,10 +189,14 @@ def test_viewer_header_content_still_fits(
expect(page.locator(".format-badge", has_text="md")).to_be_visible()
expect(page.locator(".doc-path", has_text="homelab/kubernetes.md")).to_be_visible()
# Back link still there, ≥44px touch target, in the shared bar.
# Back link still there, ≥44px touch target, in the titlebar row.
back = page.locator("#doc-back")
expect(back).to_be_visible()
assert _box_height(page, "#doc-back") >= 44
expect(page.locator(".doc-header")).to_have_css(
# Phase 34 two-row contract: ROW 1 is the standard bar (the
# pinned --header-h), and the titlebar row is present below it.
expect(page.locator(".doc-header .app-header")).to_have_css(
"height", f"{expected_h}px"
)
assert _box_height(page, ".doc-titlebar") > 0, ("the titlebar row must render")
+493
View File
@@ -0,0 +1,493 @@
"""Phase 34 story E2E (Playwright): ONE navbar on every page.
Story: ``.agent/user_stories/nav-consistency.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_nav_consistency.py -v --no-cov
TODO.md L3 (owner 2026-08-26): "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."
Contract under test — the header is IDENTICAL on all five pages (chat,
sources, document viewer, global tuning, login): one shared markup block
(phase 34 task 03), one owner of all control behavior (header.js, tasks
01/02), the viewer's back + title preserved in a second titlebar row
(task 04), and the phase-12/19 height contract (64px desktop / 58px at
≤640px) on the standard row everywhere.
Per role, the VISIBLE inventory:
* admin: brand + nav [Chat, #nav-sources, #nav-tuning] + #steering-toggle
+ #sync-btn + #new-chat-btn + #sign-out-btn (with #sign-in-link
hidden) — on all five pages, same id+class inventory, same DOM order;
* anonymous: brand + nav [Chat] (#nav-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 + panel are ABSENT from the DOM (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,
so both are stripped (the href is compared by pathname only).
Viewer specifics: row 1 (the standard bar) is exactly as tall as the chat
page's bar (64px / 58px) and row 2 (``.doc-titlebar``) is present with
#doc-back + #doc-title + #doc-meta badges; #doc-back target resolution
(phase 13) is honored — ``back=`` accepted for same-origin relative
URLs, rejected (→ /sources.html) otherwise.
Steering works off-chat: on /tuning.html (admin, zero notes) the toggle
opens/closes #steering-panel with the empty state and a 0 count badge —
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).
Determinism note: every assertion is settled-state — each page visit
first waits for the whoami toggle to land (exactly one of Sign in /
Sign out visible; the anonymous removal of the steering toggle happens
in the SAME initSharedHeader pass) and, on the viewer, for the document
title to render. The seed truncates steering_notes, so the count badge is
0 on every admin page. No chat turn is ever submitted; #sync-btn is
never clicked.
Test → story mapping (Playwright Mapping Rule):
1. ``test_admin_inventory_identical_on_all_five_pages``
2. ``test_anonymous_inventory_identical_on_all_five_pages``
3. ``test_viewer_row1_height_matches_chat_and_titlebar_present``
4. ``test_viewer_back_link_honors_back_param``
5. ``test_steering_panel_works_off_chat_on_tuning_page``
6. ``test_sync_button_present_on_tuning_page_without_triggering``
"""
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"
#: The five pages of the app (acceptance criterion 2 of the story).
CHAT_URL = "/"
SOURCES_URL = "/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 phase-12/19 pinned bar heights (frontend/assets/styles.css
#: --header-h, desktop and ≤640px).
DESKTOP_HEADER_H = 64
MOBILE_HEADER_H = 58
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread (Playwright owns the test loop)."""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _seed_db(mock_port: int) -> None:
"""Fresh KB + ZERO steering notes (deterministic count badge on
every admin page) + the fixture docs for the viewer URL."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
db.commit()
_run_in_thread(_import_fixtures(mock_port))
def _box_height(page: Page, selector: str) -> float:
box = page.locator(selector).bounding_box()
assert box is not None, f"{selector} not rendered"
return box["height"]
# ---------------------------------------------------------------------------
# The heart of the suite: the normalized header-control inventory
# ---------------------------------------------------------------------------
#: The header controls, in their shipped DOM order. The inventory is
#: normalized per the task: the current-page ``is-active`` nav marker is
#: stripped from the class list, and anchor hrefs are compared by
#: pathname only (the sign-in ``?next=`` value legitimately differs per
#: page — it is rewritten to the current page by header.js).
_INVENTORY_JS = """() => {
const inner = document.querySelector("header .header-inner");
if (!inner) return null;
const sel = [
".brand",
".app-nav > a.nav-link",
"#steering-toggle",
"#sync-btn",
"#new-chat-btn",
"#sign-in-link",
"#sign-out-btn",
].join(",");
return [...inner.querySelectorAll(sel)].map((el) => {
const classes = [...el.classList].filter((c) => c !== "is-active");
const id = el.id ? "#" + el.id : "";
const href = el.tagName === "A" ? (el.getAttribute("href") || "").split("?")[0] : "";
const text = (el.textContent || "").replace(/\\s+/g, " ").trim();
return el.tagName.toLowerCase() + id + "." + classes.join(".") + "::" + text + "::" + href;
});
}"""
def _header_inventory(page: Page) -> list[str]:
"""The ordered id+class inventory of the header controls on the page
``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}"
return inv
def _wait_settled(page: Page, admin: bool) -> None:
"""Wait for initSharedHeader's whoami toggle to land: exactly one of
Sign in / Sign out is visible (both ship hidden in the HTML). For
anonymous visitors the steering toggle + panel removal happens in
the SAME pass, so they are already gone when this returns."""
if admin:
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
expect(page.locator("#sign-in-link")).to_be_hidden()
else:
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
expect(page.locator("#sign-out-btn")).to_be_hidden()
def _assert_landmarks(page: Page, label: str) -> None:
"""UI Structure Check (AGENTS.md rule 5): the page's landmarks — a
<header>, the labeled <nav>, and <main> — survive on every page."""
assert page.locator("header").count() >= 1, f"{label}: no <header> landmark"
assert page.locator('nav[aria-label="Primary"]').count() == 1, (
f"{label}: no labeled <nav aria-label> landmark"
)
assert page.locator("main").count() >= 1, f"{label}: no <main> landmark"
def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[str]:
"""Goto a page, wait for the settled header state (+ the document on
the viewer), check the per-role visible inventory, and return the
normalized control inventory."""
page.goto(app_url + url)
_wait_settled(page, admin=admin)
if name == "viewer":
# The document itself has settled (rendered, not Loading…/
# not-found) so the bar is measured on the real page.
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
# The per-role VISIBLE inventory (the story: no button pops in or
# out because of which page you are on).
expect(page.locator(".app-nav a[href='/']")).to_be_visible() # Chat
if admin:
expect(page.locator("#nav-sources")).to_be_visible()
expect(page.locator("#nav-tuning")).to_be_visible()
expect(page.locator("#steering-toggle")).to_be_visible()
expect(page.locator("#sync-btn")).to_be_visible()
expect(page.locator("#sign-out-btn")).to_be_visible()
expect(page.locator("#sign-in-link")).to_be_hidden()
else:
# Locked A10 UI revision: admin-only links ship hidden, never
# revealed for anonymous…
expect(page.locator("#nav-sources")).to_be_hidden()
expect(page.locator("#nav-tuning")).to_be_hidden()
expect(page.locator("#sync-btn")).to_be_hidden()
expect(page.locator("#sign-out-btn")).to_be_hidden()
expect(page.locator("#sign-in-link")).to_be_visible()
# …and the steering surface is ABSENT (phase 16 "absent, not
# hidden", carried into the shared module by phase 34 task 01
# — test_admin_auth pins the same contract).
assert page.locator("#steering-toggle").count() == 0, (
f"{name}: the steering toggle must be absent for anonymous"
)
assert page.locator("#steering-panel").count() == 0, (
f"{name}: the steering panel must be absent for anonymous"
)
expect(page.locator("#new-chat-btn")).to_be_visible()
_assert_landmarks(page, name)
return _header_inventory(page)
LOGIN_JS_ROUTE = re.compile(r"/assets/login\.js(\?.*)?$")
def _admin_login_page_inventory(page: Page, app_url: str) -> list[str]:
"""The login page's header IN THE ADMIN STATE. A signed-in admin is
redirected off the login form by login.js (``location.replace`` →
the default next, /sources.html — phase 16, pinned by
test_admin_auth), so this ONE visit serves the page script with the
redirect lines suppressed (a test-local route on the login.js
script; 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)
_wait_settled(page, admin=True)
expect(page.locator(".app-nav a[href='/']")).to_be_visible()
expect(page.locator("#nav-sources")).to_be_visible()
expect(page.locator("#nav-tuning")).to_be_visible()
expect(page.locator("#steering-toggle")).to_be_visible()
expect(page.locator("#sync-btn")).to_be_visible()
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:
page.unroute(LOGIN_JS_ROUTE)
# ---------------------------------------------------------------------------
# 1. Admin: the same visible controls, same inventory, same DOM order,
# on all five pages
# ---------------------------------------------------------------------------
def test_admin_inventory_identical_on_all_five_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)
inventories: dict[str, list[str]] = {}
for name, url in (
("chat", CHAT_URL),
("sources", SOURCES_URL),
("viewer", VIEWER_URL),
("tuning", TUNING_URL),
):
inventories[name] = _visit(page, app_url, name, url, admin=True)
# The login page redirects a signed-in admin away — measure it with
# the redirect aborted (see the helper).
inventories["login"] = _admin_login_page_inventory(page, app_url)
reference = inventories["chat"]
for name, inv in inventories.items():
assert inv == reference, (
f"admin header control inventory differs on {name}:\n"
f" chat: {reference}\n {name}: {inv}"
)
# ---------------------------------------------------------------------------
# 2. Anonymous: the reduced bar — identically — on all five pages
# ---------------------------------------------------------------------------
def test_anonymous_inventory_identical_on_all_five_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)
# No login: a fresh context is anonymous by construction.
inventories: dict[str, list[str]] = {}
for name, url in (
("chat", CHAT_URL),
("sources", SOURCES_URL),
("viewer", VIEWER_URL),
("tuning", TUNING_URL),
("login", LOGIN_URL),
):
inventories[name] = _visit(page, app_url, name, url, admin=False)
reference = inventories["chat"]
for name, inv in inventories.items():
assert inv == reference, (
f"anonymous header control inventory differs on {name}:\n"
f" chat: {reference}\n {name}: {inv}"
)
# ---------------------------------------------------------------------------
# 3. Viewer: row 1 is exactly the chat bar's height (64px / 58px) and
# the titlebar row (back + title + meta) is present
# ---------------------------------------------------------------------------
def test_viewer_row1_height_matches_chat_and_titlebar_present(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_seed_db(mock_llm)
for width, expected_h in ((1280, DESKTOP_HEADER_H), (375, MOBILE_HEADER_H)):
page.set_viewport_size({"width": width, "height": 800})
page.goto(app_url + CHAT_URL)
chat_h = _box_height(page, ".app-header")
page.goto(app_url + VIEWER_URL)
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
# Row 1 IS the standard bar — the same --header-h as chat…
row1 = _box_height(page, ".doc-header .app-header")
assert row1 == expected_h, f"viewer row 1 is {row1}px at {width}px"
assert chat_h == expected_h, f"chat bar is {chat_h}px at {width}px"
assert row1 == chat_h, "viewer row 1 must match the chat bar exactly"
# …and the titlebar row exists below it (content-sized, > 0)…
titlebar = _box_height(page, ".doc-titlebar")
assert titlebar > 0, "the .doc-titlebar row is not rendered"
# …with the back link + the rendered title + the meta badges.
expect(page.locator("#doc-back")).to_be_visible()
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE)
expect(page.locator("#doc-meta .doc-source-badge", has_text="docs")).to_be_visible()
expect(page.locator("#doc-meta .format-badge", has_text="md")).to_be_visible()
# ---------------------------------------------------------------------------
# 4. Viewer: #doc-back target resolution (phase 13) — one positive, one
# rejection case, both by clicking the link
# ---------------------------------------------------------------------------
def test_viewer_back_link_honors_back_param(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
# Positive: back=/ (same-origin relative) is honored — href "/",
# label "Chat", and the click returns to the chat page.
page.goto(app_url + VIEWER_URL + "&back=%2F")
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
back = page.locator("#doc-back")
expect(back).to_have_attribute("href", "/")
expect(back.locator("span")).to_have_text("Chat")
back.click()
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
# Rejection: an absolute URL is NOT same-origin-relative — the
# target falls back to the Sources page (label "Sources") and the
# click goes there.
page.goto(app_url + VIEWER_URL + "&back=https%3A%2F%2Fevil.example")
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")
back.click()
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
# ---------------------------------------------------------------------------
# 5. Steering works off-chat: on /tuning.html (admin, zero notes) the
# header toggle drives the panel — open/close cycle, empty state,
# count badge 0. No chat turn is needed.
# ---------------------------------------------------------------------------
def test_steering_panel_works_off_chat_on_tuning_page(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm) # truncates steering_notes → zero notes
login(page, app_url, next=TUNING_URL)
expect(page).to_have_url(app_url + TUNING_URL, timeout=30_000)
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
# Settled admin state: the toggle is on the bar, the panel ships
# hidden, and the count badge reads 0 (zero seeded notes).
expect(page.locator("#steering-toggle")).to_be_visible()
expect(page.locator("#steering-panel")).to_be_hidden()
expect(page.locator("#steering-count")).to_have_text("0")
# Open: the panel shows, the toggle's aria-expanded follows, and
# the empty state is visible (the re-open refresh fetched 0 notes).
page.click("#steering-toggle")
expect(page.locator("#steering-panel")).to_be_visible()
expect(page.locator("#steering-toggle")).to_have_attribute("aria-expanded", "true")
expect(page.locator("#steering-empty")).to_be_visible()
expect(page.locator("#steering-list .steering-note")).to_have_count(0)
expect(page.locator("#steering-count")).to_have_text("0")
# Close: the cycle completes, the count badge still reads 0.
page.click("#steering-toggle")
expect(page.locator("#steering-panel")).to_be_hidden()
expect(page.locator("#steering-toggle")).to_have_attribute("aria-expanded", "false")
expect(page.locator("#steering-count")).to_have_text("0")
# ---------------------------------------------------------------------------
# 6. Sync is present (admin) on a non-Sources page — and is NOT
# triggered: a real sync clones real repos; the full state machine
# is test_sync_button.py's job.
# ---------------------------------------------------------------------------
def test_sync_button_present_on_tuning_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)
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
btn = page.locator("#sync-btn")
expect(btn).to_be_visible(timeout=15_000)
# Idle, retry-ready state — the boot re-attach (GET /api/sync/status,
# idle on the fresh app) must not have left it busy or labeled as a
# finished run.
expect(btn).to_be_enabled()
assert btn.get_attribute("aria-busy") is None, "a fresh idle sync must not be busy"
expect(page.locator("#sync-label")).to_have_text("Sync sources")
# Deliberately NOT clicked.
+34 -24
View File
@@ -8,16 +8,20 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
Contract under test (owner report 2026-08-23, phase 19) — ONE bar per
page, the same controls everywhere:
* chat / sources: brand + nav [Chat, Sources — admin only] + New Chat
+ Sign in / Sign out;
* document viewer: back + title + New Chat + Sign in / Sign out (the
viewer has no nav, so no Sources link at all);
* chat / sources / viewer: brand + nav [Chat, Sources — admin only] +
New Chat + Sign in / Sign out;
* document viewer: the standard bar (row 1) + back + title + meta in a
second titlebar row (phase 34, owner confirmation 2026-08-26 — the
viewer's old "no nav" single-row bar is superseded; it now carries
the SAME nav contract as every other page);
* the "Sources" nav link (``#nav-sources``) is HIDDEN for anonymous
users on every page that has a nav and shown for admin (phase-16 UX
revision with owner permission; the soft-gate page and the A10 API
split are untouched);
users on every page and shown for admin (phase-16 UX revision with
owner permission; the soft-gate page and the A10 API split are
untouched) — now on the viewer as well (phase 34);
* the bar height never moves: 64px desktop / 58px at ≤640px (phase-12
``--header-h`` contract, bounding-box measurement convention).
``--header-h`` contract, bounding-box measurement convention) — on
the viewer this is ROW 1 (``.doc-header .app-header``); the
titlebar row is content-sized.
Determinism note: every assertion is settled-state — ``assert_shared_bar``
first waits for the whoami toggle to land (exactly one of Sign in /
@@ -109,7 +113,10 @@ def _expected_h(page: Page) -> int:
def _bar_selector(page_kind: str) -> str:
return ".doc-header" if page_kind == "viewer" else ".app-header"
"""The STANDARD bar element on each page kind. Phase 34: the
viewer's header is two rows — the height contract applies to row 1
(the standard bar), not the whole two-row <header>."""
return ".doc-header .app-header" if page_kind == "viewer" else ".app-header"
def assert_shared_bar(page: Page, admin: bool, page_kind: str) -> None:
@@ -134,24 +141,26 @@ def assert_shared_bar(page: Page, admin: bool, page_kind: str) -> None:
# New Chat is on the bar on every page kind (the owner's ask).
expect(page.locator("#new-chat-btn")).to_be_visible()
if page_kind == "viewer":
# The viewer has no nav — no Sources link in the DOM at all.
assert page.locator("#nav-sources").count() == 0, (
"the viewer bar must not carry a Sources nav link"
)
# The document itself has settled (rendered, not Loading…/not-found)
# so the bar is being measured on the real page.
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
else:
# The Sources nav link: admin-only (phase-16 revision, owner
# permission 2026-08-23) — hidden for anonymous, shown for admin.
nav = page.locator("#nav-sources")
assert nav.count() == 1, f"one #nav-sources expected on the {page_kind} page"
# Phase 34: EVERY page kind — viewer included — carries the SAME
# nav contract: the Chat link always visible; the admin-only
# #nav-sources / #nav-tuning links (phase 19 / phase 29) ship
# hidden and are revealed for admin (phase-16 UX revision, owner
# permission 2026-08-23; the soft-gate page and the A10 API split
# are untouched).
expect(page.locator(".app-nav a[href='/']")).to_be_visible()
for link_id in ("#nav-sources", "#nav-tuning"):
nav = page.locator(link_id)
assert nav.count() == 1, f"one {link_id} expected on the {page_kind} page"
if admin:
expect(nav).to_be_visible()
else:
expect(nav).to_be_hidden()
if page_kind == "viewer":
# The document itself has settled (rendered, not Loading…/not-found)
# so the bar is being measured on the real page.
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
# The bar height never moves: 64px desktop / 58px ≤640px (phase 12),
# bounding-box measurement — the new pills must fit inside it.
box = page.locator(_bar_selector(page_kind)).bounding_box()
@@ -238,8 +247,9 @@ def test_sources_nav_hidden_for_anonymous_everywhere(
assert nav.count() == 1
expect(nav).to_be_hidden()
# The login page has no chat controls — header.js only toggles the
# nav link there; for anonymous it stays hidden (it ships hidden).
# The login page carries the full shared header too (phase 34);
# for anonymous the admin-only nav links stay hidden (they ship
# hidden and are revealed only for the admin).
page.goto(app_url + "/login.html")
page.wait_for_load_state("networkidle") # the whoami round-trip has settled
nav = page.locator("#nav-sources")
+2
View File
@@ -59,6 +59,8 @@ def _make_git_repo(base: Path) -> tuple[Path, str]:
"user.name=test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false", # the fixture commit never signs (env gpg)
"commit",
"-q",
"-m",
+320 -50
View File
@@ -3,9 +3,10 @@
The browser behavior is E2E-covered (tests/e2e/test_shared_header.py);
here we pin the source-level wiring — the header.js exports, the cached
whoami promise, the per-page HTML ids (anonymous-safe hidden-by-default
controls), the sign-out binding move out of app.js, the non-chat New
Chat bindings, and the viewer-bar CSS — so a silent regression is caught
without a browser.
controls), the sign-out binding move out of app.js, the SINGLE
module-owned New chat binding (phase 34 task 02) + the sign-in
?next= rewrite, and the viewer-bar CSS — so a silent regression is
catched without a browser.
"""
from __future__ import annotations
@@ -109,17 +110,75 @@ def test_sign_out_binding_lives_in_the_shared_module() -> None:
def test_nav_sources_ships_hidden_on_every_nav_page() -> None:
"""Phase 19 UX revision (owner permission 2026-08-23): the Sources
nav link is hidden for anonymous — so it SHIPS with the hidden
attribute (anonymous-safe default) on every page that has a nav
(chat, sources, login)."""
for html in (INDEX_HTML, SOURCES_HTML, LOGIN_HTML, TUNING_HTML):
"""Phase 19 UX revision (owner permission 2026-08-23), completed on
all five pages by phase 34 task 03 (owner confirmation 2026-08-26):
the Sources nav link is hidden for anonymous — so it SHIPS with the
hidden attribute (anonymous-safe default) on every page (they all
carry the nav now, viewer included)."""
for html in (INDEX_HTML, SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
text = _text(html)
assert re.search(r'id="nav-sources"[^>]*\bhidden\b', text), (
f"{html.name}: #nav-sources must ship hidden"
)
def test_all_five_pages_share_the_header_control_order() -> None:
"""Phase 34 task 03 (owner confirmation 2026-08-26): every page
ships the IDENTICAL header control inventory in the IDENTICAL
order — brand, nav [Chat, Sources, Tuning], #steering-toggle,
#sync-btn, #new-chat-btn, Sign in, Sign out — inside the shared
.header-inner row (the document viewer's row 1). Only the
current-page is-active nav marker and the static ?next= fallback
may differ per page (task 05's story E2E pins the rendered
result)."""
markers = (
'class="brand"',
'<nav class="app-nav"',
'href="/"',
'id="nav-sources"',
'id="nav-tuning"',
'id="steering-toggle"',
'id="sync-btn"',
'id="new-chat-btn"',
'id="sign-in-link"',
'id="sign-out-btn"',
)
for html in (INDEX_HTML, SOURCES_HTML, DOCUMENT_HTML, TUNING_HTML, LOGIN_HTML):
text = _text(html)
start = text.find('<div class="container header-inner">')
assert start != -1, f"{html.name}: missing the shared .header-inner row"
region = text[start : text.find("</header>", start)]
missing = [m for m in markers if m not in region]
assert not missing, f"{html.name}: header controls missing {missing}"
for m in markers:
assert region.count(m) == 1, f"{html.name}: {m} must appear exactly once"
# Same order on every page: each control follows the previous one.
pos = -1
for m in markers:
idx = region.find(m, pos + 1)
assert idx > pos, f"{html.name}: {m} out of order in the shared bar"
pos = idx
def test_all_five_pages_carry_the_steering_panel() -> None:
"""Phase 34 task 03: the #steering-panel section (+ the
#steering-announcer live region) ships on every page — after
#kb-banner in the chat shell, first child of <main> on the other
four pages — ship hidden, driven by assets/header.js."""
for html in (INDEX_HTML, SOURCES_HTML, DOCUMENT_HTML, TUNING_HTML, LOGIN_HTML):
text = _text(html)
tag = re.search(r'<section[^>]*id="steering-panel"[^>]*>', text)
assert tag, f"{html.name}: missing the #steering-panel section"
assert re.search(r'\bhidden\b', tag.group(0)), "the panel ships hidden"
assert 'id="steering-list"' in text
assert 'id="steering-empty"' in text
assert re.search(r'<p[^>]*id="steering-announcer"[^>]*role="status"[^>]*>', text), (
f"{html.name}: missing the #steering-announcer live region"
)
# The announcer follows the panel (the copied index.html block).
assert text.find('id="steering-panel"') < text.find('id="steering-announcer"')
def test_nav_tuning_ships_hidden_on_the_tuning_page() -> None:
"""Phase 27: the Global Tuning page reuses the shared header — the
"Tuning" nav link is admin-only, so it SHIPS hidden (revealed by
@@ -139,10 +198,38 @@ def test_nav_tuning_ships_hidden_on_the_tuning_page() -> None:
assert [s for s in srcs if "tuning.js" in s]
def test_nav_sources_is_absent_from_the_viewer() -> None:
"""The document viewer has no nav — no #nav-sources element there (the
module's missing-element no-op keeps it out)."""
assert 'id="nav-sources"' not in _text(DOCUMENT_HTML)
def test_viewer_carries_the_standard_nav() -> None:
"""Phase 34 task 03 (owner confirmation 2026-08-26): the document
viewer carries the SAME standard bar as every other page — row 1 is
the shared .header-inner block, so the nav (Chat + the admin-only
#nav-sources / #nav-tuning links, ship hidden) is there too. No nav
link is "current" on the viewer: a document is a detail view
reachable from chat or Sources, and the phase-13 back link (row 2)
carries the return affordance. The phase-19 single-row bar
(.doc-header-actions) is superseded by the two-row layout —
.doc-titlebar keeps #doc-back / #doc-title / #doc-meta, so
document.js needs no render change."""
text = _text(DOCUMENT_HTML)
chat = re.search(r'<a[^>]*href="/"[^>]*>Chat</a>', text)
assert chat, "the viewer bar carries the standard nav"
assert "is-active" not in chat.group(0), "no nav link is current on the viewer"
for link in ("nav-sources", "nav-tuning"):
tag = re.search(rf'<a[^>]*id="{link}"[^>]*>', text)
assert tag, f"the viewer bar must carry #{link}"
assert "hidden" in tag.group(0), f"#{link} must ship hidden (admin-only)"
assert "is-active" not in tag.group(0)
# Row 1 is the standard bar inside the two-row viewer header.
assert re.search(r'<header[^>]*class="doc-header"', text), "the header keeps .doc-header"
assert 'class="app-header"' in text, "row 1 reuses the .app-header bar"
assert 'doc-header-inner' not in text, "the old single-row wrapper is gone"
assert 'doc-header-actions' not in text, "the old actions wrapper is gone"
# Row 2: the .doc-titlebar keeps the back link + title + meta.
assert 'class="doc-titlebar"' in text, "row 2 must be the .doc-titlebar"
assert 'id="doc-back"' in text
assert 'id="doc-title"' in text
assert 'id="doc-meta"' in text
back = re.search(r'<a[^>]*id="doc-back"[^>]*href="/sources.html"', text)
assert back, "the back link keeps its /sources.html no-JS fallback"
def test_sources_and_viewer_carry_the_shared_controls() -> None:
@@ -197,14 +284,31 @@ def test_header_module_loads_before_the_page_script() -> None:
)
def test_login_page_carries_no_chat_controls() -> None:
"""Noted boundary (owner-confirmed): the login page is the auth page,
not an app page — no New Chat / Sign in / Sign out controls there;
header.js only toggles the Sources link."""
def test_login_page_carries_the_full_header() -> None:
"""Phase 34 task 03 (owner confirmation 2026-08-26): the noted
boundary is reversed by owner decision — the login page finally
carries the FULL shared header: the nav gains the #nav-tuning link
(same ship-hidden markup as the other pages), plus the Tuning
toggle, the admin-only Sync button, New chat, and the Sign in /
Sign out pair (ship hidden — initSharedHeader reveals exactly one
after whoami; the static ?next= fallback is the login page itself).
No nav link is "current" on the auth page."""
text = _text(LOGIN_HTML)
assert "new-chat-btn" not in text
assert "sign-in-link" not in text
assert "sign-out-btn" not in text
for marker in (
'id="nav-sources"',
'id="nav-tuning"',
'id="steering-toggle"',
'id="sync-btn"',
'id="new-chat-btn"',
'id="sign-in-link"',
'id="sign-out-btn"',
):
assert marker in text, f"login.html must carry {marker} (phase 34 full header)"
assert 'href="/login.html?next=/login.html"' in text, (
"the login page's Sign in returns to the login page (no-JS fallback)"
)
for tag in re.findall(r'<a[^>]*class="nav-link[^"]*"[^>]*>', text):
assert "is-active" not in tag, "no nav link is current on the login page"
# ---------- page-script adaptations ----------
@@ -249,40 +353,206 @@ def test_login_js_uses_the_shared_fetch_is_admin() -> None:
assert "window.location.replace(safeNext())" in js
def test_non_chat_pages_bind_new_chat_to_the_chat_page() -> None:
"""On sources, the viewer, and the tuning page, New Chat means "go to
the chat, fresh": the binding clears the phase-14 key
(clearChatStorage) and navigates to "/" — and each page runs
initSharedHeader() at boot on the shared cached whoami. Phase 23:
def test_new_chat_binding_is_single_and_module_owned() -> None:
"""Phase 34 task 02: header.js owns the SINGLE #new-chat-btn binding
(module import, like the sign-out binding): on the chat page
(#messages exists) it dispatches window "bor:new-chat" — app.js acts
through its own in-flight-turn guard + list reset; on every other
page it means "go to the chat, fresh" (clearChatStorage + navigate
to "/"). NO page script binds #new-chat-btn anymore, and each page
still runs initSharedHeader() on the shared cached whoami. Phase 23:
the import is relative (`./header.js`)."""
js = _text(HEADER_JS)
assert 'querySelector("#new-chat-btn")' in js
assert "newChatBtn.addEventListener" in js
assert 'querySelector("#messages")' in js, "the chat-page branch key"
assert 'new CustomEvent("bor:new-chat")' in js
assert "clearChatStorage();" in js
assert 'window.location.href = "/"' in js
for js_file in (SOURCES_JS, DOCUMENT_JS, TUNING_JS):
js = _text(js_file)
assert 'from "./header.js"' in js
assert "initSharedHeader()" in js
btn_idx = js.find("new-chat-btn")
clear_idx = js.find("clearChatStorage();")
nav_idx = js.find('window.location.href = "/"')
assert -1 < btn_idx < clear_idx < nav_idx, (
f"{js_file.name}: #new-chat-btn must clear storage then navigate to '/'"
page_js = _text(js_file)
assert 'from "./header.js"' in page_js
assert "initSharedHeader()" in page_js
assert "new-chat-btn" not in page_js, (
f"{js_file.name}: no #new-chat-btn binding (the module owns it)"
)
assert 'fetch("/api/whoami")' not in js, (
assert 'fetch("/api/whoami")' not in page_js, (
f"{js_file.name}: whoami goes through the shared cached promise"
)
# ---------- viewer-bar CSS ----------
def test_viewer_bar_css_pushes_actions_right_and_title_clips() -> None:
"""styles.css defines .doc-header-actions (margin-left:auto flex
cluster) and the title block keeps min-width: 0 so
#doc-title/#doc-meta clip instead of overflowing the --header-h bar."""
css = _text(STYLES_CSS)
block = re.search(r"\.doc-header-actions\s*\{([^}]*)\}", css)
assert block, "styles.css must define .doc-header-actions"
body = block.group(1)
assert "margin-left: auto" in body
assert "display: flex" in body
assert re.search(r"\.doc-title-block\s*\{[^}]*min-width:\s*0", css), (
"the title block must keep clipping while the pills fit"
app_js = _text(APP_JS)
assert 'window.addEventListener("bor:new-chat", startNewChat)' in app_js
assert "newChatBtn.addEventListener" not in app_js, (
"app.js acts off the module's event, not its own binding"
)
def test_init_shared_header_rewrites_sign_in_next_to_current_page() -> None:
"""Phase 34 task 02: initSharedHeader points #sign-in-link at
/login.html?next=<current pathname> (default "/") — the admin lands
back on the page they signed in from. The page markup keeps its own
static ?next= as the no-JS fallback."""
js = _text(HEADER_JS)
fn = js.find("function initSharedHeader")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
# raw pathname (always a query-safe "/…" string — never "//", and ?
# # / spaces stay percent-encoded inside it; login.js safeNext
# re-validates), same shape as the static markup fallbacks
assert '"/login.html?next=" + (window.location.pathname || "/")' in body
# ---------- phase 34 task 01: the steering controls move to the module ----------
# (task 02 — the sync machine + New chat + sign-in next — is pinned in
# test_sync_button.py / above)
def test_header_module_owns_the_steering_panel() -> None:
"""header.js owns the steering panel behavior (moved from app.js in
phase 34 task 01): the module-level null-safe element refs, the
newest-first textContent render (XSS contract), the labeled
per-note delete, the count badge, the announcer, and the toggle
binding that runs at module import (like the sign-out binding)."""
js = _text(HEADER_JS)
for selector in (
"#steering-toggle",
"#steering-count",
"#steering-panel",
"#steering-list",
"#steering-empty",
"#steering-announcer",
):
assert f'querySelector("{selector}")' in js, f"missing {selector} ref"
assert "function renderSteeringPanel" in js
assert "text.className = \"steering-note-text\"" in js
assert "text.textContent = n.note" in js, (
"XSS contract: the note renders via textContent, never innerHTML"
)
assert 'del.setAttribute("aria-label", `Delete tuning note: ${n.note}`)' in js
assert "async function deleteSteeringNote" in js
assert 'fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" })' in js
assert "steeringToggle.addEventListener" in js, "the toggle binding is module-owned"
assert 'setAttribute("aria-expanded"' in js
assert "refreshSteering()" in js # re-open refreshes the list
def test_header_module_exports_refresh_and_announce_steering() -> None:
"""refreshSteering() (fetch + render; non-2xx / unreachable API →
the empty list state) and announceSteering() (the polite live
region) are exported for the chat page's per-bubble Tune form.
"""
js = _text(HEADER_JS)
assert "export async function refreshSteering" in js
fn = js.find("function refreshSteering")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
assert 'fetch("/api/steering")' in body
assert "renderSteeringPanel(notes)" in body
assert "export function announceSteering" in js
ann = js.find("function announceSteering")
assert ann != -1
ann_body = js[ann : js.find("\n}", ann)]
assert "steeringAnnouncer.textContent = message" in ann_body
def test_init_shared_header_gates_the_steering_surface() -> None:
"""Inside initSharedHeader: admin → the list refreshes (count badge
right before the panel is ever opened; only when the page ships the
panel markup); anonymous → the toggle + panel are REMOVED from the
DOM (phase-16 'absent, not hidden') and /api/steering is never
fetched."""
js = _text(HEADER_JS)
fn = js.find("function initSharedHeader")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
assert "if (steeringPanel) refreshSteering();" in body
assert "steeringToggle?.remove();" in body
assert "steeringPanel?.remove();" in body
def test_app_js_no_longer_owns_the_steering_panel() -> None:
"""app.js keeps only the chat-specific per-bubble Tune button +
inline form: the panel refs + logic are gone (header.js owns them
now), and the form's success path awaits the module's
refreshSteering() (announcing through the module's
announceSteering()). The form's POST /api/steering + error handling
stay in app.js, untouched."""
js = _text(APP_JS)
for gone in (
"steeringToggle",
"steeringCount",
"steeringPanel",
"steeringList",
"steeringEmpty",
"steeringAnnouncer",
"loadSteering",
"renderSteeringPanel",
"deleteSteeringNote",
"setSteeringPanel",
'querySelector("#steering-toggle")',
):
assert gone not in js, f"{gone!r} must be gone from app.js (header.js owns it)"
# the chat-specific part survives and is wired to the shared module
assert "function appendTuneButton" in js
assert "function openTuneForm" in js
assert "TUNE_ICON" in js
assert 'from "./header.js"' in js
assert "refreshSteering" in js and "announceSteering" in js
assert "await refreshSteering()" in js
assert 'fetch("/api/steering", {' in js # the form's POST still lives here
# ---------- viewer two-row-header CSS (phase 34 task 04) ----------
def test_viewer_two_row_header_css() -> None:
"""Phase 34 task 04: the viewer header is two rows — row 1 reuses
the .app-header / .header-inner rules verbatim (the pinned
--header-h height still applies to row 1), row 2 is the
.doc-titlebar: a quiet --line border-top separator, a flex
.container row, its own content-sized height. The old single-row
.doc-header-actions / .doc-header-inner rules are gone, .doc-header
no longer pins a fixed height, and .doc-title-block keeps
min-width: 0 so #doc-title / #doc-meta ellipsize in the row."""
css = _text(STYLES_CSS)
block = re.search(r"\.doc-titlebar\s*\{([^}]*)\}", css)
assert block, "styles.css must define .doc-titlebar"
body = block.group(1)
assert "border-top: 1px solid var(--line)" in body, (
"the titlebar separates from row 1 with the quiet hairline"
)
assert "height: var(--header-h)" not in body, (
"the titlebar row is content-sized (title line + meta line)"
)
assert re.search(r"\.doc-titlebar\s*\.container\s*\{[^}]*display:\s*flex", css), (
"the titlebar row must be a flex row (back link + title block)"
)
assert re.search(r"\.doc-header-actions\s*\{", css) is None, (
"the old single-row actions cluster rule is gone"
)
assert re.search(r"\.doc-header-inner\s*\{", css) is None, (
"the old single-row wrapper rule is gone"
)
header_block = re.search(r"\.doc-header\s*\{([^}]*)\}", css)
assert header_block, "styles.css must still style the .doc-header header element"
assert "height: var(--header-h)" not in header_block.group(1), (
"the two-row header is content-sized — row 1 keeps the pinned height"
)
assert re.search(r"\.doc-title-block\s*\{[^}]*min-width:\s*0", css), (
"the title block must keep clipping while the row stays tidy"
)
def test_failed_sync_button_carries_the_error_look() -> None:
"""Phase 34 task 04: the #sync-btn now lives on every page, and the
non-Sources pages have no error banner — the failed state must be
visible on the button itself. header.js adds .is-error (with the
sanitized error in title / aria-label); styles.css must render it
with the phase-08 error pair (--err-ink on --err-bg ≈9.1:1,
--err-line border — never the amber deflection accent)."""
css = _text(STYLES_CSS)
block = re.search(r"\.sync-btn\.is-error\s*\{([^}]*)\}", css)
assert block, "styles.css must define the failed .sync-btn look"
body = block.group(1)
assert "var(--err-bg)" in body
assert "var(--err-ink)" in body
assert "var(--accent-" not in body, "the amber deflection accent is never on errors"
+170 -78
View File
@@ -3,10 +3,13 @@
The browser behavior is E2E-covered (tests/e2e/test_sync_button.py,
task 03); here we pin the source-level wiring — the anonymous-safe
ship-hidden button markup, the header.js admin reveal on the SAME
cached whoami (no extra fetch), the sources.js sync state machine
(2 s poll, 202 start / 409 adoption / 403 hide, terminal labels,
the aria-live result, the single-poll-loop guard, no client-side hard
timeout), the §7.4 never-stale CSS (spin + reduced-motion opt-out,
cached whoami (no extra fetch), the header.js sync state machine
(moved here from sources.js in phase 34 task 02: 2 s poll, 202 start
/ 409 adoption / 403 hide, terminal labels, the "bor:sync-status"
event with the status object as detail, the single-poll-loop guard, no
client-side hard timeout), the Sources page's event-driven
#sync-result line + #sync-error-banner, and the §7.4 never-stale CSS
(spin + reduced-motion opt-out,
disabled state, 44px floor, contrast pair) — so a silent regression is
caught without a browser.
"""
@@ -117,103 +120,114 @@ def test_sources_page_stays_cdn_free() -> None:
def test_header_reveals_sync_btn_on_the_admin_branch() -> None:
"""initSharedHeader reveals #sync-btn in the SAME admin branch as
#nav-sources (querySelector + hidden = !admin) — one cached whoami,
no extra whoami call; anonymous users never leave the hidden
default."""
#nav-sources (hidden = !admin) — one cached whoami, no extra
whoami call; anonymous users never leave the hidden default. The
ref is the module-level one — the state machine shares it."""
js = _text(HEADER_JS)
assert 'querySelector("#sync-btn")' in js, "module-level #sync-btn ref"
fn = js.find("function initSharedHeader")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
assert 'querySelector("#sync-btn")' in body, "#sync-btn must join the admin reveal"
assert "syncBtn.hidden = !admin" in body
assert "syncBtn.hidden = !admin" in body, "#sync-btn must join the admin reveal"
# The reveal must not introduce a second whoami call site.
assert js.count('fetch("/api/whoami")') == 1
# ---------- sources.js: the sync state machine ----------
# ---------- header.js: the sync state machine (moved here from
# ---------- sources.js in phase 34 task 02) ----------
def test_sources_js_calls_the_sync_api() -> None:
def test_header_js_owns_the_sync_button_elements() -> None:
"""The button refs are module-level and null-safe: #sync-btn,
#sync-label, the .sync-icon inside the button — a page without the
markup is a complete no-op, exactly like the rest of the module."""
js = _text(HEADER_JS)
assert 'querySelector("#sync-btn")' in js
assert 'querySelector("#sync-label")' in js
assert 'syncBtn.querySelector(".sync-icon")' in js
def test_header_js_calls_the_sync_api() -> None:
"""The click posts to POST /api/sync and the poll loop GETs
/api/sync/status — both through the same-origin API (A10)."""
js = _text(SOURCES_JS)
js = _text(HEADER_JS)
assert 'fetch("/api/sync", { method: "POST" })' in js
assert 'fetch("/api/sync/status")' in js
def test_sources_js_polls_every_2000ms() -> None:
def test_header_js_polls_every_2000ms() -> None:
"""The feedback loop is a 2000 ms poll of the status endpoint,
re-scheduled one tick at a time (setTimeout, not setInterval — an
in-flight fetch can never overlap the next tick)."""
js = _text(SOURCES_JS)
js = _text(HEADER_JS)
assert "SYNC_POLL_MS = 2000" in js
assert "setTimeout(tick, SYNC_POLL_MS)" in js
assert "setInterval" not in js
def test_sources_js_adopts_409_and_starts_on_202() -> None:
def test_header_js_adopts_409_and_starts_on_202() -> None:
"""202 (started) and 409 (a run started elsewhere — e.g. a second
tab) both enter the running state and start polling: the UI never
starts a second run, it adopts the in-flight one."""
js = _text(SOURCES_JS)
js = _text(HEADER_JS)
assert "r.status === 202 || r.status === 409" in js
idx = js.find("r.status === 202 || r.status === 409")
branch = js[idx : idx + 200]
assert "enterRunningState()" in branch
branch = js[idx : idx + 400]
assert "enterSyncRunningState()" in branch
assert "startSyncPolling()" in branch
def test_sources_js_hides_the_button_on_403() -> None:
"""A 403 anywhere (POST or poll) is treated as not-admin: the
button hides — defense in depth behind header.js's whoami reveal."""
js = _text(SOURCES_JS)
for occurrence in re.finditer(r"r\.status === 403", js):
window = js[occurrence.start() : occurrence.start() + 400]
assert "syncBtn.hidden = true" in window, "every 403 branch must hide the button"
def test_header_js_hides_the_button_on_403() -> None:
"""A 403 anywhere (POST, the status poll, the load re-attach) is
treated as not-admin: the button hides — defense in depth behind
the whoami reveal (the primary gate)."""
js = _text(HEADER_JS)
assert len(re.findall(r"r\.status === 403", js)) >= 3, (
"POST, the status poll, and the load re-attach must all handle 403"
)
assert js.count("syncBtn.hidden = true") >= 3, (
"every 403 branch must hide the button"
)
def test_sources_js_running_state_is_never_stale() -> None:
def test_header_js_running_state_is_never_stale() -> None:
"""Entering the running state disables the button, sets aria-busy,
spins the icon, and swaps the label to 'Syncing…' (the §7.4
feedback while the poll waits)."""
js = _text(SOURCES_JS)
fn = js.find("function enterRunningState")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
feedback while the poll waits) — and a fresh run starts clean: the
previous failure's title / aria-label / .is-error come off NOW,
not when the run settles."""
js = _text(HEADER_JS)
body = _body(js, "enterSyncRunningState")
assert "syncBtn.disabled = true" in body
assert 'syncBtn.setAttribute("aria-busy", "true")' in body
assert 'syncBtn.removeAttribute("title")' in body
assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body
assert "syncBtn.classList.remove(\"is-error\")" in body
assert "syncIcon.classList.add(\"is-spinning\")" in body
assert '"Syncing…"' in body
def test_sources_js_terminal_states() -> None:
def test_header_js_terminal_states() -> None:
"""Terminal rendering: success → enabled + 'Synced HH:MM' (local
time of finished_at) + the last-result counts ('added' always
announced, zero terms omitted — a no-op re-sync reads '0 added ·
1 unchanged', never an empty live region) + a live catalog refresh
(the KB just changed — never a stale table); failed → enabled +
retry-ready 'Sync sources' label + the role='alert' banner with
the error; the result is cleared on a failure."""
js = _text(SOURCES_JS)
time of finished_at); failed → enabled + retry-ready 'Sync sources'
label + the sanitized error in the button's title + aria-label +
the .is-error class (non-Sources pages: that is where the failure
is visible). The counts formatting (fmtSyncResult) lives here and
is EXPORTED for the Sources page ('added' always announced, zero
terms omitted — a no-op re-sync reads '0 added · 1 unchanged')."""
js = _text(HEADER_JS)
success = _body(js, "applySyncSuccess")
assert '"Synced"' in success and "fmtSyncTime(status.finished_at)" in success
assert "fmtSyncResult(status.detail)" in success
# A successful sync just changed the KB: the catalog re-fetches live
# (table / stats / empty state never sit stale under "Synced").
assert "loadDocs()" in success
failure = _body(js, "applySyncFailure")
assert 'settleSyncButton("Sync sources")' in failure # retry-ready
assert "showSyncError(status.error)" in failure
assert "syncBtn.title = error" in failure
assert 'syncBtn.setAttribute("aria-label", error)' in failure
assert "syncBtn.classList.add(\"is-error\")" in failure
assert "sanitizeSyncError(status.error)" in failure
result = _body(js, "fmtSyncResult")
# "added" is the always-announced headline term; "unchanged" covers
# the no-op case ("0 added · 1 unchanged"); updated/pruned are
# zero-omitted.
assert "added" in result and "unchanged" in result
assert " · " in result
assert "> 0" in result, "zero terms must be omitted"
@@ -222,26 +236,38 @@ def test_sources_js_terminal_states() -> None:
assert "getHours()" in time and "getMinutes()" in time, "local HH:MM of finished_at"
def test_sources_js_settles_the_button_on_terminal() -> None:
"""settleSyncButton re-enables the control, drops aria-busy, and
un-spins the icon — the button can never sit disabled after a run
reaches a terminal state (failed included: retry-ready)."""
js = _text(SOURCES_JS)
fn = js.find("function settleSyncButton")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
def test_header_js_failed_error_is_sanitized() -> None:
"""The button's title/aria-label error is sanitized for the
attributes: the server already masks credentials (sync.py
_sanitize_error); the module collapses whitespace to a single line
and caps the length, and a missing error still names a failure."""
js = _text(HEADER_JS)
body = _body(js, "sanitizeSyncError")
assert "replace(/\\s+/g, \" \")" in body, "single line for the attributes"
assert "200" in body, "long errors (chatty git stderr) are capped"
assert '"The sync failed."' in body
def test_header_js_settles_the_button_on_terminal() -> None:
"""settleSyncButton re-enables the control, drops aria-busy, the
title, and the failed affordances, and un-spins the icon — the
button can never sit disabled after a run reaches a terminal state
(failed included: retry-ready)."""
js = _text(HEADER_JS)
body = _body(js, "settleSyncButton")
assert "syncBtn.disabled = false" in body
assert 'syncBtn.removeAttribute("aria-busy")' in body
assert 'syncBtn.removeAttribute("title")' in body
assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body
assert "syncIcon.classList.remove(\"is-spinning\")" in body
def test_sources_js_never_starts_a_second_poll_loop() -> None:
def test_header_js_never_starts_a_second_poll_loop() -> None:
"""startSyncPolling is guarded by the module-level timer: a 409
adoption, a reload re-attach, or a stray call can never run two
poll loops at once (phase completion criterion)."""
js = _text(SOURCES_JS)
js = _text(HEADER_JS)
fn = js.find("function startSyncPolling")
assert fn != -1
head = js[fn : js.find("const tick", fn)]
assert re.search(r"if\s*\(\s*syncPollTimer\s*!==\s*null\s*\)\s*return", head), (
"the single-loop guard must be the first statement"
@@ -249,37 +275,103 @@ def test_sources_js_never_starts_a_second_poll_loop() -> None:
assert "clearTimeout(syncPollTimer)" in _body(js, "stopSyncPolling")
def test_sources_js_has_no_client_side_hard_timeout() -> None:
"""Phase locked decision: a sync can legitimately run for minutes,
so there is NO client-side hard timeout — the 2 s poll is the
feedback loop and the server state is authoritative (the 120 s
LLM-turn guard must not leak into the sync path)."""
js = _text(SOURCES_JS)
def test_header_js_has_no_client_side_hard_timeout() -> None:
"""Phase locked decision: a sync can legitimately run for minutes
(and outlive the page), so there is NO client-side hard timeout —
the 2 s poll is the feedback loop and the server state is
authoritative (the 120 s LLM-turn guard must not leak into the
sync path)."""
js = _text(HEADER_JS)
assert "TURN_TIMEOUT" not in js
assert "120" not in js[js.find("Phase 32") :], (
"no turn-timeout constant in the sync section"
)
sync_start = js.find("sync sources (phase 32")
assert sync_start != -1, "the sync section marker comment"
assert "120" not in js[sync_start:]
def test_sources_js_reattaches_on_load() -> None:
"""initSyncButton (run from the IIFE on the admin path, after
initSharedHeader) fetches the status once and re-enters the running
state on 'running' (reload mid-sync) or renders the last result on
a terminal state; the click binding wires startSync to the button."""
js = _text(SOURCES_JS)
def test_header_js_reattaches_on_load_admin_only() -> None:
"""initSyncButton (run at module import, button pages only) awaits
the SAME cached whoami — ADMIN ONLY (non-admins never poll, the
status endpoint is admin-only): a running run re-enters the
running state (reload mid-sync), a terminal run renders its last
result, idle settles retry-ready; the click binding wires
startSync to the button."""
js = _text(HEADER_JS)
fn = js.find("function initSyncButton")
assert fn != -1
body = js[fn : js.find("\n}\n", fn)]
assert "await fetchIsAdmin()" in body, "admin-only boot (no extra fetch)"
assert 'fetch("/api/sync/status")' in body
assert 'status.state === "running"' in body
assert 'status.state === "success"' in body
assert 'status.state === "failed"' in body
assert "syncBtn.addEventListener(\"click\", startSync)" in js
# The IIFE runs it on the admin path only (after the whoami gate).
iife = js[js.find("(async () => {") :]
admin_idx = iife.find("await isAdmin()")
init_idx = iife.find("initSyncButton();")
assert -1 < admin_idx < init_idx, "re-attach must run only for the admin"
tail = js[js.rfind("if (syncBtn)") :]
assert "initSyncButton()" in tail, "boot re-attach runs at module import"
def test_header_js_emits_bor_sync_status_on_state_changes() -> None:
"""Every state change dispatches window 'bor:sync-status' with the
status object as detail — the channel the Sources page's
banner/result line subscribe to. The click path emits the
synthetic running frame IMMEDIATELY (no 2 s poll lag — the exact
old enterRunningState clear behavior, now event-driven)."""
js = _text(HEADER_JS)
assert (
'window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }))'
in js
)
for fn in ("applySyncSuccess", "applySyncFailure", "applySyncIdle"):
assert "emitSyncStatus" in _body(js, fn), f"{fn} must emit its frame"
# the running frame: synthetic on click, the real object on boot
assert 'emitSyncStatus({ state: "running" })' in js
assert "emitSyncStatus(status)" in _body(js, "initSyncButton")
# ---------- sources.js: the event-driven result line + banner ----------
def test_sources_js_renders_off_the_sync_status_event() -> None:
"""The Sources page keeps ONLY its page-specific rendering:
#sync-result (aria-live) + #sync-error-banner (role=alert), driven
by the module's 'bor:sync-status' event (detail = the status
object): running → clear + hide; success → the counts (the
imported fmtSyncResult) + a live catalog refresh; failed → the
banner with the error; idle → hide + clear."""
js = _text(SOURCES_JS)
assert 'window.addEventListener("bor:sync-status"' in js
assert 'status.state === "running"' in js
assert 'status.state === "success"' in js
assert 'status.state === "failed"' in js
assert "fmtSyncResult(status.detail)" in js
assert "loadDocs()" in js, "the catalog re-fetches live on a successful sync"
assert "showSyncError(status.error)" in js
assert "syncResult.textContent" in js
def test_sources_js_no_longer_owns_the_sync_machine() -> None:
"""The state machine is GONE from sources.js (header.js owns it):
no button refs, no POST, no status poll, no button-state helpers,
no click binding, no load re-attach."""
js = _text(SOURCES_JS)
for gone in (
"SYNC_POLL_MS",
"syncPollTimer",
"startSyncPolling",
"stopSyncPolling",
"enterRunningState",
"settleSyncButton",
"applySyncSuccess",
"applySyncFailure",
"applySyncIdle",
"initSyncButton",
"startSync",
'fetch("/api/sync", { method: "POST" })',
'fetch("/api/sync/status")',
'querySelector("#sync-btn")',
'querySelector("#sync-label")',
'querySelector(".sync-icon")',
):
assert gone not in js, f"{gone!r} must be gone from sources.js (header.js owns it)"
# ---------- styles.css: the §7.4 states ----------