mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
fix(sidebar): align collapse CSS breakpoint with JS _isDesktopWidth (641px)
`_isDesktopWidth()` in boot.js gates every collapse path on
`matchMedia('(min-width:641px)')` — matching where the rail itself becomes
visible. The CSS rules driving the actual visual collapse were nested inside
the workspace-panel block at `@media(min-width:901px)` — a threshold copied
from the right-panel collapse but with no functional reason to apply here.
Behavioural consequence in the 641–900 px band (tablet portrait + small
laptop windows):
- Rail is visible, user clicks the active icon
- JS adds `.layout.sidebar-collapsed` and writes localStorage='1'
- JS sets aria-expanded='false' on the active rail button
- CSS at min-width:901px does NOT apply → sidebar stays at 300 px width
- User sees no visual change; screen reader announces collapsed state for
a sidebar that is still visible; localStorage silently persists
- Resize to ≥901 px later → sidebar suddenly collapses (surprise state)
Fix: hoist the three `.sidebar-collapsed` / flash-prevention rules out of
the workspace-panel @media block and into their own `@media(min-width:641px)`
block. The rail visibility breakpoint, the JS gate, and the CSS gate now
all agree.
`:not(.mobile-open)` is preserved on both selectors so the mobile slide-in
overlay (handled in the `max-width:640px` block) is never targeted — the
new @641 boundary doesn't change that contract.
Verified breakpoint matrix end-to-end (Node harness over real boot.js +
style.css):
Width | JS desktop | CSS applies | Effect
------|------------|-------------|------------
640 | no | no | no-op (mobile overlay)
641 | yes | yes | collapses ✓
700 | yes | yes | collapses ✓
768 | yes | yes | collapses ✓
900 | yes | yes | collapses ✓
1024 | yes | yes | collapses ✓
Regression test added: `test_css_breakpoint_matches_js_isdesktopwidth`
parses boot.js for the `_isDesktopWidth` matchMedia query, walks CSS to
find the @media block enclosing `.layout.sidebar-collapsed`, and asserts
the thresholds match. Locks the invariant so a future refactor can't
re-introduce the asymmetric-band silent-state-leak.
Test counts:
- tests/test_sidebar_collapse_toggle.py: 35/35 pass (was 34, +1 regression)
- Full suite (Python 3.14, local): 5040 passed, 0 failed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+9
-3
@@ -1314,9 +1314,15 @@
|
||||
@media(min-width:901px){
|
||||
html[data-workspace-panel="closed"] .rightpanel{width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;}
|
||||
.layout.workspace-panel-collapsed .rightpanel{width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;}
|
||||
/* Sidebar collapse — same shape as workspace panel collapse, mirrored on the left.
|
||||
:not(.mobile-open) so the mobile slide-in overlay (handled in the max-width:640px
|
||||
block above) is never targeted by these rules. */
|
||||
}
|
||||
|
||||
/* Sidebar collapse breakpoint matches `_isDesktopWidth()` (min-width:641px) so
|
||||
clicking the active rail icon in the tablet-portrait band (641–900px) actually
|
||||
produces a visual change rather than silently flipping a class while CSS sits
|
||||
out at @901. The rail itself becomes visible at min-width:641px, so any width
|
||||
where the user can click the rail should also be a width where the collapse
|
||||
rule applies. :not(.mobile-open) excludes the slide-in overlay below 641px. */
|
||||
@media(min-width:641px){
|
||||
.layout.sidebar-collapsed .sidebar:not(.mobile-open){width:0 !important;min-width:0;opacity:0;transform:translateX(-14px);border-right-color:transparent;pointer-events:none;overflow:hidden;}
|
||||
.layout.sidebar-collapsed .sidebar .resize-handle{display:none;}
|
||||
/* Flash prevention: an inline <script> in index.html sets this dataset on
|
||||
|
||||
@@ -88,6 +88,54 @@ class TestSidebarCollapseCSS:
|
||||
assert ":not(.mobile-open)" in selector, \
|
||||
f"Collapse selector must exclude .mobile-open: {selector!r}"
|
||||
|
||||
def test_css_breakpoint_matches_js_isdesktopwidth(self):
|
||||
# The CSS @media block guarding .layout.sidebar-collapsed must use the
|
||||
# same min-width threshold as JS _isDesktopWidth(). Otherwise a click
|
||||
# in the asymmetric band silently flips the class while CSS sits out
|
||||
# — confusing for the user, broken for screen readers.
|
||||
js_bp = re.search(
|
||||
r"function\s+_isDesktopWidth[^}]*?matchMedia\('([^']+)'\)",
|
||||
BOOT_JS, re.DOTALL,
|
||||
)
|
||||
assert js_bp, "Could not locate _isDesktopWidth matchMedia query in boot.js"
|
||||
js_query = js_bp.group(1)
|
||||
|
||||
# Walk CSS to find which @media block encloses .layout.sidebar-collapsed
|
||||
idx = CSS.index(".layout.sidebar-collapsed .sidebar:not(.mobile-open)")
|
||||
# Search backward for the most recent unmatched `@media(...)`
|
||||
prefix = CSS[:idx]
|
||||
depth = 0
|
||||
media_stack = []
|
||||
last_open_media = None
|
||||
i = 0
|
||||
while i < len(prefix):
|
||||
ch = prefix[i]
|
||||
if ch == "@" and prefix[i:i + 6] == "@media":
|
||||
end = prefix.index("{", i)
|
||||
cond = prefix[i + 6:end].strip()
|
||||
media_stack.append((cond, depth + 1))
|
||||
i = end + 1
|
||||
depth += 1
|
||||
continue
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
while media_stack and media_stack[-1][1] > depth:
|
||||
media_stack.pop()
|
||||
i += 1
|
||||
last_open_media = media_stack[-1][0] if media_stack else None
|
||||
assert last_open_media is not None, (
|
||||
"Collapse rule must be inside an @media block to gate it correctly"
|
||||
)
|
||||
# Normalise whitespace for comparison
|
||||
norm = lambda s: s.replace(" ", "")
|
||||
assert norm(last_open_media) == norm(js_query), (
|
||||
f"CSS @media('{last_open_media}') for .sidebar-collapsed must match JS "
|
||||
f"_isDesktopWidth() ('{js_query}'). Otherwise clicks in the asymmetric band "
|
||||
f"silently flip state without visual feedback."
|
||||
)
|
||||
|
||||
|
||||
# ── boot.js contract ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user