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 * collapsed Thinking block is restored with it; records without it (old
* sessions) restore exactly as before, so no version bump. A10 is * sessions) restore exactly as before, so no version bump. A10 is
* untouched: the API stays stateless, nothing is stored server-side. * untouched: the API stays stateless, nothing is stored server-side.
* "New chat" (#new-chat-btn) clears the key + the list back to the empty * "New chat" (#new-chat-btn — bound by the shared header module,
* state. * 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 * Steering notes (phase 15) let the owner tune how Brain answers: a
* "Tune" button under every completed brain bubble (deflected included) * "Tune" button under every completed brain bubble (deflected included)
* opens an inline form → POST /api/steering → the note is stored in * opens an inline form → POST /api/steering → the note is stored in
* Postgres and injected into the system prompt of every subsequent turn * Postgres and injected into the system prompt of every subsequent turn
* (the <tuning> section). Notes are listed newest-first in the header * (the <tuning> section). The header "Tuning" panel (#steering-panel)
* "Tuning" panel (#steering-panel), where each can be deleted. Note text * — toggle, list, per-note delete, count badge, announcer — is owned by
* is always rendered with textContent (XSS-safe), save/delete are * the shared header module (assets/header.js, phase 34); this file keeps
* announced through a polite live region (#steering-announcer), and the * only the chat-specific per-bubble Tune button + inline form, whose
* panel + count badge update on every change. * 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 * 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 * while the user is pinned to the bottom. NEAR_BOTTOM_PX (200px) covers
@@ -72,7 +74,12 @@
* All DOM ids match frontend/index.html. * 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 import { openDocumentModal } from "./document-modal.js"; // phase 26: chips open the same-page modal
const messagesEl = document.querySelector("#messages"); const messagesEl = document.querySelector("#messages");
@@ -164,30 +171,24 @@ export function documentUrl(source, path, back = "/") {
return url; 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 * The owner's tuning notes steer every future answer: they live in
* Postgres (stateless API, A10) and the chat turn reads them into the * Postgres (stateless API, A10) and the chat turn reads them into the
* system prompt. UI contract: Tune button → inline form → save → * system prompt. UI contract: Tune button → inline form → save →
* confirmation (or inline error, form kept); the header panel lists the * confirmation (or inline error, form kept). The header panel — toggle,
* notes (newest first) with per-note delete. * 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 = 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>'; '<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 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 /* "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 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. 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."; saved.textContent = "Saved — future answers will follow this.";
form.replaceWith(saved); form.replaceWith(saved);
announceSteering("Tuning note saved. Future answers will follow it."); announceSteering("Tuning note saved. Future answers will follow it.");
await loadSteering(); // panel + count badge update await refreshSteering(); // header.js: panel + count badge update
} catch { } catch {
status.textContent = "Could not save the note — is the app reachable?"; status.textContent = "Could not save the note — is the app reachable?";
status.hidden = false; status.hidden = false;
@@ -294,76 +295,6 @@ function openTuneForm(wrap, toggleBtn) {
form.querySelector("textarea").focus(); 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) ---------- /* ---------- avatar glyphs (phase 08: emoji-free chrome) ----------
* Inline SVG as string constants so the message renderer and the typing * Inline SVG as string constants so the message renderer and the typing
* indicator share exactly the same marks. currentColor lets the CSS theme * 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) — * reload) all moved to the shared header module (assets/header.js) —
* initSharedHeader() does the header toggling on every page, and * initSharedHeader() does the header toggling on every page, and
* fetchIsAdmin() is the single cached whoami, so this page still makes * fetchIsAdmin() is the single cached whoami, so this page still makes
* exactly one request per load. applyAuthState keeps only the * exactly one request per load.
* chat-page-specific work (removing the tuning surface for anonymous *
* visitors) — idempotent alongside the header module's own link/button * Phase 34: removing the tuning surface for anonymous visitors (toggle
* toggling. * + 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 signInLink = document.querySelector("#sign-in-link");
const signOutBtn = document.querySelector("#sign-out-btn"); const signOutBtn = document.querySelector("#sign-out-btn");
@@ -831,13 +765,8 @@ let isAdmin = false;
function applyAuthState() { function applyAuthState() {
if (signInLink) signInLink.hidden = isAdmin; if (signInLink) signInLink.hidden = isAdmin;
if (signOutBtn) signOutBtn.hidden = !isAdmin; if (signOutBtn) signOutBtn.hidden = !isAdmin;
if (!isAdmin && steeringToggle) {
steeringToggle.remove();
steeringPanel?.remove();
}
} }
const newChatBtn = document.querySelector("#new-chat-btn");
function startNewChat() { function startNewChat() {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return; if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
conversation = []; conversation = [];
@@ -852,7 +781,11 @@ function startNewChat() {
input.focus(); input.focus();
sendStatus.textContent = "New chat started — previous conversation cleared."; 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) { function showErrorBanner(detail) {
banner.hidden = false; banner.hidden = false;
@@ -1052,13 +985,15 @@ window.addEventListener("pagehide", () => {
Phase 14: the conversation then comes back exactly as left. Phase 19: Phase 14: the conversation then comes back exactly as left. Phase 19:
the shared header module runs the whoami (cached — exactly one the shared header module runs the whoami (cached — exactly one
request per page load) and toggles the Sign in/out pair + the Sources 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 () => { (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 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(); restoreConversation();
loadSuggestions(); loadSuggestions();
loadHealth(); 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 * Phase 19: the viewer joins the shared header (assets/header.js) — the
* whoami fetch is the module's cached promise (one request per page, * whoami fetch is the module's cached promise (one request per page,
* shared with initSharedHeader's toggling), and the bar gains the New * shared with initSharedHeader's toggling). Phase 34 task 02: the New
* chat button: on a non-chat page "new chat" means going to the chat, * chat binding is module-owned (assets/header.js, the SINGLE one) — on
* fresh (clear the phase-14 conversation key, then navigate to "/"). * 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 * Phase 26 (import safety): the modal module imports renderDocument
* from THIS file on the chat/sources pages, so everything * from THIS file on the chat/sources pages, so everything
@@ -41,7 +42,7 @@
* fetch, no New Chat binding. * fetch, no New Chat binding.
*/ */
import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "./header.js"; import { fetchIsAdmin, initSharedHeader } from "./header.js";
function fmtDate(iso) { function fmtDate(iso) {
try { try {
@@ -71,6 +72,9 @@ function metaBadge(cls, text) {
* document-derived string is a text node. */ * document-derived string is a text node. */
export function renderDocument(doc, { titleEl, metaEl, contentEl }) { export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
titleEl.textContent = doc.title; 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"); const pathCode = document.createElement("code");
pathCode.className = "doc-path"; pathCode.className = "doc-path";
@@ -149,16 +153,19 @@ if (document.querySelector("#doc-title")) {
function showNotFound() { function showNotFound() {
titleEl.textContent = "Document not found"; titleEl.textContent = "Document not found";
titleEl.removeAttribute("title"); // no stale full-title tooltip
document.title = "Document not found · Brain of Reese"; document.title = "Document not found · Brain of Reese";
metaEl.replaceChildren(); metaEl.replaceChildren();
contentEl.replaceChildren(); contentEl.replaceChildren();
notFoundEl.hidden = false; notFoundEl.hidden = false;
} }
/* Phase 19: the shared header controls (Sign in / Sign out — exactly /* Phase 19 (phase 34 task 03, owner confirmation 2026-08-26): the
* one visible) are toggled here; the viewer has no nav, so there is * viewer now carries the SAME standard bar as every other page —
* no #nav-sources for the module to touch. Independent of the doc * nav incl. the admin-only links, Tuning toggle, Sync, New chat, the
* fetch (its own IIFE — load() below never waits on it). * 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 * (fetchIsAdmin is imported for parity with the other header
* consumers — the module's cached promise is the single whoami per * consumers — the module's cached promise is the single whoami per
* page either way.) */ * page either way.) */
@@ -166,16 +173,9 @@ if (document.querySelector("#doc-title")) {
await initSharedHeader(); await initSharedHeader();
})(); })();
/* Phase 19: New chat on a non-chat page means "go to the chat, /* The New chat binding is module-owned (assets/header.js, phase 34
* fresh": clear the phase-14 conversation key, then land on the chat * task 02 — the SINGLE binding): on this non-chat page it clears the
* page — its empty state, since the conversation is gone from storage. */ * phase-14 conversation key and navigates to the chat's empty state. */
const newChatBtn = document.querySelector("#new-chat-btn");
if (newChatBtn) {
newChatBtn.addEventListener("click", () => {
clearChatStorage();
window.location.href = "/";
});
}
async function load() { async function load() {
try { try {
+503 -20
View File
@@ -6,28 +6,60 @@
* *
* • the Sign in / Sign out auth pair (phase 16, exactly one visible — * • the Sign in / Sign out auth pair (phase 16, exactly one visible —
* decided by /api/whoami at load); * decided by /api/whoami at load);
* • the admin-only nav links — "Sources" (#nav-sources) and "Tuning" * • the admin-only nav links — "Sources" (#nav-sources, phase 19)
* (#nav-tuning, on the Global Tuning page, phase 27) — phase 19 UX * and "Tuning" (#nav-tuning, phase 29) — phase 19 UX revision
* revision (owner permission 2026-08-23): hidden for anonymous on * (owner permission 2026-08-23): hidden for anonymous on EVERY
* every page that has a nav (chat, sources, tuning, login), * page, revealed for admin. Phase 34 task 03 (owner confirmation
* revealed for admin. The links SHIP hidden in the HTML * 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" * (anonymous-safe default — the phase-16 "absent, not hidden"
* spirit), so no anonymous user ever sees one for a frame; and the * spirit), so no anonymous user ever sees one for a frame; and
* Sources page's "Sync sources" button (#sync-btn, phase 32) — * the "Sync sources" button (#sync-btn, phase 32 — every page
* the same ship-hidden / reveal-for-admin contract on the SAME * from phase 34 task 03) — the same ship-hidden / reveal-for-admin
* cached whoami (one fetch, no extra request); * contract on the SAME cached whoami (one fetch, no extra request);
* • the sign-out click binding (POST /api/logout → reload) — moved * • the sign-out click binding (POST /api/logout → reload) — moved
* here from app.js so there is exactly one implementation; * 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 * • clearChatStorage() — the phase-14 conversation key, for the
* New Chat buttons on the NON-CHAT pages (sources / document * New chat action on the NON-CHAT pages (sources / document viewer /
* viewer / tuning): a new chat means going to the chat, fresh. * tuning / login): a new chat means going to the chat, fresh.
* *
* Every page loads this module (type="module", before its page script) * Every page loads this module (type="module", before its page script)
* and its page script calls initSharedHeader() once at boot. init… * and its page script calls initSharedHeader() once at boot. init…
* toggles ONLY the controls that exist on the page — a missing element * 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 * is a no-op. Phase 34 task 03 ships the SAME full header block on all
* gaining chat controls (no #new-chat-btn / #sign-in-link / * five pages (the login page included), so every control resolves on
* #sign-out-btn in its markup → none appear). * every page; a page that lacks one simply skips it.
* *
* whoami is fetched at most ONCE per page load: the promise is cached in * 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 * 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). */ cached promise makes both awaits the same single request). */
export async function initSharedHeader() { export async function initSharedHeader() {
const admin = await fetchIsAdmin(); 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"); 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"); const signOut = document.querySelector("#sign-out-btn");
if (signOut) signOut.hidden = !admin; if (signOut) signOut.hidden = !admin;
const navSources = document.querySelector("#nav-sources"); const navSources = document.querySelector("#nav-sources");
if (navSources) navSources.hidden = !admin; if (navSources) navSources.hidden = !admin;
// Phase 27: the Global Tuning page's own nav link — admin-only, the // Phase 29: the Global Tuning nav link (every page from phase 34
// same ship-hidden / reveal-for-admin contract as the Sources link. // task 03) — admin-only, the same ship-hidden / reveal-for-admin
// contract as the Sources link.
const navTuning = document.querySelector("#nav-tuning"); const navTuning = document.querySelector("#nav-tuning");
if (navTuning) navTuning.hidden = !admin; if (navTuning) navTuning.hidden = !admin;
// Phase 32: the Sources page's "Sync sources" button — admin-only, // Phase 32: the "Sync sources" button — admin-only, revealed on this
// revealed on this same cached whoami (anonymous users never see it). // same cached whoami (anonymous users never see it).
const syncBtn = document.querySelector("#sync-btn");
if (syncBtn) syncBtn.hidden = !admin; 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; return admin;
} }
@@ -106,3 +160,432 @@ if (signOutBtn) {
window.location.reload(); 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 → * role=alert error region. On load, /api/whoami already says admin →
* straight to `next`, no form. * straight to `next`, no form.
* *
* Phase 19: the whoami check runs on the shared header module's cached * Phase 19 (phase 34 task 03, owner confirmation 2026-08-26): the
* promise (assets/header.js) — one request per page, and the module's * whoami check runs on the shared header module's cached promise
* initSharedHeader() toggles the (admin-only) Sources nav link. The * (assets/header.js) — one request per page, and the module's
* login page carries no chat controls, so the module's missing-element * initSharedHeader() settles the login page's FULL shared header —
* no-op keeps this page control-free. * 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. * No CDN, no state in this file: the signed cookie is the whole session.
* All DOM ids match frontend/login.html. * 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. /* The bar settles in BOTH branches (phase 34 task 05 — the login page
* (For anonymous visitors, initSharedHeader toggles the Sources nav * carries the FULL shared header, so a signed-in admin who lands here
* link — the only shared control this page carries; for the signed-in * gets the settled admin bar for the frame before the redirect, not
* case the redirect above makes the toggle moot.) */ * 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 () => { (async () => {
if (await alreadySignedIn()) { const admin = await alreadySignedIn();
await initSharedHeader();
if (admin) {
window.location.replace(safeNext()); window.location.replace(safeNext());
return; return;
} }
await initSharedHeader(); // phase 19: Sources link toggle (no chat controls here)
passwordInput.focus(); passwordInput.focus();
})(); })();
+44 -218
View File
@@ -7,9 +7,15 @@
* *
* Phase 19: the page joins the shared header (assets/header.js) — the * 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 * 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 * page, shared with the header toggling).
* chat button: on a non-chat page "new chat" means going to the chat, *
* fresh (clear the phase-14 conversation key, then navigate to "/"). * 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 * Phase 26: the table's path links open the document in the
* almost-fullscreen modal overlay (assets/document-modal.js) on the * almost-fullscreen modal overlay (assets/document-modal.js) on the
@@ -21,7 +27,7 @@
* tag; esbuild inlines it into the page bundle). * 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 import { openDocumentModal } from "./document-modal.js"; // phase 26: row links open the same-page modal
const tbody = document.querySelector("#docs-tbody"); const tbody = document.querySelector("#docs-tbody");
@@ -43,17 +49,6 @@ function isAdmin() {
return fetchIsAdmin(); 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) { function fmtDate(iso) {
try { try {
return new Date(iso).toLocaleString(); return new Date(iso).toLocaleString();
@@ -146,46 +141,33 @@ function showEmpty() {
if (tableWrap) tableWrap.hidden = true; if (tableWrap) tableWrap.hidden = true;
} }
/* ---------- Phase 32: the admin "Sync sources" button (§7.4) ---------- /* ---------- Phase 32 (state machine module-owned from phase 34
* The "never stale" lifecycle for a long background job: * task 02): the #sync-result line + #sync-error-banner ----------
* *
* idle → click → POST /api/sync * The #sync-btn state machine itself (click → POST /api/sync, the 2 s
* 202 → "Syncing…" (disabled, aria-busy, spinning icon) + a * GET /api/sync/status poll, the running / success / failed button
* 2 s poll of GET /api/sync/status — the feedback loop; * states, the admin-only load re-attach) lives in the shared header
* 409 adopts the in-flight run the same way (one poll * module (assets/header.js) — so the SAME button markup on ANY page
* loop at a time, never two); * behaves identically. This page keeps only the page-specific
* success → "Synced HH:MM" + last-result counts in #sync-result * rendering: the aria-live last-result line and the role="alert" error
* (aria-live — announced to screen readers) + the * banner, driven by the module's "bor:sync-status" event (detail = the
* catalog re-fetches live (never a stale table); * GET /api/sync/status object):
* failed → "Sync sources" (retry-ready) + the role="alert"
* banner naming the error.
* *
* NO client-side hard timeout (phase locked decision): a sync can * running → clear the result line, hide the banner (a new run starts
* legitimately run for minutes (clone + embed), so the 2 s poll is the * clean — the module emits the frame immediately on
* feedback loop and the server state is authoritative — the button is * click/boot, no 2 s poll lag);
* disabled until the run reaches a terminal state, so it can never sit * success → the last-result counts in #sync-result (fmtSyncResult —
* stale OR stuck. On load (admin only) the page re-attaches: a running * "added" always shown, zero terms omitted) + the catalog
* run re-enters the running state (reload mid-sync), a terminal run * re-fetches live (the KB just changed — never a stale
* renders its last result. A 403 anywhere hides the button (defense in * table) + the banner hidden;
* depth — header.js's whoami reveal is the primary gate). * 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 syncResult = document.querySelector("#sync-result");
const syncErrorBanner = document.querySelector("#sync-error-banner"); const syncErrorBanner = document.querySelector("#sync-error-banner");
const syncErrorText = document.querySelector("#sync-error-text"); 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) { function showSyncError(detail) {
if (syncErrorText) syncErrorText.textContent = detail || "The sync failed."; if (syncErrorText) syncErrorText.textContent = detail || "The sync failed.";
if (syncErrorBanner) syncErrorBanner.hidden = false; if (syncErrorBanner) syncErrorBanner.hidden = false;
@@ -196,185 +178,28 @@ function hideSyncError() {
if (syncErrorBanner) syncErrorBanner.hidden = true; if (syncErrorBanner) syncErrorBanner.hidden = true;
} }
/* The local HH:MM of finished_at — 24-hour, locale-independent, so the window.addEventListener("bor:sync-status", (e) => {
* "Synced 14:32" last-result label is deterministic. */ const status = e.detail || {};
function fmtSyncTime(iso) { if (status.state === "running") {
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…";
if (syncResult) syncResult.textContent = ""; if (syncResult) syncResult.textContent = "";
hideSyncError(); hideSyncError();
} } else if (status.state === "success") {
/* 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");
if (syncResult) syncResult.textContent = fmtSyncResult(status.detail); if (syncResult) syncResult.textContent = fmtSyncResult(status.detail);
hideSyncError(); hideSyncError();
// The KB just changed — refresh the catalog live so the table, stats, // The KB just changed — refresh the catalog live so the table,
// and empty state never sit stale under the "Synced" label (the sync is // stats, and empty state never sit stale under the "Synced" label
// the page's own action; a reload should not be needed to see it). // (the sync is the page's own action; a reload should not be
// needed to see it).
loadDocs(); loadDocs();
} } else if (status.state === "failed") {
function applySyncFailure(status) {
settleSyncButton("Sync sources"); // retry-ready
if (syncResult) syncResult.textContent = ""; if (syncResult) syncResult.textContent = "";
showSyncError(status.error); showSyncError(status.error);
} } else {
// idle
/* 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");
if (syncResult) syncResult.textContent = ""; if (syncResult) syncResult.textContent = "";
hideSyncError(); 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 () => { (async () => {
await initSharedHeader(); // phase 19: Sign in/out + Sources link in the shared bar 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; if (gateEl) gateEl.hidden = true;
loadDocs(); 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; flex-shrink: 0;
} }
/* 2px brand→cyan gradient hairline under the sticky header (phase 08; /* 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, .app-header::after,
.doc-header::after { .doc-header::after {
content: ""; content: "";
@@ -235,12 +238,23 @@ html::after {
font-size: 1.125rem; font-size: 1.125rem;
color: var(--ink); color: var(--ink);
text-decoration: none; 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 { .brand-text {
font-family: var(--mono); font-family: var(--mono);
font-size: 0.95rem; font-size: 0.95rem;
letter-spacing: 0.08em; 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-mark { width: 22px; height: 22px; flex: 0 0 auto; display: block; }
.brand-text strong { color: var(--brand-ink); font-weight: 700; } .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:hover { background: var(--brand-soft); color: var(--brand-ink); }
.sync-btn:disabled { opacity: 0.6; cursor: wait; } .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; } .sync-icon { width: 16px; height: 16px; display: block; flex: 0 0 auto; }
/* Running state: the refresh icon spins (reuses the shared spin /* Running state: the refresh icon spins (reuses the shared spin
keyframes) — the visible half of "Syncing…" while the 2 s poll waits. */ 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: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-delete:disabled { opacity: 0.5; cursor: wait; }
.steering-empty { margin: 0.65rem 0 0; color: var(--ink-soft); font-size: 0.88rem; } .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) ---------- */ /* ---------- Global tuning page (phase 27) ---------- */
/* /tuning.html: create / edit / delete steering notes without a chat /* /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:hover { background: var(--bg); }
.docs-table tbody tr:last-child td { border-bottom: 0; } .docs-table tbody tr:last-child td { border-bottom: 0; }
/* ---------- Document viewer (phase 10) ---------- */ /* ---------- Document viewer (phase 10; two-row header since phase 34) ---------- */
/* Phase 12: the same fixed-height bar as .app-header (--header-h, 64px / /* Phase 34 (owner confirmation 2026-08-26): the viewer header is TWO
58px mobile) — the header must never change size between chat, rows in one sticky <header> — row 1 reuses the standard .app-header /
sources, and the document viewer (owner report 2026-08-22). */ .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 { .doc-header {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 20; z-index: 20;
background: var(--surface); background: var(--surface);
height: var(--header-h); /* Phase 34: no fixed height — the header sizes to row 1 (--header-h)
/* Phase 12: same guard as .app-header — the bar never shrinks. */ + the .doc-titlebar; the phase-12 shrink guard stays. */
flex-shrink: 0; flex-shrink: 0;
} }
.doc-header-inner { /* Row 1's own gradient hairline would land BETWEEN the rows; the
height: 100%; separator there is the quiet --line border-top on the titlebar, and
display: flex; the signature hairline belongs at the bottom edge of the whole
align-items: center; header (the .doc-header::after in the shared rule above). */
gap: 0.9rem; .doc-header > .app-header::after { content: none; }
} /* Back link: pill with an SVG arrow + "Sources" (>=44px touch target;
/* Back link: pill with an SVG arrow + "Sources" (>=44px touch target). */ :focus-visible via the global 3px outline rule, phase 08). */
.doc-back { .doc-back {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1276,31 +1316,37 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
} }
.doc-back:hover { background: #2a345f; } .doc-back:hover { background: #2a345f; }
.doc-back svg { width: 16px; height: 16px; display: block; } .doc-back svg { width: 16px; height: 16px; display: block; }
.doc-title-block { min-width: 0; } /* Row 2: the titlebar — a .container-width row with the back link +
/* Phase 19: the shared header controls reach the viewer bar (New chat the title block, its own content-sized height (title line + meta
+ Sign in / Sign out) — margin-left:auto pushes them to the right; line), the same surface as row 1, separated from it by the quiet
the title block keeps clipping (min-width: 0 above) so the two pills hairline (var(--line) — the existing 1px border color). */
fit while the bar still measures exactly --header-h. The reused .doc-titlebar {
.new-chat-btn / .auth-link classes already carry the ≤640px icon-only border-top: 1px solid var(--line);
rules, so at 360px the bar is back pill + clipping title + two icon padding-block: 0.6rem;
pills (no overflow — test_responsive_polish pins scrollWidth). */
.doc-header-actions {
margin-left: auto;
display: flex;
gap: 0.5rem;
align-items: center;
} }
.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 { #doc-title {
margin: 0; margin: 0;
font-size: 1.3rem; font-size: 1.3rem;
line-height: 1.3; 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; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* Meta row: source badge · format badge · mono path · indexed · chunks. /* Meta row: source badge · format badge · mono path · indexed · chunks.
Phase 12: it may clip, but it must NEVER wrap — a wrapped meta row Phase 12/34: it may clip, but it must NEVER wrap — the meta stays on
would grow the header past the shared --header-h bar. */ one line in the titlebar row (the row is content-sized, so a wrap
would grow it). */
.doc-meta { .doc-meta {
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
@@ -1606,6 +1652,20 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
font-size: 0.85rem; 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) ---------- */ /* ---------- Responsive (mobile-first adjustments) ---------- */
@media (max-width: 640px) { @media (max-width: 640px) {
:root { --header-h: 58px; } :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 the nav pills + gaps instead: tighter pill padding/font and a
tighter inner gap. The 44px touch floor is held by min-height, the 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 58px bar height is pinned by --header-h, and the bar now fits both
auth states at 375px (and 360px) without horizontal overflow. */ auth states at 375px (and 360px) without horizontal overflow.
.header-inner { gap: 0.5rem; } Phase 34 task 04 (visual pass): the full admin bar — nav [Chat,
.brand { min-width: 0; } 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 { .brand-text {
font-size: 0.8rem; font-size: 0.8rem;
letter-spacing: 0.04em; letter-spacing: 0.04em;
@@ -1630,20 +1698,20 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.nav-link { padding: 0.4rem 0.4rem; font-size: 0.84rem; } .nav-link { padding: 0.35rem 0.3rem; font-size: 0.78rem; }
.app-nav { gap: 0.15rem; } .app-nav { gap: 0.1rem; }
.new-chat-btn { padding: 0.4rem 0.55rem; } .new-chat-btn { padding: 0.4rem 0.35rem; }
.new-chat-label { display: none; } .new-chat-label { display: none; }
.new-chat-btn svg { display: block; } .new-chat-btn svg { display: block; }
/* Phase 16: the auth pill goes icon-only like New chat — brand text /* Phase 16: the auth pill goes icon-only like New chat — brand text
ellipsizes as the designated squeeze target, no bar overflow. */ 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-label { display: none; }
.auth-link svg { display: block; } .auth-link svg { display: block; }
/* Phase 32: the sync pill goes icon-only like the other pills (the /* Phase 32: the sync pill goes icon-only like the other pills (the
aria-label keeps the accessible name); the spinning icon is the aria-label keeps the accessible name); the spinning icon is the
visible running state on a touch screen. */ 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; } .sync-label { display: none; }
/* The last-result counts stay ANNOUNCED (aria-live is untouched) but /* The last-result counts stay ANNOUNCED (aria-live is untouched) but
go visually hidden — the 58px bar has no room for the text; the 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; white-space: nowrap;
border: 0; 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 /* Visually hidden, NOT display:none — the accessible name keeps the
word "Tuning" next to the count badge. */ word "Tuning" next to the count badge. */
.steering-label { .steering-label {
@@ -1670,6 +1738,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
border: 0; border: 0;
} }
.steering-note { padding: 0.3rem 0.3rem 0.3rem 0.7rem; } .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; } .tune-btn { min-height: 44px; }
/* Phase 27: the tuning page squeezes like the other cards — the row /* Phase 27: the tuning page squeezes like the other cards — the row
padding tightens; the icon+label actions keep their 44px floor and 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; .suggestions { flex-wrap: nowrap; overflow-x: auto; justify-content: flex-start;
padding-bottom: 0.4rem; -webkit-overflow-scrolling: touch; scrollbar-width: thin; } padding-bottom: 0.4rem; -webkit-overflow-scrolling: touch; scrollbar-width: thin; }
.suggestion-chip { flex: 0 0 auto; } .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-title { font-size: 1.1rem; }
.doc-path { max-width: 16rem; } .doc-path { max-width: 16rem; }
.doc-md { padding: 1.1rem 1rem; } .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 * the admin-only Sources link, and this page's own admin-only
* "Tuning" nav link (#nav-tuning), all decided by the module's * "Tuning" nav link (#nav-tuning), all decided by the module's
* cached whoami promise (exactly one /api/whoami request per * cached whoami promise (exactly one /api/whoami request per
* page); plus the non-chat New chat binding — "new chat" means * page). Phase 34 task 02: the New chat binding is module-owned
* going to the chat, fresh (clear the phase-14 conversation key, * (assets/header.js, the SINGLE one) — on a non-chat page "new
* then navigate to "/"), the same contract as sources.js / * chat" means going to the chat, fresh (the module clears the
* document.js. * phase-14 conversation key and navigates to "/").
* *
* Anonymous-safe (task 03): the header already hides the "Tuning" nav * Anonymous-safe (task 03): the header already hides the "Tuning" nav
* link for anonymous visitors; a DIRECT anonymous URL still gets a safe * 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). * 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) ---------- */ /* ---------- page elements (tuning.html, task 02) ---------- */
const tuneForm = document.querySelector("#tune-form"); 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 /* The New chat binding is module-owned (assets/header.js, phase 34
* chat, fresh": clear the phase-14 conversation key, then land on the * task 02 — the SINGLE binding): on this non-chat page it clears the
* chat page — its empty state, since the conversation is gone from * phase-14 conversation key and navigates to the chat's empty state.
* storage (the same contract as sources.js / document.js). */ *
const newChatBtn = document.querySelector("#new-chat-btn"); * Boot: the shared header FIRST (Sign in/out + the admin-only nav
if (newChatBtn) { * links — one cached whoami), then the note list — admin data only
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 Sources page gate pattern): an anonymous visitor gets the page (the Sources page gate pattern): an anonymous visitor gets the page
frame with the empty state, and the create form 403s gracefully on frame with the empty state, and the create form 403s gracefully on
submit if one tries. */ submit if one tries. */
+92 -17
View File
@@ -11,28 +11,73 @@
<body> <body>
<a class="skip-link" href="#main">Skip to content</a> <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"> <header class="doc-header">
<div class="container doc-header-inner"> <div class="app-header">
<a class="doc-back" id="doc-back" href="/sources.html"> <div class="container header-inner">
<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 class="brand">
<span>Sources</span> <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>
</a> <span class="brand-text">Brain of <strong>Reese</strong></span>
<div class="doc-title-block"> </span>
<h1 id="doc-title">Loading…</h1> <nav class="app-nav" aria-label="Primary">
<div id="doc-meta" class="doc-meta"></div> <a href="/" class="nav-link">Chat</a>
</div> <!-- Phase 19 (now every page — phase 34, owner confirmation
<!-- Phase 19: the shared header controls reach the viewer bar — 2026-08-26): the Sources link is admin-only (owner
same markup, ids, and aria as the chat header (one consistent permission 2026-08-23) — hidden by default, header.js
bar on every page). The viewer has no nav, so no Sources link reveals it once whoami says admin. The soft-gated page
here. header.js (assets/header.js) reveals exactly one of Sign in / itself is unchanged. -->
Sign out after whoami; New chat here means "go to the chat, <a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
fresh" (document.js). The title block clips while the two <!-- Phase 29 (now every page — phase 34, owner confirmation
pills fit (.doc-header-actions, styles.css). --> 2026-08-26): the Global Tuning link is admin-only (owner
<div class="doc-header-actions"> 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"> <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> <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> <span class="new-chat-label">New chat</span>
</button> </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> <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> <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> <span class="auth-label">Sign in</span>
@@ -43,9 +88,39 @@
</button> </button>
</div> </div>
</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> </header>
<main id="main" class="app-main" tabindex="-1"> <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 <!-- aria-live wraps the load → content swap so screen readers hear the
document land (phase 10 a11y contract). --> document land (phase 10 a11y contract). -->
<div class="container doc-shell" aria-live="polite"> <div class="container doc-shell" aria-live="polite">
+41 -14
View File
@@ -19,32 +19,55 @@
</span> </span>
<nav class="app-nav" aria-label="Primary"> <nav class="app-nav" aria-label="Primary">
<a href="/" class="nav-link is-active" aria-current="page">Chat</a> <a href="/" class="nav-link is-active" aria-current="page">Chat</a>
<!-- Phase 19: the Sources link is admin-only (owner permission <!-- Phase 19 (now every page — phase 34, owner confirmation
2026-08-23) — hidden by default, header.js reveals it once 2026-08-26): the Sources link is admin-only (owner
whoami says admin. The soft-gated page itself is unchanged. --> permission 2026-08-23) — hidden by default, header.js
reveals it once whoami says admin. The soft-gated page
itself is unchanged. -->
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a> <a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
<!-- Phase 29: the Global Tuning link is admin-only (owner permission <!-- Phase 29 (now every page — phase 34, owner confirmation
2026-08-25) — hidden by default, header.js reveals it once 2026-08-26): the Global Tuning link is admin-only (owner
whoami says admin, exactly like the Sources link above. Points at permission 2026-08-25) — hidden by default, header.js
the standalone /tuning.html manager (phase 27). --> 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> <a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
</nav> </nav>
<!-- Phase 15: open the tuning-notes panel (stored in Postgres, read <!-- Phase 15 (now every page — phase 34, owner confirmation
into every system prompt) — chat page only. --> 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" <button type="button" class="steering-toggle" id="steering-toggle"
aria-expanded="false" aria-controls="steering-panel"> 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> <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-label">Tuning</span>
<span class="steering-count" id="steering-count">0</span> <span class="steering-count" id="steering-count">0</span>
</button> </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"> <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> <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> <span class="new-chat-label">New chat</span>
</button> </button>
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign out <!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
is visible; /api/whoami decides at load (app.js). Icon-only out is visible; /api/whoami decides at load (the shared
below 640px (aria-labels keep the accessible names). --> header module). Icon-only below 640px (aria-labels keep the
accessible names). -->
<a href="/login.html?next=/sources.html" class="auth-link" id="sign-in-link" hidden> <a href="/login.html?next=/sources.html" class="auth-link" 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> <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> <span class="auth-label">Sign in</span>
@@ -63,7 +86,11 @@
<span id="kb-banner-text"></span> <span id="kb-banner-text"></span>
</div> </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" <section class="steering-panel" id="steering-panel" role="region"
aria-label="Tuning notes" hidden> aria-label="Tuning notes" hidden>
<div class="steering-panel-head"> <div class="steering-panel-head">
+77 -9
View File
@@ -20,16 +20,82 @@
</span> </span>
<nav class="app-nav" aria-label="Primary"> <nav class="app-nav" aria-label="Primary">
<a href="/" class="nav-link">Chat</a> <a href="/" class="nav-link">Chat</a>
<!-- Phase 19: the Sources link is admin-only (owner permission <!-- Phase 19 (now every page — phase 34, owner confirmation
2026-08-23) — hidden by default, header.js reveals it once 2026-08-26): the Sources link is admin-only (owner
whoami says admin. The login page deliberately carries NO permission 2026-08-23) — hidden by default, header.js
chat controls, so header.js only toggles this link here. --> reveals it once whoami says admin. The soft-gated page
itself is unchanged. -->
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a> <a href="/sources.html" class="nav-link" id="nav-sources" hidden>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> </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> </div>
</header> </header>
<main id="main" class="app-main" tabindex="-1"> <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"> <div class="container login-shell">
<!-- Centered sign-in card (phase 16): one admin, one password. --> <!-- Centered sign-in card (phase 16): one admin, one password. -->
<section class="login-card" aria-labelledby="login-title"> <section class="login-card" aria-labelledby="login-title">
@@ -61,11 +127,13 @@
</div> </div>
</footer> </footer>
<!-- Phase 19: shared header module — the login page reuses it for the <!-- Phase 19 (full header from phase 34 — owner confirmation
Sources-link toggle only (no chat controls in this markup, so 2026-08-26): the login page carries the SAME shared header as
none appear). It loads through the page script's own every other page (nav incl. the admin-only Tuning link, Tuning
`import "./header.js"` (hoisted, evaluated before the page script toggle, Sync, New chat, the auth pair) + the #steering-panel in
body calls initSharedHeader() at boot). --> <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> <script type="module" src="/assets/login.js"></script>
</body> </body>
</html> </html>
+59 -23
View File
@@ -19,38 +19,59 @@
</span> </span>
<nav class="app-nav" aria-label="Primary"> <nav class="app-nav" aria-label="Primary">
<a href="/" class="nav-link">Chat</a> <a href="/" class="nav-link">Chat</a>
<!-- Phase 19: the Sources link is admin-only (owner permission <!-- Phase 19 (now every page — phase 34, owner confirmation
2026-08-23) — hidden by default, header.js reveals it once 2026-08-26): the Sources link is admin-only (owner
whoami says admin. The soft-gated page itself is unchanged. --> permission 2026-08-23) — hidden by default, header.js
reveals it once whoami says admin. The soft-gated page
itself is unchanged. -->
<a href="/sources.html" class="nav-link is-active" aria-current="page" id="nav-sources" hidden>Sources</a> <a href="/sources.html" class="nav-link is-active" aria-current="page" id="nav-sources" hidden>Sources</a>
<!-- Phase 29: the Global Tuning link is admin-only (owner permission <!-- Phase 29 (now every page — phase 34, owner confirmation
2026-08-25) — hidden by default, header.js reveals it once 2026-08-26): the Global Tuning link is admin-only (owner
whoami says admin, exactly like the Sources link above. --> 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> <a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
</nav> </nav>
<!-- Phase 19: the shared header controls reach the Sources page — <!-- Phase 15 (now every page — phase 34, owner confirmation
same markup, ids, and aria as the chat header (one consistent 2026-08-26): open the tuning-notes panel (stored in
bar on every page). header.js (assets/header.js) reveals Postgres, read into every system prompt). The behavior is
exactly one of Sign in / Sign out after whoami; the New chat owned by the shared header module (assets/header.js); the
button here means "go to the chat, fresh" (sources.js). --> #steering-panel section ships in every page's <main>. -->
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat"> <button type="button" class="steering-toggle" id="steering-toggle"
<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> aria-expanded="false" aria-controls="steering-panel">
<span class="new-chat-label">New chat</span> <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> </button>
<!-- Phase 32: the admin-only "Sync sources" button (TODO.md:5) — <!-- Phase 32 (now every page — phase 34, owner confirmation
SHIPS hidden (anonymous-safe), header.js reveals it for the 2026-08-26): the admin-only "Sync sources" button — SHIPS
admin on the SAME cached whoami that reveals #nav-sources / hidden (anonymous-safe), header.js reveals it for the admin
#nav-tuning (one fetch, no extra whoami call). sources.js on the SAME cached whoami that reveals #nav-sources /
drives the §7.4 "never stale" lifecycle: idle → "Syncing…" #nav-tuning. The §7.4 "never stale" lifecycle is
(disabled + spinning icon + 2 s GET /api/sync/status poll) → module-owned (assets/header.js); the Sources page's
last result ("Synced HH:MM" + counts in #sync-result) or the #sync-result line + #sync-error-banner render off the
role="alert" error banner. #sync-result is the aria-live module's "bor:sync-status" event. -->
announcer for the last result. -->
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources"> <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> <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> <span class="sync-label" id="sync-label">Sync sources</span>
</button> </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> <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> <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> <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> <span class="auth-label">Sign in</span>
@@ -63,6 +84,21 @@
</header> </header>
<main id="main" class="app-main" tabindex="-1"> <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"> <div class="container sources-shell">
<!-- Phase 32: the sync failure banner — the chat error-banner <!-- Phase 32: the sync failure banner — the chat error-banner
markup style (kb-banner + is-error), role="alert" so a failed markup style (kb-banner + is-error), role="alert" so a failed
+58 -17
View File
@@ -19,29 +19,55 @@
</span> </span>
<nav class="app-nav" aria-label="Primary"> <nav class="app-nav" aria-label="Primary">
<a href="/" class="nav-link">Chat</a> <a href="/" class="nav-link">Chat</a>
<!-- Phase 19: the Sources link is admin-only (owner permission <!-- Phase 19 (now every page — phase 34, owner confirmation
2026-08-23) — hidden by default, header.js reveals it once 2026-08-26): the Sources link is admin-only (owner
whoami says admin. The soft-gated page itself is unchanged. --> permission 2026-08-23) — hidden by default, header.js
reveals it once whoami says admin. The soft-gated page
itself is unchanged. -->
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a> <a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
<!-- Phase 27: this page's own nav link. Admin-only, exactly like <!-- Phase 29 (now every page — phase 34, owner confirmation
the Sources link: it SHIPS hidden (anonymous-safe default — 2026-08-26): the Global Tuning link is admin-only (owner
the phase-16 "absent, not hidden" spirit) and header.js permission 2026-08-25) — hidden by default, header.js
reveals it once whoami says admin, so no anonymous user ever reveals it once whoami says admin, exactly like the
sees it for a frame. --> Sources link above. -->
<a href="/tuning.html" class="nav-link is-active" id="nav-tuning" aria-current="page" hidden>Tuning</a> <a href="/tuning.html" class="nav-link is-active" aria-current="page" id="nav-tuning" hidden>Tuning</a>
</nav> </nav>
<!-- Phase 19: the shared header controls reach the tuning page — <!-- Phase 15 (now every page — phase 34, owner confirmation
same markup, ids, and aria as the other pages (one consistent 2026-08-26): open the tuning-notes panel (stored in
bar on every page). header.js (assets/header.js) reveals Postgres, read into every system prompt). The behavior is
exactly one of Sign in / Sign out after whoami; the New chat owned by the shared header module (assets/header.js); the
button here means "go to the chat, fresh" (tuning.js). --> #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"> <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> <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> <span class="new-chat-label">New chat</span>
</button> </button>
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign out <!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
is visible; /api/whoami decides at load (header.js). Icon-only out is visible; /api/whoami decides at load (the shared
below 640px (aria-labels keep the accessible names). --> header module). Icon-only below 640px (aria-labels keep the
accessible names). -->
<a href="/login.html?next=/tuning.html" class="auth-link" id="sign-in-link" hidden> <a href="/login.html?next=/tuning.html" class="auth-link" 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> <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> <span class="auth-label">Sign in</span>
@@ -54,6 +80,21 @@
</header> </header>
<main id="main" class="app-main" tabindex="-1"> <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="container tuning-shell">
<div class="page-head"> <div class="page-head">
<h1>Global Tuning</h1> <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. # Header: Sign in offered, Sign out not.
expect(page.locator("#sign-in-link")).to_be_visible() expect(page.locator("#sign-in-link")).to_be_visible()
expect(page.locator("#sign-in-link")).to_have_attribute( # Phase 34 task 02: the shared header module rewrites the static
"href", "/login.html?next=/sources.html" # ?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() expect(page.locator("#sign-out-btn")).to_be_hidden()
# Chat still streams a grounded answer (with source chips) for # 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 header's ``.header-inner`` — the desktop test verifies its presence in
both auth states without the bar's height moving (height assertions both auth states without the bar's height moving (height assertions
unchanged). 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 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) page.goto(app_url + VIEWER_URL)
expect(page.locator("#doc-title")).to_have_text("Kubernetes Homelab Cluster", timeout=15_000) 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 return heights
@@ -158,6 +168,9 @@ def test_header_height_identical_across_pages_mobile(
def test_viewer_header_content_still_fits( def test_viewer_header_content_still_fits(
page: Page, app_url: str, mock_llm: int, db_ready: None page: Page, app_url: str, mock_llm: int, db_ready: None
) -> 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) _seed_db(mock_llm)
for width, expected_h in ((1280, DESKTOP_HEADER_H), (375, MOBILE_HEADER_H)): 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(".format-badge", has_text="md")).to_be_visible()
expect(page.locator(".doc-path", has_text="homelab/kubernetes.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") back = page.locator("#doc-back")
expect(back).to_be_visible() expect(back).to_be_visible()
assert _box_height(page, "#doc-back") >= 44 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" "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 Contract under test (owner report 2026-08-23, phase 19) — ONE bar per
page, the same controls everywhere: page, the same controls everywhere:
* chat / sources: brand + nav [Chat, Sources — admin only] + New Chat * chat / sources / viewer: brand + nav [Chat, Sources — admin only] +
+ Sign in / Sign out; New Chat + Sign in / Sign out;
* document viewer: back + title + New Chat + Sign in / Sign out (the * document viewer: the standard bar (row 1) + back + title + meta in a
viewer has no nav, so no Sources link at all); second titlebar row (phase 34, owner confirmation 2026-08-26 — the
viewer's old "no nav" single-row bar is superseded; it now carries
the SAME nav contract as every other page);
* the "Sources" nav link (``#nav-sources``) is HIDDEN for anonymous * the "Sources" nav link (``#nav-sources``) is HIDDEN for anonymous
users on every page that has a nav and shown for admin (phase-16 UX users on every page and shown for admin (phase-16 UX revision with
revision with owner permission; the soft-gate page and the A10 API owner permission; the soft-gate page and the A10 API split are
split are untouched); untouched) — now on the viewer as well (phase 34);
* the bar height never moves: 64px desktop / 58px at ≤640px (phase-12 * 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`` Determinism note: every assertion is settled-state — ``assert_shared_bar``
first waits for the whoami toggle to land (exactly one of Sign in / 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: 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: 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). # New Chat is on the bar on every page kind (the owner's ask).
expect(page.locator("#new-chat-btn")).to_be_visible() expect(page.locator("#new-chat-btn")).to_be_visible()
if page_kind == "viewer": # Phase 34: EVERY page kind — viewer included — carries the SAME
# The viewer has no nav — no Sources link in the DOM at all. # nav contract: the Chat link always visible; the admin-only
assert page.locator("#nav-sources").count() == 0, ( # #nav-sources / #nav-tuning links (phase 19 / phase 29) ship
"the viewer bar must not carry a Sources nav link" # hidden and are revealed for admin (phase-16 UX revision, owner
) # permission 2026-08-23; the soft-gate page and the A10 API split
# The document itself has settled (rendered, not Loading…/not-found) # are untouched).
# so the bar is being measured on the real page. expect(page.locator(".app-nav a[href='/']")).to_be_visible()
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000) for link_id in ("#nav-sources", "#nav-tuning"):
else: nav = page.locator(link_id)
# The Sources nav link: admin-only (phase-16 revision, owner assert nav.count() == 1, f"one {link_id} expected on the {page_kind} page"
# 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"
if admin: if admin:
expect(nav).to_be_visible() expect(nav).to_be_visible()
else: else:
expect(nav).to_be_hidden() 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), # The bar height never moves: 64px desktop / 58px ≤640px (phase 12),
# bounding-box measurement — the new pills must fit inside it. # bounding-box measurement — the new pills must fit inside it.
box = page.locator(_bar_selector(page_kind)).bounding_box() 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 assert nav.count() == 1
expect(nav).to_be_hidden() expect(nav).to_be_hidden()
# The login page has no chat controls — header.js only toggles the # The login page carries the full shared header too (phase 34);
# nav link there; for anonymous it stays hidden (it ships hidden). # 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.goto(app_url + "/login.html")
page.wait_for_load_state("networkidle") # the whoami round-trip has settled page.wait_for_load_state("networkidle") # the whoami round-trip has settled
nav = page.locator("#nav-sources") nav = page.locator("#nav-sources")
+2
View File
@@ -59,6 +59,8 @@ def _make_git_repo(base: Path) -> tuple[Path, str]:
"user.name=test", "user.name=test",
"-c", "-c",
"user.email=test@example.com", "user.email=test@example.com",
"-c",
"commit.gpgsign=false", # the fixture commit never signs (env gpg)
"commit", "commit",
"-q", "-q",
"-m", "-m",
+320 -50
View File
@@ -3,9 +3,10 @@
The browser behavior is E2E-covered (tests/e2e/test_shared_header.py); 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 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 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 controls), the sign-out binding move out of app.js, the SINGLE
Chat bindings, and the viewer-bar CSS — so a silent regression is caught module-owned New chat binding (phase 34 task 02) + the sign-in
without a browser. ?next= rewrite, and the viewer-bar CSS — so a silent regression is
catched without a browser.
""" """
from __future__ import annotations 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: def test_nav_sources_ships_hidden_on_every_nav_page() -> None:
"""Phase 19 UX revision (owner permission 2026-08-23): the Sources """Phase 19 UX revision (owner permission 2026-08-23), completed on
nav link is hidden for anonymous — so it SHIPS with the hidden all five pages by phase 34 task 03 (owner confirmation 2026-08-26):
attribute (anonymous-safe default) on every page that has a nav the Sources nav link is hidden for anonymous — so it SHIPS with the
(chat, sources, login).""" hidden attribute (anonymous-safe default) on every page (they all
for html in (INDEX_HTML, SOURCES_HTML, LOGIN_HTML, TUNING_HTML): carry the nav now, viewer included)."""
for html in (INDEX_HTML, SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
text = _text(html) text = _text(html)
assert re.search(r'id="nav-sources"[^>]*\bhidden\b', text), ( assert re.search(r'id="nav-sources"[^>]*\bhidden\b', text), (
f"{html.name}: #nav-sources must ship hidden" 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: def test_nav_tuning_ships_hidden_on_the_tuning_page() -> None:
"""Phase 27: the Global Tuning page reuses the shared header — the """Phase 27: the Global Tuning page reuses the shared header — the
"Tuning" nav link is admin-only, so it SHIPS hidden (revealed by "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] assert [s for s in srcs if "tuning.js" in s]
def test_nav_sources_is_absent_from_the_viewer() -> None: def test_viewer_carries_the_standard_nav() -> None:
"""The document viewer has no nav — no #nav-sources element there (the """Phase 34 task 03 (owner confirmation 2026-08-26): the document
module's missing-element no-op keeps it out).""" viewer carries the SAME standard bar as every other page — row 1 is
assert 'id="nav-sources"' not in _text(DOCUMENT_HTML) 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: 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: def test_login_page_carries_the_full_header() -> None:
"""Noted boundary (owner-confirmed): the login page is the auth page, """Phase 34 task 03 (owner confirmation 2026-08-26): the noted
not an app page — no New Chat / Sign in / Sign out controls there; boundary is reversed by owner decision — the login page finally
header.js only toggles the Sources link.""" 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) text = _text(LOGIN_HTML)
assert "new-chat-btn" not in text for marker in (
assert "sign-in-link" not in text 'id="nav-sources"',
assert "sign-out-btn" not in text '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 ---------- # ---------- 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 assert "window.location.replace(safeNext())" in js
def test_non_chat_pages_bind_new_chat_to_the_chat_page() -> None: def test_new_chat_binding_is_single_and_module_owned() -> None:
"""On sources, the viewer, and the tuning page, New Chat means "go to """Phase 34 task 02: header.js owns the SINGLE #new-chat-btn binding
the chat, fresh": the binding clears the phase-14 key (module import, like the sign-out binding): on the chat page
(clearChatStorage) and navigates to "/" — and each page runs (#messages exists) it dispatches window "bor:new-chat" — app.js acts
initSharedHeader() at boot on the shared cached whoami. Phase 23: 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`).""" 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): for js_file in (SOURCES_JS, DOCUMENT_JS, TUNING_JS):
js = _text(js_file) page_js = _text(js_file)
assert 'from "./header.js"' in js assert 'from "./header.js"' in page_js
assert "initSharedHeader()" in js assert "initSharedHeader()" in page_js
btn_idx = js.find("new-chat-btn") assert "new-chat-btn" not in page_js, (
clear_idx = js.find("clearChatStorage();") f"{js_file.name}: no #new-chat-btn binding (the module owns it)"
nav_idx = js.find('window.location.href = "/"')
assert -1 < btn_idx < clear_idx < nav_idx, (
f"{js_file.name}: #new-chat-btn must clear storage then navigate to '/'"
) )
assert 'fetch("/api/whoami")' not in js, ( assert 'fetch("/api/whoami")' not in page_js, (
f"{js_file.name}: whoami goes through the shared cached promise" f"{js_file.name}: whoami goes through the shared cached promise"
) )
app_js = _text(APP_JS)
assert 'window.addEventListener("bor:new-chat", startNewChat)' in app_js
# ---------- viewer-bar CSS ---------- assert "newChatBtn.addEventListener" not in app_js, (
"app.js acts off the module's event, not its own binding"
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"
) )
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, The browser behavior is E2E-covered (tests/e2e/test_sync_button.py,
task 03); here we pin the source-level wiring — the anonymous-safe task 03); here we pin the source-level wiring — the anonymous-safe
ship-hidden button markup, the header.js admin reveal on the SAME ship-hidden button markup, the header.js admin reveal on the SAME
cached whoami (no extra fetch), the sources.js sync state machine cached whoami (no extra fetch), the header.js sync state machine
(2 s poll, 202 start / 409 adoption / 403 hide, terminal labels, (moved here from sources.js in phase 34 task 02: 2 s poll, 202 start
the aria-live result, the single-poll-loop guard, no client-side hard / 409 adoption / 403 hide, terminal labels, the "bor:sync-status"
timeout), the §7.4 never-stale CSS (spin + reduced-motion opt-out, 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 disabled state, 44px floor, contrast pair) — so a silent regression is
caught without a browser. 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: def test_header_reveals_sync_btn_on_the_admin_branch() -> None:
"""initSharedHeader reveals #sync-btn in the SAME admin branch as """initSharedHeader reveals #sync-btn in the SAME admin branch as
#nav-sources (querySelector + hidden = !admin) — one cached whoami, #nav-sources (hidden = !admin) — one cached whoami, no extra
no extra whoami call; anonymous users never leave the hidden whoami call; anonymous users never leave the hidden default. The
default.""" ref is the module-level one — the state machine shares it."""
js = _text(HEADER_JS) js = _text(HEADER_JS)
assert 'querySelector("#sync-btn")' in js, "module-level #sync-btn ref"
fn = js.find("function initSharedHeader") fn = js.find("function initSharedHeader")
assert fn != -1 assert fn != -1
body = js[fn : js.find("\n}", fn)] 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, "#sync-btn must join the admin reveal"
assert "syncBtn.hidden = !admin" in body
# The reveal must not introduce a second whoami call site. # The reveal must not introduce a second whoami call site.
assert js.count('fetch("/api/whoami")') == 1 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 """The click posts to POST /api/sync and the poll loop GETs
/api/sync/status — both through the same-origin API (A10).""" /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", { method: "POST" })' in js
assert 'fetch("/api/sync/status")' 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, """The feedback loop is a 2000 ms poll of the status endpoint,
re-scheduled one tick at a time (setTimeout, not setInterval — an re-scheduled one tick at a time (setTimeout, not setInterval — an
in-flight fetch can never overlap the next tick).""" 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 "SYNC_POLL_MS = 2000" in js
assert "setTimeout(tick, SYNC_POLL_MS)" in js assert "setTimeout(tick, SYNC_POLL_MS)" in js
assert "setInterval" not 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 """202 (started) and 409 (a run started elsewhere — e.g. a second
tab) both enter the running state and start polling: the UI never tab) both enter the running state and start polling: the UI never
starts a second run, it adopts the in-flight one.""" 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 assert "r.status === 202 || r.status === 409" in js
idx = js.find("r.status === 202 || r.status === 409") idx = js.find("r.status === 202 || r.status === 409")
branch = js[idx : idx + 200] branch = js[idx : idx + 400]
assert "enterRunningState()" in branch assert "enterSyncRunningState()" in branch
assert "startSyncPolling()" in branch assert "startSyncPolling()" in branch
def test_sources_js_hides_the_button_on_403() -> None: def test_header_js_hides_the_button_on_403() -> None:
"""A 403 anywhere (POST or poll) is treated as not-admin: the """A 403 anywhere (POST, the status poll, the load re-attach) is
button hides — defense in depth behind header.js's whoami reveal.""" treated as not-admin: the button hides — defense in depth behind
js = _text(SOURCES_JS) the whoami reveal (the primary gate)."""
for occurrence in re.finditer(r"r\.status === 403", js): js = _text(HEADER_JS)
window = js[occurrence.start() : occurrence.start() + 400]
assert "syncBtn.hidden = true" in window, "every 403 branch must hide the button"
assert len(re.findall(r"r\.status === 403", js)) >= 3, ( assert len(re.findall(r"r\.status === 403", js)) >= 3, (
"POST, the status poll, and the load re-attach must all handle 403" "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, """Entering the running state disables the button, sets aria-busy,
spins the icon, and swaps the label to 'Syncing…' (the §7.4 spins the icon, and swaps the label to 'Syncing…' (the §7.4
feedback while the poll waits).""" feedback while the poll waits) — and a fresh run starts clean: the
js = _text(SOURCES_JS) previous failure's title / aria-label / .is-error come off NOW,
fn = js.find("function enterRunningState") not when the run settles."""
assert fn != -1 js = _text(HEADER_JS)
body = js[fn : js.find("\n}", fn)] body = _body(js, "enterSyncRunningState")
assert "syncBtn.disabled = true" in body assert "syncBtn.disabled = true" in body
assert 'syncBtn.setAttribute("aria-busy", "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 "syncIcon.classList.add(\"is-spinning\")" in body
assert '"Syncing…"' 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 """Terminal rendering: success → enabled + 'Synced HH:MM' (local
time of finished_at) + the last-result counts ('added' always time of finished_at); failed → enabled + retry-ready 'Sync sources'
announced, zero terms omitted — a no-op re-sync reads '0 added · label + the sanitized error in the button's title + aria-label +
1 unchanged', never an empty live region) + a live catalog refresh the .is-error class (non-Sources pages: that is where the failure
(the KB just changed — never a stale table); failed → enabled + is visible). The counts formatting (fmtSyncResult) lives here and
retry-ready 'Sync sources' label + the role='alert' banner with is EXPORTED for the Sources page ('added' always announced, zero
the error; the result is cleared on a failure.""" terms omitted — a no-op re-sync reads '0 added · 1 unchanged')."""
js = _text(SOURCES_JS) js = _text(HEADER_JS)
success = _body(js, "applySyncSuccess") success = _body(js, "applySyncSuccess")
assert '"Synced"' in success and "fmtSyncTime(status.finished_at)" in success 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") failure = _body(js, "applySyncFailure")
assert 'settleSyncButton("Sync sources")' in failure # retry-ready 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") 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 "added" in result and "unchanged" in result
assert " · " in result assert " · " in result
assert "> 0" in result, "zero terms must be omitted" 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" assert "getHours()" in time and "getMinutes()" in time, "local HH:MM of finished_at"
def test_sources_js_settles_the_button_on_terminal() -> None: def test_header_js_failed_error_is_sanitized() -> None:
"""settleSyncButton re-enables the control, drops aria-busy, and """The button's title/aria-label error is sanitized for the
un-spins the icon — the button can never sit disabled after a run attributes: the server already masks credentials (sync.py
reaches a terminal state (failed included: retry-ready).""" _sanitize_error); the module collapses whitespace to a single line
js = _text(SOURCES_JS) and caps the length, and a missing error still names a failure."""
fn = js.find("function settleSyncButton") js = _text(HEADER_JS)
assert fn != -1 body = _body(js, "sanitizeSyncError")
body = js[fn : js.find("\n}", fn)] 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.disabled = false" in body
assert 'syncBtn.removeAttribute("aria-busy")' 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 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 """startSyncPolling is guarded by the module-level timer: a 409
adoption, a reload re-attach, or a stray call can never run two adoption, a reload re-attach, or a stray call can never run two
poll loops at once (phase completion criterion).""" poll loops at once (phase completion criterion)."""
js = _text(SOURCES_JS) js = _text(HEADER_JS)
fn = js.find("function startSyncPolling") fn = js.find("function startSyncPolling")
assert fn != -1
head = js[fn : js.find("const tick", fn)] head = js[fn : js.find("const tick", fn)]
assert re.search(r"if\s*\(\s*syncPollTimer\s*!==\s*null\s*\)\s*return", head), ( assert re.search(r"if\s*\(\s*syncPollTimer\s*!==\s*null\s*\)\s*return", head), (
"the single-loop guard must be the first statement" "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") assert "clearTimeout(syncPollTimer)" in _body(js, "stopSyncPolling")
def test_sources_js_has_no_client_side_hard_timeout() -> None: def test_header_js_has_no_client_side_hard_timeout() -> None:
"""Phase locked decision: a sync can legitimately run for minutes, """Phase locked decision: a sync can legitimately run for minutes
so there is NO client-side hard timeout — the 2 s poll is the (and outlive the page), so there is NO client-side hard timeout —
feedback loop and the server state is authoritative (the 120 s the 2 s poll is the feedback loop and the server state is
LLM-turn guard must not leak into the sync path).""" authoritative (the 120 s LLM-turn guard must not leak into the
js = _text(SOURCES_JS) sync path)."""
js = _text(HEADER_JS)
assert "TURN_TIMEOUT" not in js assert "TURN_TIMEOUT" not in js
assert "120" not in js[js.find("Phase 32") :], ( sync_start = js.find("sync sources (phase 32")
"no turn-timeout constant in the sync section" assert sync_start != -1, "the sync section marker comment"
) assert "120" not in js[sync_start:]
def test_sources_js_reattaches_on_load() -> None: def test_header_js_reattaches_on_load_admin_only() -> None:
"""initSyncButton (run from the IIFE on the admin path, after """initSyncButton (run at module import, button pages only) awaits
initSharedHeader) fetches the status once and re-enters the running the SAME cached whoami — ADMIN ONLY (non-admins never poll, the
state on 'running' (reload mid-sync) or renders the last result on status endpoint is admin-only): a running run re-enters the
a terminal state; the click binding wires startSync to the button.""" running state (reload mid-sync), a terminal run renders its last
js = _text(SOURCES_JS) result, idle settles retry-ready; the click binding wires
startSync to the button."""
js = _text(HEADER_JS)
fn = js.find("function initSyncButton") fn = js.find("function initSyncButton")
assert fn != -1 assert fn != -1
body = js[fn : js.find("\n}\n", fn)] 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 'fetch("/api/sync/status")' in body
assert 'status.state === "running"' in body assert 'status.state === "running"' in body
assert 'status.state === "success"' in body assert 'status.state === "success"' in body
assert 'status.state === "failed"' in body assert 'status.state === "failed"' in body
assert "syncBtn.addEventListener(\"click\", startSync)" in js assert "syncBtn.addEventListener(\"click\", startSync)" in js
# The IIFE runs it on the admin path only (after the whoami gate). tail = js[js.rfind("if (syncBtn)") :]
iife = js[js.find("(async () => {") :] assert "initSyncButton()" in tail, "boot re-attach runs at module import"
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" 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 ---------- # ---------- styles.css: the §7.4 states ----------