/* Brain of Reese — shell router (phase 76, task 01). * * The five navbar views are views of ONE HTML shell (index.html), not * five documents: this module makes a navbar click a CLIENT-SIDE view * switch — history.pushState + show/hide — never a document load, so * the in-flight chat stream in the hidden view keeps streaming * through any switch and completes when the user returns to Chat. * Real departures (tab close, leaving the app, the Stop button) still * cancel the fetch and stop the model — the phase-48 contract, owned * by app.js and untouched here. The phase-48 LOCKED refinement * (owner-confirmed 2026-09-06): "real navigation cancels the fetch" * now means LEAVING THE APP — in-app navbar switches no longer cancel. * * The contract (pinned at source level in * tests/unit/test_frontend_router.py): * * • VIEW — the pathname → view name map for the folded views * ("/" → chat, "/index.html" → chat — the shell's own two URLs, * "/tuning.html" → tuning, "/sources.html" → rag, "/git-sources.html" * → git-sources, "/history.html" → history). Only a link whose href * is IN this map is intercepted; every other link (login, document * viewer, a /?chat= deep link — its query string keeps it out * of the map) still performs its real, document-level navigation. * • boot from location.pathname: the matching view is shown WITHOUT * focus (no focus steal on load) — a direct load of /tuning.html * deep-links to the Tuning view (the shell route in app/main.py * serves this shell for that path). * • mount-once, hide-forever: a non-chat view's module is * lazy-imported on FIRST show only, and `await module.mount(root)` * runs once (the `mounted` guard) — the view's DOM and JS state * (for chat, the in-flight SSE reader; for the Sources view, the * upload-progress poller) persist across every switch; that * persistence IS the phase-76 fix. The chat view needs no module: * app.js already ran at shell boot. * • show = drop hidden + inert, hide = add BOTH (WCAG: a hidden view * must not receive focus or keyboard traversal — the inert pair * pins the [hidden] contract in the a11y tree, AGENTS.md rule 5). * • SINGLE WRITER of the .nav-link active state (is-active + * aria-current="page"), of document.title, and of the per-view * (values carried over from the old * pages' s) — no page script stamps any of these. * • focus the target view (its tabindex="-1") ONLY on * user-initiated switches (navbar click / popstate back-forward); * a switch also lands the viewport at the top of the document, * the same way the old per-view page loads did (user intent — the * no-reply-autoscroll contract is about streaming frames, not * navigation the user performs). * * Boot order (index.html): brand.js (classic) → app.js (module — the * chat view, runs at shell boot exactly as before) → router.js * (module — this file). No CDN, no framework, no bundler dependency: * a plain ES module whose dynamic imports (./tuning.js, task 01; * ./sources.js + ./git-sources.js, task 02; ./history.js in task 03) * resolve relatively in dev and are inlined by the Containerfile's * esbuild stage in the image. */ /* ---------- the view map (pathname → view name) ---------- * The shell's own two URLs are the chat view (the shell IS the chat * page — app.js boots it); every folded view adds one entry. The * values are the
slugs in * index.html. */ const VIEW = { "/": "chat", "/index.html": "chat", // the shell's alternate URL (HTML_PAGES) "/tuning.html": "tuning", // phase 76 task 01: the first folded view "/sources.html": "rag", // phase 76 task 02: the RAG view (knowledge base) "/git-sources.html": "git-sources", // phase 76 task 02: the Sources view "/history.html": "history", // phase 76 task 03: the History view (saved chats) }; /* The nav-link href the router stamps active for each view (the Chat link is href="/", the RAG link href="/sources.html", …). */ const VIEW_PATH = { chat: "/", tuning: "/tuning.html", rag: "/sources.html", "git-sources": "/git-sources.html", history: "/history.html", }; /* The lazy view modules — ONLY the non-chat views (chat needs no import: app.js already ran at shell boot). Static specifiers so the Containerfile's esbuild stage can inline each module into the router bundle (the browser still defers its code until the first import() — mount-once semantics are preserved in the image). */ const VIEW_MODULES = { tuning: () => import("./tuning.js"), rag: () => import("./sources.js"), // phase 76 task 02 "git-sources": () => import("./git-sources.js"), // phase 76 task 02 history: () => import("./history.js"), // phase 76 task 03 }; /* Per-view document.head values, carried over from the old pages' s (the router is the single writer of both). The values are the DEFAULT-deployment form: at write time they are composed through brandName() (below) so a configured deployment keeps its name. */ const TITLES = { chat: "Brain of Reese", tuning: "Global Tuning · Brain of Reese", rag: "Sources · Brain of Reese", // old sources.html "git-sources": "Git sources · Brain of Reese", // old git-sources.html <title> history: "Saved chats · Brain of Reese", // old history.html <title> }; const DESCRIPTIONS = { chat: "Ask anything about your indexed documents — every answer cites the exact doc.", tuning: "Manage the global tuning notes that steer every Brain of Reese answer.", rag: "Documents indexed in Brain of Reese.", // old sources.html meta "git-sources": "Add and remove the git repositories Brain of Reese syncs and indexes (admin-only).", history: "Saved chats — every conversation is saved automatically, one click back.", // old history.html meta }; /* The brand-resolved display name (phase 39 — brand.js is the single owner: window.BOR_BRAND is "Brain of Reese" from parse time and is updated once /api/config settles). The router composes the per-view title/meta from it instead of stamping the hardcoded literal: the lazy view import defers switchTo PAST brand.js's one-time DOMContentLoaded pass, so a literal stamp would overwrite a configured deployment's name (e.g. "Brain of Testy") in the client-side head. Composing at write time keeps the name correct for every config/switch ordering (an unset deployment — the name IS the literal — stays byte-identical: replaceAll is a no-op). */ const brandName = () => window.BOR_BRAND || "Brain of Reese"; const titleFor = (view) => TITLES[view].replaceAll("Brain of Reese", brandName()); const descFor = (view) => DESCRIPTIONS[view].replaceAll("Brain of Reese", brandName()); /* The view sections — one per view name (index.html: #view-chat is visible at boot, the folded views ship hidden + inert). */ const viewEls = {}; for (const name of new Set(Object.values(VIEW))) { viewEls[name] = document.getElementById(`view-${name}`); } /* The mount-once guard: a view is imported + mounted at most ONCE per document life — re-shows are show/hide only (no refetch, no re-mount; the view's state persists). Chat starts mounted: app.js owns it and ran at shell boot. */ const mounted = { chat: true }; const nav = document.getElementById("app-nav"); const metaDesc = document.querySelector('meta[name="description"]'); let current = null; // the visible view name (null until boot resolves) /* ---------- show / hide (the single writer of the view state) ---------- */ /* Show `name`, hide every other view, and write the single-writer head/nav state. `userInitiated` marks navbar-click / popstate switches: only those focus the target view (its tabindex="-1") and land the viewport at the top — a boot switch never steals focus. */ async function switchTo(name, { userInitiated }) { const root = viewEls[name]; if (!root) return; /* Mount-once: the lazy module is imported on FIRST show only, then mounted into the view's section. The guard runs BEFORE the import (a re-show never re-imports) and is set only after mount resolves (a failed mount may retry on the next show). */ if (!mounted[name]) { const load = VIEW_MODULES[name]; if (load) { const mod = await load(); await mod.mount(root); } mounted[name] = true; } /* Show = drop hidden AND inert; hide = add BOTH (a hidden view must not receive focus or keyboard traversal — the inert pair makes the [hidden] contract hold in the a11y tree, not just the layout). */ for (const [viewName, el] of Object.entries(viewEls)) { el.hidden = viewName !== name; el.inert = viewName !== name; } /* SINGLE WRITER: the active nav link (is-active + aria-current), the document title, and the per-view meta description. */ const path = VIEW_PATH[name]; if (nav) { for (const a of nav.querySelectorAll("a.nav-link")) { const active = (a.getAttribute("href") || "") === path; a.classList.toggle("is-active", active); if (active) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current"); } } document.title = titleFor(name); if (metaDesc) metaDesc.content = descFor(name); current = name; /* Focus the target view ONLY on user-initiated switches (navbar click / popstate) — never on initial boot (no focus steal on load). The top landing mirrors what the old per-view page loads did (user intent, not a streaming-frame autoscroll). */ if (userInitiated) { window.scrollTo(0, 0); root.focus({ preventScroll: true }); } } /* ---------- navbar click: same-shell links become view switches ---------- * Delegated on the nav (covers the mobile dropdown too — it is the * same #app-nav element): a same-shell a.nav-link (href in VIEW) is * intercepted — preventDefault + history.pushState + switch, so the * click is a view switch, NEVER a document load. Every other link * (login, the document viewer, the not-yet-folded views in tasks * 02/03) keeps its real navigation untouched. */ if (nav) { nav.addEventListener("click", (e) => { const a = e.target instanceof Element ? e.target.closest("a.nav-link") : null; if (!a) return; const href = a.getAttribute("href") || ""; if (!(href in VIEW)) return; // not a same-shell view — real navigation e.preventDefault(); const name = VIEW[href]; if (name === current) return; // already visible (the menu still closes) history.pushState({ view: name }, "", href); switchTo(name, { userInitiated: true }); }); /* Back / forward: popstate switches views (the history entries were written by the pushState above — same-document, no page load). */ window.addEventListener("popstate", () => { const name = VIEW[window.location.pathname]; if (name && name !== current) switchTo(name, { userInitiated: true }); }); } /* ---------- boot: deep-link from the pathname, no focus steal ---------- */ /* A direct load of any shell path shows its view (chat for "/" and "/index.html", tuning for "/tuning.html"); an unexpected pathname falls back to chat (the shell's default view). userInitiated:false — boot never focuses (no focus steal on load). */ const bootName = VIEW[window.location.pathname] ?? "chat"; switchTo(bootName, { userInitiated: false });