332 lines
16 KiB
JavaScript
332 lines
16 KiB
JavaScript
/* 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=<current pathname>
|
|
* (default "/"), so the admin lands back on the page they signed in
|
|
* from; the page markup keeps its own href as the no-JS fallback;
|
|
* • clearChatStorage() — the phase-14 conversation key, for the
|
|
* New chat 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).
|
|
document.querySelectorAll(".sign-in-link").forEach(link => {
|
|
link.hidden = admin;
|
|
link.href = "/login.html?next=" + (window.location.pathname || "/");
|
|
});
|
|
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 =
|
|
'<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;
|
|
}
|
|
|
|
/* 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"));
|
|
});
|
|
}
|
|
|