/* Brain of Reese — shared header module (phase 19). * * Owner report 2026-08-23: clicking "Sources" made New Chat and Sign in * vanish — the user expects ONE consistent bar on every page. This module * is the single owner of the shared header controls: * * • the Sign in / Sign out auth pair (phase 16, exactly one visible — * decided by /api/whoami at load); * • the admin-only nav links — "RAG" (#nav-sources, phase 19), * "Sources" (#nav-git-sources, phase 35) and "Tuning" * (#nav-tuning, phase 29) — phase 19 UX revision * (owner permission 2026-08-23): hidden for anonymous on EVERY * page, revealed for admin. Phase 34 task 03 (owner confirmation * 2026-08-26): the SAME nav ships on all five pages (chat, * sources, document viewer, tuning, login) — the viewer's * "no nav" bar is gone. The links SHIP hidden in the HTML * (anonymous-safe default — the phase-16 "absent, not hidden" * spirit), so no anonymous user ever sees one for a frame; and * • the sign-out click binding (POST /api/logout → reload) — moved * here from app.js so there is exactly one implementation; * • the mobile hamburger binding (phase 46, owner permission * 2026-08-27, TODO.md L9) — at ≤640px (CSS hides the button * elsewhere) the #nav-toggle button opens the nav as an animated * dropdown (#app-nav .is-open — the 180ms slide+fade state from * task 01's CSS): a click toggles it with aria-expanded kept in * sync, a nav link click shuts it (the navigation happens anyway), * Esc shuts it and returns focus to the toggle, and resizing back * to >640px drops the open state (matchMedia change) so * aria-expanded stays honest. One binding for all six pages; a * page without either element is a no-op. The binding toggles * ONLY the container — the nav links keep their ship-hidden * whoami contract (hidden links stay hidden inside the menu); * • the steering-notes panel (phase 15, moved here from app.js in * phase 34) — the #steering-panel list (newest-first, * textContent-rendered, per-note delete) + the #steering-announcer * live region — so every page that ships the panel markup gets * exactly this behavior with zero page-script duplication. The * navbar #steering-toggle was REMOVED at owner request * (2026-08-28) — note management now lives on /tuning.html — so * the panel ships hidden on every page and is only kept fresh, * never opened from the header. 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 (panel removed from the * DOM, /api/steering never fetched); * • the SINGLE New chat binding (phase 34 task 02 — it was * duplicated across app.js / sources.js / tuning.js / document.js; * moved from the navbar to the chat page at owner request): * the button now lives ONLY on the chat page (inside .chat-shell, * above #messages). When clicked it dispatches window * "bor:new-chat" and app.js acts (it owns the in-flight-turn * guard + the list reset). * • clearChatStorage() — the phase-14 conversation key, for clearing * the conversation when navigating away from the chat page. * • the sign-in ?next= rewrite (phase 34 task 02) — initSharedHeader * points #sign-in-link at /login.html?next= * (default "/"), so the admin lands back on the page they signed in * from; the page markup keeps its own href as the no-JS fallback; * • clearChatStorage() — the phase-14 conversation key, for the * New chat action on the NON-CHAT pages (sources / document viewer / * tuning / login): a new chat means going to the chat, fresh. * * Every page loads this module (type="module", before its page script) * and its page script calls initSharedHeader() once at boot. init… * toggles ONLY the controls that exist on the page — a missing element * is a no-op. Phase 34 task 03 ships the SAME full header block on all * five pages (the login page included), so every control resolves on * every page; a page that lacks one simply skips it. * * whoami is fetched at most ONCE per page load: the promise is cached in * the module-level `adminPromise`, so app.js's tuning gate, the sources * page's catalog gate, and the header toggling all share one request. * Anonymous-safe: any network failure resolves to false (the anonymous * UI), mirroring the per-page catch the pages used before phase 19. * * A10/A11 untouched: no API change, no CDN, no state beyond the cached * promise; the soft gate page and the A10 API split are unchanged — * this is UI visibility only. */ let adminPromise = null; /* The SINGLE /api/whoami call site for the whole frontend. First call stores the promise in `adminPromise`; every later call — on this page — returns the same promise, i.e. exactly one request per page load. Anonymous-safe: non-2xx or a network failure resolves to false. */ export function fetchIsAdmin() { if (!adminPromise) { adminPromise = fetch("/api/whoami") .then(async (r) => (r.ok ? (await r.json()).authenticated === true : false)) .catch(() => false); } return adminPromise; } /* Toggle the shared header controls, only the ones present on this page (querySelector, null-safe — missing → no-op). Returns the admin flag so callers can reuse it instead of awaiting fetchIsAdmin() again (the cached promise makes both awaits the same single request). */ export async function initSharedHeader() { const admin = await fetchIsAdmin(); // The Sign in link: hidden for the admin, visible otherwise — and its // href is rewritten to return the admin to THIS page after login // (phase 34 task 02: "return to where you were"). The markup keeps its // own static ?next= as the no-JS fallback. location.pathname is always // a query-safe "/…" string (never "//"; ? # and spaces stay // percent-encoded in it), so it rides in next= as-is — the same shape // the static fallbacks use (login.js safeNext re-validates it). // // Phase 51 (owner-locked 2026-08-29, TODO.md L6): the ONE exception — // on the NESTED /shared/ page "where you were" is a public // link, not a place in the app: the rewrite stays at the APP ROOT // ("/"), so a guest signing in from a shared page lands in the chat // (the static ?next=/ fallback in shared.html matches — the rewrite // only ever KEEPS it there). A signed-in admin on the shared page // never sees the link (hidden = admin), so this only shapes the // guest experience. const nextPath = window.location.pathname || "/"; const signInNext = nextPath.startsWith("/shared/") ? "/" : nextPath; document.querySelectorAll(".sign-in-link").forEach(link => { link.hidden = admin; link.href = "/login.html?next=" + signInNext; }); document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.hidden = !admin; }); const navSources = document.querySelector("#nav-sources"); if (navSources) navSources.hidden = !admin; // Phase 35 (owner permission 2026-08-26): the Sources nav link — // admin-only, the same ship-hidden / reveal-for-admin contract as // the RAG link above. const navGitSources = document.querySelector("#nav-git-sources"); if (navGitSources) navGitSources.hidden = !admin; // Phase 29: the Global Tuning nav link (every page from phase 34 // task 03) — admin-only, the same ship-hidden / reveal-for-admin // contract as the Sources link. const navTuning = document.querySelector("#nav-tuning"); if (navTuning) navTuning.hidden = !admin; // Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the History // nav link (the phase-34 one-bar contract — it ships on every page) // — admin-only, the same ship-hidden / reveal-for-admin contract as // the Tuning link above. const navHistory = document.querySelector("#nav-history"); if (navHistory) navHistory.hidden = !admin; // Phase 34: the steering panel (phase 15) is module-owned. The // navbar #steering-toggle was removed at owner request (2026-08-28) // — the panel ships hidden and is only kept fresh. Admin: refresh // the list (fire-and-forget). Anonymous: the panel is 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 { steeringPanel?.remove(); } return admin; } /* Remove the phase-14 conversation key — same key + fail-silence contract as app.js's clearStoredConversation: private mode or a storage error is swallowed, the navigation still happens. */ export function clearChatStorage() { try { localStorage.removeItem("bor.chat.v1"); } catch { /* nothing was stored */ } } /* Sign-out binding (phase 16 behavior, now module-owned): runs at module import, so every page that loads header.js gets it exactly once. Binds to all .sign-out-btn elements (bar copy for desktop + mobile dropdown copy for ≤640px). Disable during the call, POST /api/logout (the result is ignored — the reload resets the UI either way), then reload so the header re-resolves to the anonymous state (Sign in back, Sources gone). */ document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.addEventListener("click", async () => { btn.disabled = true; try { await fetch("/api/logout", { method: "POST" }); } catch { /* the reload resets the UI either way */ } window.location.reload(); }); }); /* ---------- mobile hamburger (phase 46; module-owned) ---------- * ≤640px only (CSS hides the button elsewhere): #nav-toggle opens the * nav as a dropdown (#app-nav .is-open — the animated state, task 01 * CSS). One binding for all six pages; a page without either element * is a no-op, like the rest of this module. The nav LINKS keep their * ship-hidden whoami contract (hidden links stay hidden inside the * menu) — this binding only toggles the container. */ const navToggle = document.querySelector("#nav-toggle"); const appNav = document.querySelector("#app-nav"); function setNavMenu(open) { if (!appNav || !navToggle) return; appNav.classList.toggle("is-open", open); navToggle.setAttribute("aria-expanded", open ? "true" : "false"); } if (navToggle && appNav) { navToggle.addEventListener("click", () => setNavMenu(!appNav.classList.contains("is-open"))); // A link click navigates (or closes same-page) — shut the menu. appNav.addEventListener("click", (e) => { if (e.target.closest("a")) setNavMenu(false); }); // Esc closes while open (document-level — no other modal to fight // for a key). document.addEventListener("keydown", (e) => { if (e.key === "Escape" && appNav.classList.contains("is-open")) { setNavMenu(false); navToggle.focus(); // focus returns to the opener } }); // Resize back to desktop: the inline nav reappears — no stale open // state (the .is-open class is scoped by the ≤640px CSS anyway, but // dropping it keeps aria-expanded honest). const mq = window.matchMedia("(max-width: 640px)"); const onMqChange = () => { if (!mq.matches) setNavMenu(false); }; if (mq.addEventListener) mq.addEventListener("change", onMqChange); else mq.addListener(onMqChange); // older engines, defensive } /* ---------- 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 — list, per-note delete, announcer — * is owned by THIS module: every page that ships the panel markup gets * exactly this behavior, with zero page-script duplication. The navbar * #steering-toggle was removed at owner request (2026-08-28) — note * management now lives on /tuning.html — so the panel ships hidden and * is only kept fresh. 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 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 updates without owning the fetch itself). Non-2xx (the anonymous 403) or an unreachable API render the empty state: 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, and the empty text toggled on notes.length. */ 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 = ''; del.addEventListener("click", () => deleteSteeringNote(n.id, del)); li.appendChild(del); steeringList.appendChild(li); } if (steeringEmpty) steeringEmpty.hidden = notes.length > 0; } /* 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; } } /* ---------- New chat (the SINGLE binding — module-owned from phase 34 * task 02; moved from navbar to chat page at owner request) ---------- * * The binding used to be duplicated across app.js / sources.js / * tuning.js / document.js. It lives here exactly once (module import, * like the sign-out binding). The button now lives ONLY on the chat * page (inside .chat-shell, above #messages), so the click always * dispatches "bor:new-chat" — app.js acts (it owns the in-flight-turn * guard and the rendered-list reset). */ const newChatBtn = document.querySelector("#new-chat-btn"); if (newChatBtn) { newChatBtn.addEventListener("click", () => { window.dispatchEvent(new CustomEvent("bor:new-chat")); }); }