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
@@ -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
that frame's layout). A pre-settle throw leaves the cluster static
— a degraded boot is already degraded (the gate/header above it),
and the hamburger binding lives in header.js's module body, so it
is unaffected either way (documented in the house comment, do not
"fix")."""
and the hamburger binding lives in header.js's explicit init
(bindSharedHeaderControls), so it is unaffected either way
(documented in the house comment, do not "fix")."""
js = _app_js()
assert js.count("chat-booted") == 1, (
f"app.js must reference chat-booted EXACTLY once — the single "