fix(ui): bind the shared header controls on explicit init, not at module import
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:
@@ -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'
|
||||
squeeze rules + the 58px bar height are untouched);
|
||||
* the header.js toggle behavior (task 02) is ONE module-owned binding —
|
||||
the same import-time pattern as the sign-out/steering bindings: null-
|
||||
safe lookups of ``#nav-toggle`` + ``#app-nav``, a ``setNavMenu`` that
|
||||
the same explicit-init pattern as the sign-out binding (header.js's
|
||||
``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
|
||||
toggle, a delegated nav-link close, an Esc close that refocuses the
|
||||
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:
|
||||
"""The binding follows the module's import-time pattern (like the
|
||||
sign-out binding): look up #nav-toggle and #app-nav with
|
||||
querySelector at import, and guard the whole binding block behind
|
||||
"""The binding follows the module's explicit-init pattern (like the
|
||||
sign-out binding, inside bindSharedHeaderControls): look up
|
||||
#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
|
||||
complete no-op."""
|
||||
js = _js()
|
||||
|
||||
Reference in New Issue
Block a user