All completion criteria verified green. Final state confirmed: phase stays in `todo/` per A4 (owner device gate), task files in `complete/`, no `app/` changes, no commits made (harness commits). ## Phase 88 — final verification pass report **Verified (all 6 tasks already complete):** `touch-action: manipulation` on mobile `#nav-toggle`; `#view-chat.chat-booted` double-rAF sticky handover (CSS gate + app.js boot IIFE); `body.nav-menu-open` marker in `setNavMenu` + ≤640px `visibility: hidden` rule; router boot contract (`wasMounted && !opts.boot` + `boot: true`); new unit module (10 pins) + 4-test real-touch E2E story. **Test / lint / coverage:** - `uv run pytest --cov=app --cov-report=term-missing` → 1741 passed, **99%** on `app/` (>90% ✓) - `tests/e2e/test_mobile_chat_hamburger_boot.py` 4/4, `test_mobile_hamburger_nav.py` 8/8, `test_pinned_composer.py` 4/4, `test_smoke.py` 3/3 — each in isolation, `--no-cov` ✓ - `uv run ruff check .` clean; `uv run pyright` 0 errors ✓ **Completion criteria:** (1) 360px cold-boot touch-action + sticky handover + tap-opens-menu-with-cluster-hidden (E2E 1–2) ✓; (2) boot fires no `bor:view-refresh`, re-show fires exactly one, `/sources.html` regression (E2E 3–4) ✓; (3) full gate green ✓; (4) diff limited to 4 assets + 2 new tests + phase files, 0 changes in `app/` ✓; (5) commit deferred to harness per executor rules ✓; (6) owner device re-verification **pending** (A4 — gates the archive; no Owner report recorded yet). **Deviations (both documented in-tree):** unit pins updated in `test_frontend_router.py`/`test_hamburger_nav.py` (their exact-text pins collided with the mandated new guard/marker text — without them the suite goes red); `boot: true` count pinned at 1 not 2 (codebase has one boot call site, no `history.state` branch — verified against git HEAD). **Next pending phase:** none in `todo/` — pipeline awaits the owner's on-device report (archive, or `?dbg=nav` instrumentation follow-up if the menu is still dead).
290 lines
14 KiB
JavaScript
290 lines
14 KiB
JavaScript
/* 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=<id> 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.
|
|
* • refresh hook (phase 77): a user-initiated re-show of an
|
|
* already-mounted view dispatches the `bor:view-refresh` CustomEvent
|
|
* on the view's <section> root — exactly when the view is shown
|
|
* AGAIN: a switch back onto it, a re-click of its own (active) nav
|
|
* link (NO pushState — the URL is already its path), or back/
|
|
* forward (popstate) onto it. The FIRST show (the mount) and boot
|
|
* NEVER fire it — the mount's own load is the first fetch. A view
|
|
* module opts in by listening on its own root inside mount();
|
|
* views that do not listen (Chat — app.js) are unaffected: the
|
|
* in-flight stream and the local conversation survive (the phase-76
|
|
* LOCKED refinement).
|
|
* • 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
|
|
* <meta name="description"> (values carried over from the old
|
|
* pages' <head>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;
|
|
* ./tokens.js in phase 79 task 06) 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 <section class="view" id="view-<name>"> 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)
|
|
"/tokens.html": "tokens", // phase 79 task 06: the Tokens view (access tokens)
|
|
};
|
|
|
|
/* 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",
|
|
tokens: "/tokens.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
|
|
tokens: () => import("./tokens.js"), // phase 79 task 06
|
|
};
|
|
|
|
/* Per-view document.head values, carried over from the old pages'
|
|
<head>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 <title>
|
|
"git-sources": "Git sources · Brain of Reese", // old git-sources.html <title>
|
|
history: "Saved chats · Brain of Reese", // old history.html <title>
|
|
tokens: "Access tokens · Brain of Reese",
|
|
};
|
|
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
|
|
tokens: "Generate and revoke the API tokens that let people use the app.",
|
|
};
|
|
|
|
/* 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 re-mount; the view's
|
|
state persists). A listening view re-fetches on a re-show through the
|
|
phase-77 refresh hook (bor:view-refresh), not a re-mount. 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, opts = {}) {
|
|
const root = viewEls[name];
|
|
if (!root) return;
|
|
|
|
/* Phase 77: capture the mount state BEFORE the mount block — a
|
|
re-show of an already-mounted view dispatches bor:view-refresh
|
|
(further down); the first show (the mount) and boot never do (the
|
|
mount's own load is the first fetch). */
|
|
const wasMounted = mounted[name];
|
|
|
|
/* 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;
|
|
|
|
/* Phase 77: the re-show refresh — the view is already visible and
|
|
the head/nav state is written (event order: visible → refresh),
|
|
the focus/scroll tail runs after. Gated on the pre-mount capture:
|
|
a first show (the mount's own load is the first fetch) and boot
|
|
never dispatch. A listening view re-runs its load; the chat view
|
|
never listens (phase-76 stream survival). */
|
|
/* Phase 88: the BOOT show is the view's first display — the phase-77
|
|
contract says the first show (mount) and boot never fire the
|
|
refresh. `mounted.chat` starts true (app.js pre-mounts the chat),
|
|
so the cold boot's switchTo("chat") was hitting the wasMounted
|
|
branch; the explicit boot flag (the boot call site below) makes
|
|
the contract hold. Every LATER show — re-click on the active
|
|
link, popstate, any navigation — still fires exactly as before. */
|
|
if (wasMounted && !opts.boot) {
|
|
root.dispatchEvent(new CustomEvent("bor:view-refresh"));
|
|
}
|
|
|
|
/* 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 (opts.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];
|
|
/* Phase 77: a re-click of the ACTIVE view's own link is a
|
|
re-fetch, not a no-op — the same refresh event the re-show
|
|
dispatches. No pushState (the URL is already this view's path);
|
|
the mobile menu still closes (the container handler runs
|
|
regardless). */
|
|
if (name === current) {
|
|
viewEls[name].dispatchEvent(new CustomEvent("bor:view-refresh"));
|
|
return;
|
|
}
|
|
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, boot: true });
|