/* 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. * Phase 79 (task 05): it ALSO drops the cached token * (localStorage["bor.token"], try/catch — the fail-silence storage * contract) BEFORE the reload, so a signing-out token user meets * the gate again on the next load (the server session is wiped by * the logout; the localStorage key must go with it); * • 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. * * The three control BINDINGS (sign-out, the mobile hamburger, the * SINGLE New chat button) are NOT import-time side effects — they run * when a page script calls bindSharedHeaderControls() once at module * top. (2026-09-08 production diagnosis: the Containerfile build * inlines this module into every bundle that imports it, and the old * import-time binding ran once per bundle copy — on the shell page * the #nav-toggle click handler was registered twice, and two toggle * handlers cancel each other: open + close on one tap, a menu dead in * the deployed image only. See the function's own comment.) * * whoami is fetched at most ONCE per page load: the promise is cached in * the module-level `whoamiPromise`, so app.js's tuning gate, the sources * page's catalog gate, and the header toggling all share one request. * Phase 79 (task 05): the cache stores the FULL response — * `{ authenticated, role }` (role: "admin" | "user" | "anonymous") — * not just the admin flag: the token-gate module (assets/token-gate.js) * reuses it for its role check, and `resetWhoami()` invalidates it * right after a mid-page auth (a silent re-auth or an interactive * login) so the next fetchWhoami() is a fresh post-auth request. * Anonymous-safe: any network failure or non-2xx resolves to * `{ authenticated: false, role: "anonymous" }` (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. */ /* The anonymous fallback (phase 79): non-2xx, a network failure, or a malformed body all resolve to the anonymous role — the UI degrades to the guest surface, never to an error (the phase-19 anonymous-safe contract, unchanged in spirit). */ const ANONYMOUS_WHOAMI = Object.freeze({ authenticated: false, role: "anonymous" }); let whoamiPromise = null; /* The SINGLE /api/whoami call site for the whole frontend (phase 79, task 05: the cache now stores the FULL response — { authenticated, role } — not just the admin flag). First call stores the promise in `whoamiPromise`; 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 the anonymous role. */ export function fetchWhoami() { if (!whoamiPromise) { whoamiPromise = fetch("/api/whoami") .then(async (r) => { if (!r.ok) return ANONYMOUS_WHOAMI; const data = await r.json(); return { authenticated: data.authenticated === true, role: data.role === "admin" || data.role === "user" ? data.role : "anonymous", }; }) .catch(() => ANONYMOUS_WHOAMI); } return whoamiPromise; } /* The phase-16/19 contract every existing admin gate consumes: the role check is `role === "admin"` — a token user (role "user") is authenticated but NOT an admin, so every admin-only surface keys off this (never off `authenticated`). SAME single request: it delegates to fetchWhoami(), so all existing callers keep working with zero changes. */ export function fetchIsAdmin() { return fetchWhoami().then((w) => w.role === "admin"); } /* Phase 79 (task 05): the token gate changes the session MID-PAGE (a silent re-auth of a cached token, or an interactive login) — a whoami cached BEFORE that auth (fired at boot) is stale. The gate clears the cache right after a successful auth, so the NEXT fetchWhoami() is a fresh request carrying the post-auth role — and every consumer that awaits it afterwards (the header re-boot, the view gates) reuses that one fresh promise. */ export function resetWhoami() { whoamiPromise = null; } /* 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 fetchWhoami() again (the cached promise makes both awaits the same single request). */ export async function initSharedHeader() { const whoami = await fetchWhoami(); const admin = whoami.role === "admin"; // Phase 79 (task 05): the auth PAIR keys off the authenticated role — // admin OR token user: both get Sign out (the binding below drops the // cached token too) and neither sees Sign in. The admin-ONLY surfaces // (the nav links, the steering refresh) still key off role === // "admin": a token user gets the anonymous branch — the links stay // hidden and the steering panel is REMOVED from the DOM (/api/steering // 403s a user, so it must never be fetched). Admin and anonymous // behavior is byte-identical to phase 16/19. const signedIn = whoami.authenticated; // The Sign in link: hidden for any authenticated role (admin or // token user — phase 79), visible for anonymous — 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 = signedIn; link.href = "/login.html?next=" + signInNext; }); document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.hidden = !signedIn; }); 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 79 (task 06): the Tokens nav link (the shell's sixth view — // it ships ONLY in the shell's header) — admin-only, the same // ship-hidden / reveal-for-admin contract as the History link above. // Null-safe: a page without the link (the viewer / login / shared // pages) is a no-op. A token user (role "user") never sees it. const navTokens = document.querySelector("#nav-tokens"); if (navTokens) navTokens.hidden = !admin; // Phase 91 (task 04): the Theme nav link (the shell's seventh view — // the phase-34 one-bar contract ships it on every page's nav) — // admin-only, the same ship-hidden / reveal-for-admin contract as // the Tokens link above. Null-safe: a page without the link is a // no-op. A token user (role "user") never sees it. const navTheme = document.querySelector("#nav-theme"); if (navTheme) navTheme.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 AND token user (phase 79): // the panel is REMOVED from the DOM entirely — the phase-16 contract // says "absent", not just hidden — and /api/steering is never fetched // (it 403s a user; only the admin's notes steer the prompt). 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 */ } } /* ---------- shared header control bindings (explicit init, once per * document) ---------- * * The three shared-header control bindings — sign-out (phase 16), the * mobile hamburger (phase 46), the SINGLE New chat button (phase 34 * task 02) — live here, and they run on EXPLICIT init, never at module * import. The import-time binding was correct under native ESM (the * browser's module cache makes this file ONE instance per document) but * wrong under the Containerfile stage-1 build: esbuild inlines this * module into every bundle that imports it (the shell's app.js, * token-gate.js and the router's lazy views each carry a copy), and * top-level code runs once per copy — the shell page registered the * #nav-toggle click handler twice (the app.js + token-gate.js bundles), * and two toggle handlers cancel each other: one tap = open + close = * a menu that is dead in the deployed image only (production diagnosis * 2026-09-08). The dev tree's single ESM instance — and every test * that runs against the dev tree — never showed it; once a lazy view * load added a THIRD copy the odd count made the menu work again, which * is why the failure looked state-dependent (chat cold boot dead, * /sources.html alive). * * The four page scripts that ship header controls (app.js / login.js / * shared.js / document.js) therefore call bindSharedHeaderControls() * ONCE at module top — import-time parity, unconditional (no async * boot path to miss). The idempotency marker lives on , NOT in * module state: every bundle copy has its own function instance, so * only the document can say "the first caller won" — later copies and * repeated inits (the token gate's mid-page header re-boot) are no-ops. * Each control keeps the module's null-safe contract: a page without an * element is a complete no-op. */ export function bindSharedHeaderControls() { const body = document.body; if (!body || body.dataset.borHeaderBound) return; // a later bundle copy / a re-init — already bound body.dataset.borHeaderBound = "1"; /* Sign-out binding (phase 16 behavior, module-owned): binds to all .sign-out-btn elements (bar copy for desktop + mobile dropdown copy for ≤640px, phase 46) so both copies log out. Disable during the call, POST /api/logout (the result is ignored — the reload resets the UI either way), drop the cached token (phase 79: one logout clears BOTH the server session and the localStorage key — a signing-out token user meets the gate again on the next load), then reload so the header re-resolves to the anonymous state (Sign in back, Sources gone, the gate back for the not-yet-token holder). */ 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 */ } try { localStorage.removeItem("bor.token"); } catch { /* private mode / storage error — the server logout already signed out; the next load re-gates 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"); // Phase 88: body-level marker — while the mobile menu is open, the // chat's sticky bottom cluster is hidden (styles.css, ≤640px block): // it must not compete for taps with the open menu, and on short // viewports it overlaps the menu's lower rows. Every close path // (Esc / outside-click / media) funnels through setNavMenu, so the // marker can never stick. document.body.classList.toggle("nav-menu-open", open); } 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 } /* ---------- 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 (explicit * init, 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 + the rendered-list reset). */ const newChatBtn = document.querySelector("#new-chat-btn"); if (newChatBtn) { newChatBtn.addEventListener("click", () => { window.dispatchEvent(new CustomEvent("bor:new-chat")); }); } } /* ---------- 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; } }