diff --git a/frontend/assets/app.js b/frontend/assets/app.js index da24bf1..cb9ab64 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -265,6 +265,7 @@ */ import { + bindSharedHeaderControls, fetchWhoami, initSharedHeader, refreshSteering, @@ -273,6 +274,14 @@ import { 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 +// 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. */ const messagesEl = document.querySelector("#messages"); const emptyState = document.querySelector("#empty-state"); diff --git a/frontend/assets/document.js b/frontend/assets/document.js index 872af9e..97a339d 100644 --- a/frontend/assets/document.js +++ b/frontend/assets/document.js @@ -68,9 +68,14 @@ * 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 +// 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 * 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 diff --git a/frontend/assets/header.js b/frontend/assets/header.js index b5cbc18..673cd19 100644 --- a/frontend/assets/header.js +++ b/frontend/assets/header.js @@ -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 , 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")); - }); -} - diff --git a/frontend/assets/login.js b/frontend/assets/login.js index a3f1a6f..1cec5e9 100644 --- a/frontend/assets/login.js +++ b/frontend/assets/login.js @@ -18,7 +18,12 @@ * 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 passwordInput = document.querySelector("#login-password"); diff --git a/frontend/assets/shared.js b/frontend/assets/shared.js index 791ed5c..c0fe8a4 100644 --- a/frontend/assets/shared.js +++ b/frontend/assets/shared.js @@ -45,7 +45,12 @@ * 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 noteEl = document.querySelector(".shared-note"); diff --git a/tests/unit/test_hamburger_nav.py b/tests/unit/test_hamburger_nav.py index 50efea0..ab358cb 100644 --- a/tests/unit/test_hamburger_nav.py +++ b/tests/unit/test_hamburger_nav.py @@ -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() diff --git a/tests/unit/test_header_bindings_once_per_document.py b/tests/unit/test_header_bindings_once_per_document.py new file mode 100644 index 0000000..aa45607 --- /dev/null +++ b/tests/unit/test_header_bindings_once_per_document.py @@ -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 + ```` (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" + ) diff --git a/tests/unit/test_mobile_chat_hamburger_boot.py b/tests/unit/test_mobile_chat_hamburger_boot.py index 3020377..835dfb8 100644 --- a/tests/unit/test_mobile_chat_hamburger_boot.py +++ b/tests/unit/test_mobile_chat_hamburger_boot.py @@ -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 " diff --git a/tests/unit/test_shared_header.py b/tests/unit/test_shared_header.py index 8fb67d0..edd8ddd 100644 --- a/tests/unit/test_shared_header.py +++ b/tests/unit/test_shared_header.py @@ -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: """The sign-out click binding (disable → POST /api/logout → reload) - is owned by header.js at module import — exactly one implementation - for every page that loads it. It binds to ALL .sign-out-btn + is owned by header.js — bound once per document via the explicit + 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 ≤640px, phase 46), so both copies log out.""" js = _text(HEADER_JS) @@ -373,10 +376,11 @@ def test_header_module_loads_before_the_page_script() -> None: header.js with a direct