From 28b4777b5a968d412d649cec70226e7314b73891 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 20 Apr 2026 17:58:02 -0700 Subject: [PATCH] fix(ui): hide duplicate close button in workspace header at mobile width (#783) At the @media(max-width:900px) breakpoint both .close-preview and .mobile-close-btn were visible simultaneously. Since boot.js wires both to handleWorkspaceClose(), only the mobile-close-btn needs to show at that width. Adds .close-preview{display:none} to the 900px media block. Fixes #781 --- CHANGELOG.md | 5 ++ static/style.css | 1 + tests/test_issue781.py | 131 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 tests/test_issue781.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 95cf6f9..d46938e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Hermes Web UI -- Changelog +## [v0.50.122] — 2026-04-21 + +### Fixed +- **Duplicate X button in workspace panel header on mobile** — at viewport widths ≤900px the desktop close-preview button (`.close-preview` / `btnClearPreview`) is now hidden via CSS, leaving only the mobile close button (`.mobile-close-btn`) visible. Previously both buttons appeared side-by-side when the window was resized below the 900px breakpoint. (#781) + ## [v0.50.121] — 2026-04-20 ### Performance diff --git a/static/style.css b/static/style.css index ddbf643..afb0442 100644 --- a/static/style.css +++ b/static/style.css @@ -651,6 +651,7 @@ .rightpanel{display:none} .workspace-toggle-btn,.mobile-files-btn{display:inline-flex!important;} .mobile-close-btn{display:flex;} + .close-preview{display:none;} #btnCollapseWorkspacePanel{display:none;} } diff --git a/tests/test_issue781.py b/tests/test_issue781.py new file mode 100644 index 0000000..ea5f710 --- /dev/null +++ b/tests/test_issue781.py @@ -0,0 +1,131 @@ +""" +Tests for issue #781 — duplicate X close button in workspace preview header +on window resize below 900px breakpoint. + +Verifies that: + - .close-preview is hidden (display:none) inside the @media (max-width:900px) block + - .mobile-close-btn is shown (display:flex) inside the same @media block +Both rules must appear inside the same @media(max-width:900px) block so that +at mobile widths only the mobile-close-btn is visible. +""" + +import re +import os + +CSS_PATH = os.path.join(os.path.dirname(__file__), "..", "static", "style.css") + + +def _load_css(): + with open(CSS_PATH, "r", encoding="utf-8") as f: + return f.read() + + +def _extract_media_block(css, media_query_pattern): + """Extract the content of a @media block by tracking brace depth. + + Returns the inner text (between the outermost braces) of the first + @media block matching media_query_pattern (a regex applied to the @media + line itself). + """ + # Find the start of the @media declaration + m = re.search(media_query_pattern, css) + assert m, f"Media query matching {media_query_pattern!r} not found in style.css" + + # Walk forward from the opening brace to find its matching close brace + start = css.index("{", m.start()) + depth = 0 + for i in range(start, len(css)): + if css[i] == "{": + depth += 1 + elif css[i] == "}": + depth -= 1 + if depth == 0: + return css[start + 1 : i] # content between { and } + raise AssertionError("Unmatched brace in CSS after @media block") + + +def _strip_media_blocks(css): + """Remove all @media {...} blocks from CSS, returning base rules only.""" + result = [] + i = 0 + while i < len(css): + # Look for @media keyword + m = re.search(r"@media\b", css[i:]) + if not m: + result.append(css[i:]) + break + # Append everything before this @media + result.append(css[i : i + m.start()]) + # Find the opening brace of this @media block + brace_start = css.index("{", i + m.start()) + depth = 0 + j = brace_start + while j < len(css): + if css[j] == "{": + depth += 1 + elif css[j] == "}": + depth -= 1 + if depth == 0: + i = j + 1 + break + j += 1 + else: + break + return "".join(result) + + +_MEDIA_900_PATTERN = r"@media\s*\(\s*max-width\s*:\s*900px\s*\)" + + +def test_mobile_close_btn_displayed_in_900px_block(): + """mobile-close-btn must be display:flex inside the 900px media query.""" + css = _load_css() + block = _extract_media_block(css, _MEDIA_900_PATTERN) + assert ".mobile-close-btn" in block, ( + ".mobile-close-btn rule is missing from @media(max-width:900px) block" + ) + rule_match = re.search(r"\.mobile-close-btn\s*\{([^}]*)\}", block) + assert rule_match, ".mobile-close-btn rule body not found in 900px block" + assert "display:flex" in rule_match.group(1).replace(" ", ""), ( + ".mobile-close-btn should have display:flex in the 900px media query" + ) + + +def test_close_preview_hidden_in_900px_block(): + """.close-preview must be display:none inside the 900px media query (fix for #781).""" + css = _load_css() + block = _extract_media_block(css, _MEDIA_900_PATTERN) + assert ".close-preview" in block, ( + ".close-preview rule is missing from @media(max-width:900px) block — " + "the duplicate-button fix (#781) may have been reverted" + ) + rule_match = re.search(r"\.close-preview\s*\{([^}]*)\}", block) + assert rule_match, ".close-preview rule body not found in 900px block" + assert "display:none" in rule_match.group(1).replace(" ", ""), ( + ".close-preview should have display:none in the 900px media query to hide " + "the duplicate desktop X button at mobile widths" + ) + + +def test_both_rules_in_same_media_block(): + """Both .close-preview and .mobile-close-btn must appear in the same 900px block.""" + css = _load_css() + block = _extract_media_block(css, _MEDIA_900_PATTERN) + assert ".mobile-close-btn" in block, ( + ".mobile-close-btn missing from @media(max-width:900px) block" + ) + assert ".close-preview" in block, ( + ".close-preview missing from @media(max-width:900px) block" + ) + + +def test_close_preview_visible_outside_media_query(): + """Outside the media query, .close-preview must NOT be display:none + (it should remain visible on desktop).""" + css = _load_css() + base_css = _strip_media_blocks(css) + close_rules = re.findall(r"\.close-preview\s*\{([^}]*)\}", base_css) + for rule_body in close_rules: + assert "display:none" not in rule_body.replace(" ", ""), ( + ".close-preview must not be hidden in base (desktop) CSS" + )