|
|
|
@@ -72,6 +72,16 @@
|
|
|
|
|
* 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.
|
|
|
|
@@ -235,79 +245,133 @@ export function clearChatStorage() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 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), 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), 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();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
/* ---------- 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";
|
|
|
|
|
|
|
|
|
|
/* ---------- 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);
|
|
|
|
|
/* 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();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
// 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
|
|
|
|
|
|
|
|
|
|
/* ---------- 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) ----------
|
|
|
|
@@ -408,21 +472,3 @@ async function deleteSteeringNote(id, btn) {
|
|
|
|
|
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"));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|