fix(ui): bind the shared header controls on explicit init, not at module import
Build and Push Containers / build-and-push-app (push) Successful in 1m53s
Build and Push Containers / build-and-push-db (push) Successful in 11s

header.js's control bindings (sign-out, the mobile hamburger, the
SINGLE New chat button) ran at module import. The Containerfile stage-1
build inlines header.js into every bundle that imports it (the shell's
app.js, token-gate.js and the router's lazy views), so the shell page
registered the #nav-toggle click handler twice, and two toggle handlers
cancel each other — one tap = open + close = the mobile menu dead in
the deployed image only. The dev tree's single ESM instance (and every
test that runs against it) never showed it; a lazy view load adding a
THIRD copy made the menu work again, which is why the failure looked
state-dependent (chat cold boot dead, /sources.html alive).

- header.js: the three bindings move into an exported
  bindSharedHeaderControls(), guarded by a marker on <body> (NOT module
  state — every bundle copy has its own function instance), so later
  bundle copies and repeated inits (the token gate's mid-page header
  re-boot) are no-ops; header.js is now side-effect-free at top level,
  which also lets esbuild tree-shake the dead copies out of the bundles
  that do not need them (the token-gate bundle no longer carries the
  binding code at all)
- app.js / login.js / shared.js / document.js: call
  bindSharedHeaderControls() once at module top — import-time parity,
  unconditional (no async boot path to miss); doc-edit.js ships no
  header controls and calls nothing
- unit: tests/unit/test_header_bindings_once_per_document.py pins the
  contract — the init export, the document-level idempotency marker,
  all three bindings inside the init, NO top-level addEventListener
  remaining, and exactly one module-top call in each header-carrying
  page script; stale import-time docstrings in the legacy header pins
  updated to the new contract

Verified: full unit + integration suite (1746 passed), the hamburger /
pinned-composer / smoke E2E stories green in isolation, ruff + pyright
clean. Containerfile-equivalent esbuild 0.25.5 rebuild probed in
Chromium: exactly ONE #nav-toggle click listener on chat cold boot,
/sources.html and login.html, and a touch tap opens the menu in all
three states (pre-fix production: two listeners on cold boot = dead,
three on sources = alive).
This commit is contained in:
2026-09-08 22:31:45 -04:00
parent 4d287155c0
commit 1f0e4c6bb9
10 changed files with 379 additions and 110 deletions
+9
View File
@@ -265,6 +265,7 @@
*/ */
import { import {
bindSharedHeaderControls,
fetchWhoami, fetchWhoami,
initSharedHeader, initSharedHeader,
refreshSteering, refreshSteering,
@@ -273,6 +274,14 @@ import {
import { openDocumentModal } from "./document-modal.js"; // phase 26: chips open the same-page modal import { openDocumentModal } from "./document-modal.js"; // phase 26: chips open the same-page modal
import { mountGate } from "./token-gate.js"; // phase 79 (task 05): the in-app token gate import { mountGate } from "./token-gate.js"; // phase 79 (task 05): the in-app token gate
// The shared header's control bindings (sign-out / mobile hamburger /
// New chat) — EXPLICIT init, once per document. header.js is inlined
// into every bundle that imports it (Containerfile stage 1), so the
// bindings must not run at module import — the body marker in
// header.js makes any further copy a no-op (2026-09-08
// double-binding production fix).
bindSharedHeaderControls();
/* Phase 77 (task 02): the bor:view-refresh exclusion is deliberate — the in-flight SSE stream and the local conversation must survive every switch (phase 76), so the chat view never listens and never re-fetches on a show. */ /* Phase 77 (task 02): the bor:view-refresh exclusion is deliberate — the in-flight SSE stream and the local conversation must survive every switch (phase 76), so the chat view never listens and never re-fetches on a show. */
const messagesEl = document.querySelector("#messages"); const messagesEl = document.querySelector("#messages");
const emptyState = document.querySelector("#empty-state"); const emptyState = document.querySelector("#empty-state");
+6 -1
View File
@@ -68,9 +68,14 @@
* network call) until the admin gate resolves true. * network call) until the admin gate resolves true.
*/ */
import { fetchIsAdmin, initSharedHeader } from "./header.js"; import { bindSharedHeaderControls, fetchIsAdmin, initSharedHeader } from "./header.js";
import { mountGate } from "./token-gate.js"; // phase 79 (task 05): the inline token gate import { mountGate } from "./token-gate.js"; // phase 79 (task 05): the inline token gate
// The shared header's control bindings (sign-out / mobile hamburger) —
// EXPLICIT init, once per document (header.js is bundle-inlined per
// entry; import-time side effects would double-bind — 2026-09-08 fix).
bindSharedHeaderControls();
/* Phase 39: the page title's display name — window.BOR_BRAND (set at /* Phase 39: the page title's display name — window.BOR_BRAND (set at
* parse time by the classic assets/brand.js, refreshed from * parse time by the classic assets/brand.js, refreshed from
* /api/config). This is a module, so the global is set by the time this * /api/config). This is a module, so the global is set by the time this
+135 -89
View File
@@ -72,6 +72,16 @@
* five pages (the login page included), so every control resolves on * five pages (the login page included), so every control resolves on
* every page; a page that lacks one simply skips it. * 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 * 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 * the module-level `whoamiPromise`, so app.js's tuning gate, the sources
* page's catalog gate, and the header toggling all share one request. * 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 /* ---------- shared header control bindings (explicit init, once per
import, so every page that loads header.js gets it exactly once. * document) ----------
Binds to all .sign-out-btn elements (bar copy for desktop + mobile *
dropdown copy for ≤640px). Disable during the call, POST /api/logout * The three shared-header control bindings — sign-out (phase 16), the
(the result is ignored — the reload resets the UI either way), drop * mobile hamburger (phase 46), the SINGLE New chat button (phase 34
the cached token (phase 79: one logout clears BOTH the server * task 02) — live here, and they run on EXPLICIT init, never at module
session and the localStorage key — a signing-out token user meets * import. The import-time binding was correct under native ESM (the
the gate again), then reload so the header re-resolves to the * browser's module cache makes this file ONE instance per document) but
anonymous state (Sign in back, Sources gone, the gate back for the * wrong under the Containerfile stage-1 build: esbuild inlines this
not-yet-token holder). */ * module into every bundle that imports it (the shell's app.js,
document.querySelectorAll(".sign-out-btn").forEach(btn => { * token-gate.js and the router's lazy views each carry a copy), and
btn.addEventListener("click", async () => { * top-level code runs once per copy — the shell page registered the
btn.disabled = true; * #nav-toggle click handler twice (the app.js + token-gate.js bundles),
try { * and two toggle handlers cancel each other: one tap = open + close =
await fetch("/api/logout", { method: "POST" }); * a menu that is dead in the deployed image only (production diagnosis
} catch { * 2026-09-08). The dev tree's single ESM instance — and every test
/* the reload resets the UI either way */ * 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
try { * is why the failure looked state-dependent (chat cold boot dead,
localStorage.removeItem("bor.token"); * /sources.html alive).
} catch { *
/* private mode / storage error — the server logout already signed * The four page scripts that ship header controls (app.js / login.js /
out; the next load re-gates either way */ * shared.js / document.js) therefore call bindSharedHeaderControls()
} * ONCE at module top — import-time parity, unconditional (no async
window.location.reload(); * 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) ---------- /* Sign-out binding (phase 16 behavior, module-owned): binds to all
* ≤640px only (CSS hides the button elsewhere): #nav-toggle opens the .sign-out-btn elements (bar copy for desktop + mobile dropdown
* nav as a dropdown (#app-nav .is-open — the animated state, task 01 copy for ≤640px, phase 46) so both copies log out. Disable during
* CSS). One binding for all six pages; a page without either element the call, POST /api/logout (the result is ignored — the reload
* is a no-op, like the rest of this module. The nav LINKS keep their resets the UI either way), drop the cached token (phase 79: one
* ship-hidden whoami contract (hidden links stay hidden inside the logout clears BOTH the server session and the localStorage key —
* menu) — this binding only toggles the container. */ a signing-out token user meets the gate again on the next load),
const navToggle = document.querySelector("#nav-toggle"); then reload so the header re-resolves to the anonymous state
const appNav = document.querySelector("#app-nav"); (Sign in back, Sources gone, the gate back for the
not-yet-token holder). */
function setNavMenu(open) { document.querySelectorAll(".sign-out-btn").forEach(btn => {
if (!appNav || !navToggle) return; btn.addEventListener("click", async () => {
appNav.classList.toggle("is-open", open); btn.disabled = true;
navToggle.setAttribute("aria-expanded", open ? "true" : "false"); try {
// Phase 88: body-level marker — while the mobile menu is open, the await fetch("/api/logout", { method: "POST" });
// chat's sticky bottom cluster is hidden (styles.css, ≤640px block): } catch {
// it must not compete for taps with the open menu, and on short /* the reload resets the UI either way */
// viewports it overlaps the menu's lower rows. Every close path }
// (Esc / outside-click / media) funnels through setNavMenu, so the try {
// marker can never stick. localStorage.removeItem("bor.token");
document.body.classList.toggle("nav-menu-open", open); } catch {
} /* private mode / storage error — the server logout already
signed out; the next load re-gates either way */
if (navToggle && appNav) { }
navToggle.addEventListener("click", () => window.location.reload();
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). /* ---------- mobile hamburger (phase 46; module-owned) ----------
document.addEventListener("keydown", (e) => { * ≤640px only (CSS hides the button elsewhere): #nav-toggle opens
if (e.key === "Escape" && appNav.classList.contains("is-open")) { * the nav as a dropdown (#app-nav .is-open — the animated state,
setNavMenu(false); * task 01 CSS). One binding for all six pages; a page without either
navToggle.focus(); // focus returns to the opener * 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. */
// Resize back to desktop: the inline nav reappears — no stale open const navToggle = document.querySelector("#nav-toggle");
// state (the .is-open class is scoped by the ≤640px CSS anyway, but const appNav = document.querySelector("#app-nav");
// dropping it keeps aria-expanded honest).
const mq = window.matchMedia("(max-width: 640px)"); function setNavMenu(open) {
const onMqChange = () => { if (!mq.matches) setNavMenu(false); }; if (!appNav || !navToggle) return;
if (mq.addEventListener) mq.addEventListener("change", onMqChange); appNav.classList.toggle("is-open", open);
else mq.addListener(onMqChange); // older engines, defensive 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) ---------- /* ---------- steering notes (phase 15; module-owned from phase 34) ----------
@@ -408,21 +472,3 @@ async function deleteSteeringNote(id, btn) {
btn.disabled = false; 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"));
});
}
+6 -1
View File
@@ -18,7 +18,12 @@
* All DOM ids match frontend/login.html. * All DOM ids match frontend/login.html.
*/ */
import { fetchIsAdmin, initSharedHeader } from "./header.js"; import { bindSharedHeaderControls, fetchIsAdmin, initSharedHeader } from "./header.js";
// The shared header's control bindings (sign-out / mobile hamburger) —
// EXPLICIT init, once per document (header.js is bundle-inlined per
// entry; import-time side effects would double-bind — 2026-09-08 fix).
bindSharedHeaderControls();
const form = document.querySelector("#login-form"); const form = document.querySelector("#login-form");
const passwordInput = document.querySelector("#login-password"); const passwordInput = document.querySelector("#login-password");
+6 -1
View File
@@ -45,7 +45,12 @@
* All DOM ids match frontend/shared.html. * All DOM ids match frontend/shared.html.
*/ */
import { initSharedHeader } from "./header.js"; import { bindSharedHeaderControls, initSharedHeader } from "./header.js";
// The shared header's control bindings (sign-out / mobile hamburger) —
// EXPLICIT init, once per document (header.js is bundle-inlined per
// entry; import-time side effects would double-bind — 2026-09-08 fix).
bindSharedHeaderControls();
const titleEl = document.querySelector("#shared-title"); const titleEl = document.querySelector("#shared-title");
const noteEl = document.querySelector(".shared-note"); const noteEl = document.querySelector(".shared-note");
+8 -5
View File
@@ -21,8 +21,10 @@ This module pins the source-level contract for tasks 01 and 02:
block still squeezes the inline nav at 641–900px, and the action pills' block still squeezes the inline nav at 641–900px, and the action pills'
squeeze rules + the 58px bar height are untouched); squeeze rules + the 58px bar height are untouched);
* the header.js toggle behavior (task 02) is ONE module-owned binding — * the header.js toggle behavior (task 02) is ONE module-owned binding —
the same import-time pattern as the sign-out/steering bindings: null- the same explicit-init pattern as the sign-out binding (header.js's
safe lookups of ``#nav-toggle`` + ``#app-nav``, a ``setNavMenu`` that ``bindSharedHeaderControls`` — 2026-09-08: import-time binding ran
once per bundle copy under the Containerfile build): null-safe
lookups of ``#nav-toggle`` + ``#app-nav``, a ``setNavMenu`` that
syncs BOTH the ``.is-open`` class and ``aria-expanded``, a click syncs BOTH the ``.is-open`` class and ``aria-expanded``, a click
toggle, a delegated nav-link close, an Esc close that refocuses the toggle, a delegated nav-link close, an Esc close that refocuses the
opener, and a matchMedia resize-back-to-desktop close; the binding opener, and a matchMedia resize-back-to-desktop close; the binding
@@ -418,9 +420,10 @@ def _js_clean(code: str) -> str:
def test_header_js_looks_up_toggle_and_nav_null_safe() -> None: def test_header_js_looks_up_toggle_and_nav_null_safe() -> None:
"""The binding follows the module's import-time pattern (like the """The binding follows the module's explicit-init pattern (like the
sign-out binding): look up #nav-toggle and #app-nav with sign-out binding, inside bindSharedHeaderControls): look up
querySelector at import, and guard the whole binding block behind #nav-toggle and #app-nav with querySelector at init, and guard the
whole binding block behind
``navToggle && appNav`` — a page lacking either element is a ``navToggle && appNav`` — a page lacking either element is a
complete no-op.""" complete no-op."""
js = _js() js = _js()
@@ -0,0 +1,187 @@
"""Unit: the shared-header control bindings are EXPLICIT init, once per
document (2026-09-08 production double-binding fix).
The bug: header.js's control bindings (sign-out, the mobile hamburger,
the SINGLE New chat button) used to run at MODULE IMPORT. Under native
ESM that is one instance per document — fine. But the Containerfile
stage-1 build inlines header.js 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 dead in the deployed image only. The dev tree (raw ESM,
single instance) never showed it, and neither did any test that runs
against the dev tree; 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 contract pinned here:
* header.js owns the three bindings in ONE exported function —
``bindSharedHeaderControls()`` — whose 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");
* NO top-level ``addEventListener`` remains in header.js — the one
surviving listener outside the init is the steering per-note delete
inside ``renderSteeringPanel`` (a per-node callback factory, not a
module binding);
* the four page scripts that ship header controls (app.js / login.js /
shared.js / document.js) each call ``bindSharedHeaderControls()``
exactly once as a module-top statement; doc-edit.js ships no header
controls and calls nothing.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
HEADER_JS = ASSETS / "header.js"
#: The call as a module-top statement (anchored — comment mentions of
#: the function can never match).
_CALL = re.compile(r"^\s*bindSharedHeaderControls\(\);\s*$", re.M)
_IMPORT = re.compile(
r"import\s*\{[^}]*bindSharedHeaderControls[^}]*\}\s*from\s*\"./header\.js\"",
re.S,
)
def _text(path: Path) -> str:
assert path.is_file(), f"missing frontend file: {path.name}"
return path.read_text(encoding="utf-8")
def _js() -> str:
return _text(HEADER_JS)
def _strip_comments(code: str) -> str:
"""Block + line comments stripped (comments may legally carry the
keywords the pins below count). header.js carries no string
literal containing ``//`` or braces, so the strip is exact."""
code = re.sub(r"/\*.*?\*/", "", code, flags=re.S)
return re.sub(r"//[^\n]*", "", code)
def _function_body(code: str, signature: str) -> str:
"""The full text from ``signature`` to its brace-matched closing
brace (neither body carries an unbalanced brace inside a string, so
the count is exact)."""
start = code.index(signature)
i = code.index("{", start)
depth = 0
for j in range(i, len(code)):
if code[j] == "{":
depth += 1
elif code[j] == "}":
depth -= 1
if depth == 0:
return code[start : j + 1]
raise AssertionError(f"unbalanced braces after {signature!r}")
# ---------- the init function: one entry point, document-level guard ----------
def test_header_exports_the_explicit_binding_init() -> None:
js = _js()
assert "export function bindSharedHeaderControls" in js, (
"header.js must export bindSharedHeaderControls — the single "
"entry point for the shared-header control bindings"
)
def test_the_idempotency_marker_lives_on_the_document() -> None:
"""The guard key is read once + written once, on document.body —
NOT in module state (every bundle copy has its own function
instance; only the document can say the first caller won)."""
js = _strip_comments(_js())
assert js.count("borHeaderBound") == 2, (
f"exactly one read (the guard) + one write (the marker) of the "
f"body dataset key (found {js.count('borHeaderBound')})"
)
assert "document.body" in js, "the marker lives on the document body"
assert 'body.dataset.borHeaderBound = "1"' in js, (
"the marker must be WRITTEN by the first caller"
)
# ---------- all three bindings live inside the init ----------
def test_all_three_control_bindings_live_inside_the_init() -> None:
js = _strip_comments(_js())
body = _function_body(js, "export function bindSharedHeaderControls")
for needle in (
# sign-out (bar copy for desktop + mobile dropdown copy)
'querySelectorAll(".sign-out-btn")',
'fetch("/api/logout", { method: "POST" })',
# the mobile hamburger (phase 46)
'querySelector("#nav-toggle")',
'querySelector("#app-nav")',
"function setNavMenu(open)",
'navToggle.addEventListener("click"',
# the SINGLE New chat binding (phase 34 task 02)
'querySelector("#new-chat-btn")',
'new CustomEvent("bor:new-chat")',
):
assert needle in body, (
f"{needle!r} must live INSIDE bindSharedHeaderControls — "
"a binding outside the init runs at module import in every "
"bundle copy that inlines header.js"
)
# ---------- the anti-regression pin: no top-level listener remains ----------
def test_no_top_level_add_event_listener_remains() -> None:
"""The pin for the double-binding class itself: NOTHING outside
bindSharedHeaderControls may register a listener at module scope —
the Containerfile inlines header.js into every bundle that imports
it, and top-level binding code runs once per bundle copy (two
#nav-toggle toggle handlers cancel each other = the dead production
menu). The ONE surviving addEventListener outside the init is the
steering per-note delete INSIDE renderSteeringPanel — a per-node
callback factory (one listener per note button created in the DOM),
not a module binding."""
js = _strip_comments(_js())
init_body = _function_body(js, "export function bindSharedHeaderControls")
cut = js.index(init_body)
rest = js[:cut] + js[cut + len(init_body) :]
assert rest.count("addEventListener") == 1, (
"exactly one addEventListener may survive outside the init — "
f"the steering per-note delete (found {rest.count('addEventListener')})"
)
panel = _function_body(rest, "function renderSteeringPanel")
assert "addEventListener" in panel, (
"the surviving listener must be the steering per-note delete "
"(renderSteeringPanel)"
)
# ---------- the page scripts: one explicit call each, at module top ----------
def test_each_header_carrying_page_script_calls_the_init_exactly_once() -> None:
for name in ("app.js", "login.js", "shared.js", "document.js"):
js = _text(ASSETS / name)
assert _IMPORT.search(js), (
f"{name}: must import bindSharedHeaderControls from "
'"./header.js" (relative — the bundler contract)'
)
calls = _CALL.findall(js)
assert len(calls) == 1, (
f"{name}: must CALL bindSharedHeaderControls() exactly once "
f"as a module-top statement (found {len(calls)})"
)
# doc-edit.js ships NO header controls (no nav / auth pair / steering
# panel — its own header comment says the page carries none), so it
# calls nothing: there is nothing to bind.
doc_edit = _text(ASSETS / "doc-edit.js")
assert "bindSharedHeaderControls" not in doc_edit, (
"doc-edit.js ships no header controls — no binding init call"
)
@@ -247,9 +247,9 @@ def test_chat_booted_flag_lands_exactly_once_after_boot_settles() -> None:
layout commit for the restore case (the rAF callback runs before layout commit for the restore case (the rAF callback runs before
that frame's layout). A pre-settle throw leaves the cluster static that frame's layout). A pre-settle throw leaves the cluster static
— a degraded boot is already degraded (the gate/header above it), — a degraded boot is already degraded (the gate/header above it),
and the hamburger binding lives in header.js's module body, so it and the hamburger binding lives in header.js's explicit init
is unaffected either way (documented in the house comment, do not (bindSharedHeaderControls), so it is unaffected either way
"fix").""" (documented in the house comment, do not "fix")."""
js = _app_js() js = _app_js()
assert js.count("chat-booted") == 1, ( assert js.count("chat-booted") == 1, (
f"app.js must reference chat-booted EXACTLY once — the single " f"app.js must reference chat-booted EXACTLY once — the single "
+13 -8
View File
@@ -148,8 +148,11 @@ def test_clear_chat_storage_removes_the_phase14_key_silently() -> None:
def test_sign_out_binding_lives_in_the_shared_module() -> None: def test_sign_out_binding_lives_in_the_shared_module() -> None:
"""The sign-out click binding (disable → POST /api/logout → reload) """The sign-out click binding (disable → POST /api/logout → reload)
is owned by header.js at module import — exactly one implementation is owned by header.js — bound once per document via the explicit
for every page that loads it. It binds to ALL .sign-out-btn bindSharedHeaderControls() init (NOT a module-import side effect:
the Containerfile inlines header.js into every bundle that imports
it, and import-time binding ran once per copy). It binds to ALL
.sign-out-btn
elements (the bar copy for desktop + the mobile dropdown copy for elements (the bar copy for desktop + the mobile dropdown copy for
≤640px, phase 46), so both copies log out.""" ≤640px, phase 46), so both copies log out."""
js = _text(HEADER_JS) js = _text(HEADER_JS)
@@ -373,10 +376,11 @@ def test_header_module_loads_before_the_page_script() -> None:
header.js with a direct <script> tag anymore. Each page script header.js with a direct <script> tag anymore. Each page script
imports it relatively (`from "./header.js"`) — a hoisted import that imports it relatively (`from "./header.js"`) — a hoisted import that
the browser evaluates BEFORE the page script body runs, and that the the browser evaluates BEFORE the page script body runs, and that the
image bundler inlines into the page bundle. The sign-out binding and image bundler inlines into the page bundle. The whoami cache
the whoami cache therefore exist when the page script boots, and therefore exists when the page script boots, and header.js can
header.js can never be evaluated twice on a page (a tag + import pair never be evaluated twice on a page (the single-evaluation design is
would double-bind the sign-out listener).""" the contract; a tag + import pair is now additionally harmless —
the binding init is idempotent via the body marker)."""
cases = [ cases = [
(INDEX_HTML, "app.js"), (INDEX_HTML, "app.js"),
(DOCUMENT_HTML, "document.js"), (DOCUMENT_HTML, "document.js"),
@@ -490,8 +494,9 @@ def test_login_js_uses_the_shared_fetch_is_admin() -> None:
def test_new_chat_binding_is_single_and_module_owned() -> None: def test_new_chat_binding_is_single_and_module_owned() -> None:
"""Phase 34 task 02 + owner rework (2026-08-28): header.js owns the """Phase 34 task 02 + owner rework (2026-08-28): header.js owns the
SINGLE #new-chat-btn binding (module import, like the sign-out SINGLE #new-chat-btn binding (explicit init via
binding). The button now lives ONLY on the chat page (inside bindSharedHeaderControls, like the sign-out binding). The button
now lives ONLY on the chat page (inside
.chat-shell, above #messages — moved from the navbar at owner .chat-shell, above #messages — moved from the navbar at owner
request), so the click ALWAYS dispatches window "bor:new-chat" and request), so the click ALWAYS dispatches window "bor:new-chat" and
app.js acts through its own in-flight-turn guard + list reset: no app.js acts through its own in-flight-turn guard + list reset: no
+6 -2
View File
@@ -244,8 +244,12 @@ def test_boot_order_header_then_public_read() -> None:
assert "showInvalid()" in branch assert "showInvalid()" in branch
assert "renderSharedChat" not in branch, "no render on a null read" assert "renderSharedChat" not in branch, "no render on a null read"
# The relative import of the shared header module (no absolute # The relative import of the shared header module (no absolute
# /assets/ import — the esbuild bundle contract). # /assets/ import — the esbuild bundle contract). The explicit
assert 'import { initSharedHeader } from "./header.js";' in js # binding init rides the same import (2026-09-08 double-binding fix).
assert (
'import { bindSharedHeaderControls, initSharedHeader } from "./header.js";'
in js
)
assert '"/assets/header.js"' not in js assert '"/assets/header.js"' not in js
# No cross-page import (the per-page duplication house style). # No cross-page import (the per-page duplication house style).
import_lines = [ import_lines = [