Files
brain-of-reese/frontend/assets/header.js
T
ducoterra d22d260b8b
Build and Push Containers / build-and-push-app (push) Successful in 5m43s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 91_admin_theme_tab
All verification is complete — this pass needed no code changes. Final report:

**Phase 91 — Admin Theme tab: final verification pass (all 6 tasks already in `complete/`)**

- Verified pre-paint theming end-to-end: `ui_settings` store + resolver, admin `GET/PUT /api/ui-settings`, `CachingMiddleware` inline-`<style id="bor-theme">` injection before `</head>` (incl. `/shared/<token>` prefix branch, unit-pinned), CSP sha256 exemption for the inline tag, Theme tab shell + `theme.js` editor, CSS-file theming fully retired.
- No defects found; zero changes made — working tree left exactly as the task executors left it.
- Tests: `uv run pytest --cov=app` → 1841 passed, 0 failed (TOTAL coverage **99%**; theming/ui_settings/caching all 100%); `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** in isolation.
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings.
- Criteria: (1) unset deployment byte-identical, no `#bor-theme` anywhere — ✓ (unit no-op test + E2E reset byte-compare); `rg "BOR_THEME|themes/"` → single hit is the permitted doc-history comment in `frontend/index.html`. (2) admin-only gate + 403s for anonymous and token users — ✓ (E2E test 3). (3) saved theme inline before `</head>` on every page incl. `/shared/<token>`, computed `--brand` on first paint for admin + anonymous — ✓ (E2E test 2 + unit). (4) reset → byte-identical; 5 contrast pairs warn <4.5:1, non-blocking — ✓ (E2E tests 4–5). (5) suite green, >90% coverage, lint clean — ✓. (6) commit deferred to harness per rules.
- Notable: `.agents/PLAN.md` is absent from the repo — the phase overview's Design section was used as the binding spec; no deviation resulted.
- Next pending phase: **none** — 91 is the last phase in `todo/`.
2026-09-09 17:22:24 -04:00

482 lines
24 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.
* 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=<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.
*
* 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/<token> 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 <body>, 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 =
'<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;
}
}