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).
443 lines
20 KiB
Python
443 lines
20 KiB
Python
"""Unit: the phase-88 mobile hamburger cold-boot contract (source-level pins).
|
||
|
||
Owner bug report 2026-09-08 (continuation of the phase-85 report): after
|
||
the phase-76 SPA migration, the mobile hamburger (``#nav-toggle``) is
|
||
DEAD on the chat page — only on a fresh load / refresh of ``/`` (the
|
||
cold boot with the chat view visible from the first frame) — on two
|
||
real Android phones (cache cleared, production). A fresh
|
||
``/sources.html`` boot, any client-side switch into chat, and the
|
||
login page all work; rotation, pinch-zoom, and scrolling do not heal
|
||
the dead state. No spec-compliant Chromium repro exists (real-touch
|
||
Playwright probes at 360–412px, both auth states: the toggle is always
|
||
hit-testable and a touch tap always opens the menu) — the failure
|
||
lives in the real devices' touch→click / compositor pipeline, and an
|
||
exhaustive code audit found nothing in the app that can swallow the
|
||
click (no touch listeners, no click-eating document handlers, no
|
||
``touch-action`` anywhere). So the phase removes ALL candidate
|
||
mechanisms with standard, safe changes.
|
||
|
||
Locked decision A1 (belt-and-suspenders, not a single mechanism — the
|
||
internal device path is not provable from the dev machine): the
|
||
independent removals ship TOGETHER — task 01 ``touch-action:
|
||
manipulation`` on the mobile toggle, task 02 the composer cluster's
|
||
sticky layer deferred out of the first layout commit (the
|
||
``.chat-booted`` gate), task 03 the cluster hidden while the menu is
|
||
open (``body.nav-menu-open``) — plus task 04's router boot-refresh
|
||
contract repair. Each is byte-identical at rest or off-mobile (A2);
|
||
the owner's on-device re-verification (A4) is the phase's final gate.
|
||
|
||
This module pins the source-level contract, task by task (house
|
||
source-level pattern — the asset files are read as text, no browser;
|
||
the live behavior is E2E-gated by
|
||
``tests/e2e/test_mobile_chat_hamburger_boot.py``, task 05). Task 01
|
||
pins the toggle's ``touch-action`` and task 02 the deferred sticky
|
||
handover below; tasks 03/04 extend this module (the shared asset
|
||
readers are already in place).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from pathlib import Path
|
||
|
||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||
ASSETS = FRONTEND / "assets"
|
||
STYLES_CSS = ASSETS / "styles.css"
|
||
APP_JS = ASSETS / "app.js"
|
||
HEADER_JS = ASSETS / "header.js"
|
||
ROUTER_JS = ASSETS / "router.js"
|
||
|
||
#: The phase's mobile breakpoint — the same query the hamburger's
|
||
#: display rule and the menu's dropdown rules live in.
|
||
MOBILE_QUERY = "@media (max-width: 640px)"
|
||
|
||
|
||
def _text(path: Path) -> str:
|
||
assert path.is_file(), f"missing frontend file: {path}"
|
||
return path.read_text(encoding="utf-8")
|
||
|
||
|
||
def _styles() -> str:
|
||
"""styles.css, raw (declaration counts are pinned against the raw
|
||
file — the contract is "exactly N occurrences in the shipped
|
||
file", comments included)."""
|
||
return _text(STYLES_CSS)
|
||
|
||
|
||
def _app_js() -> str:
|
||
return _text(APP_JS)
|
||
|
||
|
||
def _header_js() -> str:
|
||
return _text(HEADER_JS)
|
||
|
||
|
||
def _router_js() -> str:
|
||
return _text(ROUTER_JS)
|
||
|
||
|
||
def _css() -> str:
|
||
"""styles.css with comments stripped (a comment may legally carry
|
||
braces — the brace-matching helpers below must never see them;
|
||
house pattern, cf. test_hamburger_nav.py)."""
|
||
return re.sub(r"/\*.*?\*/", "", _styles(), flags=re.S)
|
||
|
||
|
||
def _media_block(css: str, query: str) -> str:
|
||
"""The full text of the FIRST ``@media <query>`` block (brace-
|
||
matched, nested rules included verbatim)."""
|
||
m = re.search(re.escape(query) + r"[^{]*\{", css)
|
||
assert m, f"missing {query!r} media query in styles.css"
|
||
depth = 0
|
||
for i in range(m.end() - 1, len(css)):
|
||
if css[i] == "{":
|
||
depth += 1
|
||
elif css[i] == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
return css[m.start() : i + 1]
|
||
raise AssertionError(f"unbalanced braces in {query!r} media block")
|
||
|
||
|
||
def _global_css(css: str) -> str:
|
||
"""The rules OUTSIDE any @media block (the desktop baseline), in
|
||
file order (house pattern, cf. test_hamburger_nav.py)."""
|
||
out: list[str] = []
|
||
pos = 0
|
||
while True:
|
||
m = re.search(r"@media[^{]*\{", css[pos:])
|
||
if not m:
|
||
out.append(css[pos:])
|
||
break
|
||
start = pos + m.end() - 1 # the @media's own opening brace
|
||
depth = 0
|
||
i = start
|
||
while i < len(css):
|
||
if css[i] == "{":
|
||
depth += 1
|
||
elif css[i] == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
break
|
||
i += 1
|
||
out.append(css[pos:start])
|
||
pos = i + 1
|
||
return "".join(out)
|
||
|
||
|
||
def _rule_block(css: str, selector: str) -> str:
|
||
"""The first rule body for ``selector`` (e.g. ``.nav-toggle``)."""
|
||
m = re.search(re.escape(selector) + r"[^{}]*\{([^}]*)\}", css)
|
||
assert m, f"missing rule for {selector!r}"
|
||
return m.group(1)
|
||
|
||
|
||
# ---------- task 01: touch-action on the mobile nav toggle ----------
|
||
|
||
|
||
def test_mobile_nav_toggle_carries_touch_action_manipulation() -> None:
|
||
"""Phase 88 A1, removal (1): the ≤640px ``.nav-toggle`` rule (the
|
||
44px button — the base rule is ``display: none`` on desktop) gains
|
||
the standard dead-mobile-button fix, ``touch-action:
|
||
manipulation``. The viewport meta keeps zoom allowed
|
||
(``initial-scale=1``, no ``maximum-scale`` lock — WCAG), so every
|
||
touch on the page goes through the browser's tap/zoom disambiguation
|
||
window; on this control the window is what the device data points
|
||
at (a tap that never resolves to a click on a cold-booted chat
|
||
page). ``manipulation`` removes double-tap-to-zoom, pinch, and the
|
||
disambiguation delay from THIS control's touch pipeline only — a
|
||
(drifting) tap resolves to a click fastest. Desktop is untouched
|
||
(the toggle does not exist outside the ≤640px block)."""
|
||
mobile = _media_block(_css(), MOBILE_QUERY)
|
||
assert re.search(
|
||
r"\.nav-toggle\s*\{[^}]*touch-action:\s*manipulation[^}]*\}", mobile, re.S
|
||
), (
|
||
"the ≤640px .nav-toggle rule must carry 'touch-action: manipulation' "
|
||
"(phase 88 task 01 — the standard dead-mobile-button fix)"
|
||
)
|
||
|
||
|
||
def test_touch_action_is_mobile_only_on_the_toggle() -> None:
|
||
"""A2 — the mobile-only surface contract: the fix is scoped to the
|
||
ONE control the bug report names. The ENTIRE styles.css carries
|
||
exactly ONE ``touch-action`` declaration (no page-wide touch-action
|
||
— the rest of the page keeps the browser's normal touch/zoom
|
||
pipeline, and desktop is byte-identical at rest), and the BASE
|
||
``.nav-toggle`` rule (the ``display: none`` desktop baseline
|
||
outside any media query) carries none of it."""
|
||
raw = _styles()
|
||
assert raw.count("touch-action") == 1, (
|
||
f"exactly ONE 'touch-action' declaration in styles.css — "
|
||
f"the mobile toggle's fix (found {raw.count('touch-action')})"
|
||
)
|
||
base = _rule_block(_global_css(_css()), ".nav-toggle")
|
||
assert "touch-action" not in base, (
|
||
"the BASE .nav-toggle rule (desktop, display:none) must NOT "
|
||
"gain touch-action (A2 — the fix is mobile-only)"
|
||
)
|
||
|
||
|
||
# ---------- task 02: defer the cluster's sticky until boot ----------
|
||
|
||
|
||
def test_chat_booted_gate_rule_statics_the_cluster_pre_boot() -> None:
|
||
"""Phase 88 A1, removal (2): the first-commit sticky layer. The
|
||
dead state is EXACTLY "shell cold boot with the chat view visible
|
||
from the first frame" — the only element present there and absent
|
||
from EVERY working state (fresh ``/sources.html`` boot, any
|
||
client-side switch into chat, login) is the sticky ``.chat-bottom``
|
||
cluster (and the doubly-sticky ``#composer`` inside it) committed
|
||
in the first layout. A ``position: sticky`` element is promoted to
|
||
a compositor layer at commit; the owner's phone accepts the
|
||
identical layer when it is born on a SETTLED page. The gate rule
|
||
keeps the cluster static until app.js adds ``.chat-booted`` two
|
||
frames after boot settles — the id-scoped selector outranks the
|
||
two class rules, and at rest (``.chat-booted`` present) it matches
|
||
nothing, so both computed styles are byte-identical to today
|
||
(A2)."""
|
||
css = _css()
|
||
assert re.search(
|
||
r"#view-chat:not\(\.chat-booted\)\s*\.chat-bottom,\s*"
|
||
r"#view-chat:not\(\.chat-booted\)\s*\.composer\s*\{\s*"
|
||
r"position:\s*static\s*;?\s*\}",
|
||
css,
|
||
re.S,
|
||
), (
|
||
"styles.css must carry the phase-88 boot gate — "
|
||
"#view-chat:not(.chat-booted) .chat-bottom AND .composer are "
|
||
"static until app.js adds .chat-booted (the sticky compositor "
|
||
"layer is born after the boot paint, not in the first layout "
|
||
"commit)"
|
||
)
|
||
|
||
|
||
def test_original_cluster_sticky_rules_are_untouched() -> None:
|
||
"""The gate is ADDITIVE (A2): the two original rules —
|
||
``.chat-bottom`` and ``.composer``, independently ``position:
|
||
sticky`` since the phase-65 sticky-composer contract — keep their
|
||
sticky pair; the at-rest layout is byte-identical, pinned here and
|
||
by test_pinned_composer.py (+ its E2E)."""
|
||
css = _css()
|
||
# LINE-ANCHORED: the gate rule (placed between the two original rules)
|
||
# carries `.chat-bottom` / `.composer` in its OWN selector list — only
|
||
# the original rules start their selector line with the bare class
|
||
# (house pattern, cf. test_pinned_composer.py::_rule).
|
||
bottom = re.search(r"^\.chat-bottom \{\n([\s\S]*?)\n\}", css, re.MULTILINE)
|
||
composer = re.search(r"^\.composer \{\n([\s\S]*?)\n\}", css, re.MULTILINE)
|
||
assert bottom and composer, (
|
||
"the original .chat-bottom / .composer rule blocks must exist"
|
||
)
|
||
assert "position: sticky;" in bottom.group(1), (
|
||
"the .chat-bottom wrapper must KEEP its phase-65 sticky pair "
|
||
"(the gate defers it pre-boot only — the at-rest rule is "
|
||
"untouched)"
|
||
)
|
||
assert "position: sticky;" in composer.group(1), (
|
||
"the .composer form must KEEP its own sticky pair (redundant "
|
||
"inside the wrapper, pinned for the computed-style contract)"
|
||
)
|
||
|
||
|
||
def test_chat_booted_flag_lands_exactly_once_after_boot_settles() -> None:
|
||
"""The handover: app.js's boot IIFE adds ``chat-booted`` EXACTLY
|
||
once — the double ``requestAnimationFrame`` sitting AFTER
|
||
``loadHealth();`` (still inside the IIFE). Two frames: frame 1
|
||
paints the settled boot (the empty state, or the synchronously
|
||
re-rendered restored conversation) with the cluster static; frame
|
||
2 pins it — one frame would fold the sticky back into the first
|
||
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 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 "
|
||
f"double-rAF boot handover (found {js.count('chat-booted')})"
|
||
)
|
||
assert re.search(
|
||
r"requestAnimationFrame\(\(\)\s*=>\s*requestAnimationFrame\(\(\)\s*=>\s*\{?\s*"
|
||
r'document\.getElementById\("view-chat"\)\?\.classList\.add\("chat-booted"\)',
|
||
js,
|
||
re.S,
|
||
), (
|
||
"the boot handover must be the double requestAnimationFrame "
|
||
"pattern (frame 1 paints the settled boot with the cluster "
|
||
"static, frame 2 pins it)"
|
||
)
|
||
assert js.index("chat-booted") > js.index("loadHealth();"), (
|
||
"the flag must land AFTER loadHealth(); inside the boot IIFE — "
|
||
"the settled boot (restore + suggestions + health) is what "
|
||
"frame 1 paints"
|
||
)
|
||
|
||
|
||
# ---------- task 03: hide the cluster while the menu is open ----------
|
||
|
||
|
||
def _function_body(js: str, signature: str) -> str:
|
||
"""The full text of a top-level function — from ``signature`` to
|
||
its brace-matched closing ``}`` (setNavMenu's body carries no
|
||
nested braces, so the brace count is exact for it)."""
|
||
start = js.index(signature)
|
||
i = js.index("{", start)
|
||
depth = 0
|
||
for j in range(i, len(js)):
|
||
if js[j] == "{":
|
||
depth += 1
|
||
elif js[j] == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
return js[start : j + 1]
|
||
raise AssertionError(f"unbalanced braces after {signature!r}")
|
||
|
||
|
||
def test_nav_menu_open_marker_is_set_exactly_once_in_set_nav_menu() -> None:
|
||
"""Phase 88 A1, removal (3): the chat's sticky bottom cluster is
|
||
the OTHER positioned/layered element on the page — while the menu
|
||
is open it competes for taps, and on short viewports it overlaps
|
||
the menu's lower rows (measured: menu y58→417 vs cluster top y395
|
||
at 390×600). ``setNavMenu`` is the single choke point for EVERY
|
||
open/close path (the toggle click, the link click, Esc,
|
||
outside-click, the 640px-media close), so the ``body.nav-menu-open``
|
||
marker rides on it and can never stick. Exactly one occurrence in
|
||
header.js — and it sits inside the setNavMenu function body."""
|
||
js = _header_js()
|
||
assert js.count("nav-menu-open") == 1, (
|
||
f"header.js must mention nav-menu-open EXACTLY once — the single "
|
||
f"body-class toggle inside setNavMenu (found {js.count('nav-menu-open')})"
|
||
)
|
||
body = _function_body(js, "function setNavMenu")
|
||
assert "nav-menu-open" in body, (
|
||
"the body.nav-menu-open marker must be set INSIDE setNavMenu — "
|
||
"the single choke point every open/close path funnels through "
|
||
"(click / link / Esc / outside-click / media)"
|
||
)
|
||
|
||
|
||
def test_nav_menu_open_hides_the_cluster_in_the_mobile_block_only() -> None:
|
||
"""The CSS half: ``body.nav-menu-open .chat-bottom { visibility:
|
||
hidden }`` lives INSIDE the ``@media (max-width: 640px)`` block,
|
||
next to the .app-nav dropdown rules. ``visibility`` (NOT
|
||
``display``): layout is preserved, so closing the menu never
|
||
reflows the chat column and the sticky pin's position is stable
|
||
for the moment the menu closes. Scoped to the mobile block — at
|
||
>640px the toggle is ``display: none``, setNavMenu never opens
|
||
the menu, and even if the marker were set the rule does not exist
|
||
there (A2 — desktop and the resting state are untouched). Exactly
|
||
one occurrence in styles.css (the rule only — no stray coupling).
|
||
(The task's spec regex gains ``;?`` — house CSS style ends the
|
||
declaration with a semicolon, cf. task 02's ``position: static;``
|
||
pin.)"""
|
||
raw = _styles()
|
||
assert raw.count("nav-menu-open") == 1, (
|
||
f"styles.css must mention nav-menu-open EXACTLY once — the single "
|
||
f"≤640px rule (found {raw.count('nav-menu-open')})"
|
||
)
|
||
mobile = _media_block(_css(), MOBILE_QUERY)
|
||
assert re.search(
|
||
r"body\.nav-menu-open\s*\.chat-bottom\s*\{\s*visibility:\s*hidden;?\s*\}",
|
||
mobile,
|
||
re.S,
|
||
), (
|
||
"the ≤640px block must hide .chat-bottom while body.nav-menu-open "
|
||
"(visibility: hidden — layout preserved, no reflow on close)"
|
||
)
|
||
|
||
|
||
# ---------- task 04: the router's boot-refresh contract ----------
|
||
|
||
|
||
def test_boot_show_guard_excludes_the_boot_flag() -> None:
|
||
"""Phase 88 A3 (the boot contract is code, not comment): the
|
||
documented router contract says the first show (the mount) AND
|
||
boot never fire ``bor:view-refresh`` — but ``mounted.chat`` starts
|
||
true (app.js pre-mounts the chat view), so a cold boot's
|
||
``switchTo("chat")`` was hitting the ``wasMounted`` branch and
|
||
dispatching the refresh on ``#view-chat`` at boot. Harmless today
|
||
(no view listens on the chat root — the phase-77 exclusion — and
|
||
no lazy module mounts on a ``/`` boot), but a future listener added
|
||
to the chat view would fire at boot and could resurrect exactly
|
||
this bug class. The code now matches the contract: the dispatch
|
||
guard is ``wasMounted && !opts.boot`` — the explicit ``boot`` flag
|
||
(A3: no module-level flag state, no race with an in-flight boot
|
||
import) is the ONLY thing that exempts a show, and it rides the
|
||
opts object already carried by every call site (every non-boot
|
||
site has ``opts.boot === undefined`` → dispatches exactly as
|
||
before — the signature pin below keeps it an opts parameter, not a
|
||
destructured shorthand or a module flag). The old unconditional
|
||
``if (wasMounted) {`` guard is gone — its exact text is what the
|
||
pin excludes (the new guard reads
|
||
``if (wasMounted && !opts.boot) {``)."""
|
||
js = _router_js()
|
||
assert re.search(
|
||
r"if\s*\(\s*wasMounted\s*&&\s*!opts\.boot\s*\)\s*\{\s*"
|
||
r"root\.dispatchEvent\(\s*new\s+CustomEvent\(\"bor:view-refresh\"\)",
|
||
js,
|
||
re.S,
|
||
), (
|
||
"the refresh-dispatch guard must be 'wasMounted && !opts.boot' "
|
||
"(phase 88 — the boot show never fires the refresh; a re-show "
|
||
"still does)"
|
||
)
|
||
assert "async function switchTo(name, opts = {})" in js, (
|
||
"switchTo must take the opts object (defaulting to {}) — the "
|
||
"guard reads opts.boot, so a module-level flag or a "
|
||
"destructured shorthand would break the contract (A3)"
|
||
)
|
||
assert "if (wasMounted) {" not in js, (
|
||
"the old UNCONDITIONAL wasMounted guard must be gone — it is "
|
||
"what let the cold boot dispatch the refresh on the "
|
||
"pre-mounted chat view"
|
||
)
|
||
|
||
|
||
def test_boot_flag_is_carried_only_by_the_boot_call_site() -> None:
|
||
"""The flag's scope (the pin the task spec states as "the popstate/
|
||
nav paths never get the flag"): ``boot: true`` is carried by the
|
||
boot call site ONLY — ``switchTo(bootName, { userInitiated: false,
|
||
boot: true })`` at the bottom of the module — and by NOTHING
|
||
else. The two user-initiated call sites (the nav-click handler
|
||
and the popstate listener) keep the bare ``{ userInitiated: true }``
|
||
opts, and the flag's occurrence sits AFTER the popstate listener
|
||
(i.e. in the boot section — not smuggled into an earlier path).
|
||
|
||
DEVIATION FROM THE TASK SPEC, documented per house pattern (cf.
|
||
tasks 01–03's ``;?`` pins): the task pins ``js.count("boot: true")
|
||
== 2`` "the two boot call sites" (the overview names a
|
||
``switchTo(history.state.view, { push: false })`` branch beside
|
||
``switchTo(bootName, { push: false })``). Those two call sites do
|
||
not exist in this codebase — the boot section is the two-liner
|
||
``const bootName = VIEW[window.location.pathname] ?? "chat";
|
||
switchTo(bootName, { userInitiated: false });`` (verified against
|
||
git HEAD; nothing in the module reads ``history.state``, and
|
||
switchTo performs NO pushState — that is the click handler's job,
|
||
pinned in test_frontend_router.py, so a ``push`` option would be a
|
||
dead no-op). A second flag occurrence would require fabricating
|
||
dead code — the opposite of A3's "no behavior change for any
|
||
later show" — so the count pin is 1: the flag rides the ONE boot
|
||
call site, never the popstate/nav paths (their bare-opts count is
|
||
pinned too)."""
|
||
js = _router_js()
|
||
assert js.count("boot: true") == 1, (
|
||
f"router.js must carry 'boot: true' EXACTLY once — the single "
|
||
f"boot call site; popstate and nav-click never get the flag "
|
||
f"(found {js.count('boot: true')})"
|
||
)
|
||
assert 'switchTo(bootName, { userInitiated: false, boot: true })' in js, (
|
||
"the boot call site must carry the flag inline — "
|
||
"switchTo(bootName, { userInitiated: false, boot: true })"
|
||
)
|
||
# The flag sits in the BOOT section — after the popstate listener
|
||
# (an earlier occurrence would mean a user-initiated path carries
|
||
# it, which would silently skip its refresh).
|
||
assert js.index("boot: true") > js.index('window.addEventListener("popstate"'), (
|
||
"the boot flag must sit in the boot section (after the "
|
||
"popstate listener) — never in the nav-click or popstate path"
|
||
)
|
||
assert js.count("switchTo(name, { userInitiated: true })") == 2, (
|
||
"the nav-click and popstate call sites must keep the bare "
|
||
"{ userInitiated: true } opts — no boot flag on any "
|
||
"user-initiated path (exactly two such call sites)"
|
||
)
|