stage batch #4644+#4645 (franksong2702): mobile drawer close-after-tap + transform animation (#4651)
#4644: close mobile sidebar drawer after a rail-click panel switch (was left open). #4645: animate drawer open/close with transform:translateX instead of left offset. Disjoint product files (panels.js vs style.css); both add tests to test_mobile_layout.py in distinct functions (no conflict). Mobile-only, desktop unaffected. 60 mobile-layout tests pass. Code byte-faithful from PR heads. Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
This commit is contained in:
parent
c888dd6a79
commit
cfe93c5ca1
4 changed files with 118 additions and 23 deletions
|
|
@ -3,6 +3,13 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.569] — 2026-06-22 — Release UB (mobile drawer fixes)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The mobile sidebar drawer now closes after you tap a panel.** Previously, tapping a rail icon (Tasks, Logs, Insights, etc.) on phone switched the panel but left the drawer open, so you had to dismiss it manually. The drawer now closes automatically after a rail-click panel switch on mobile (desktop is unaffected). Thanks @franksong2702. (#4644)
|
||||
- **Smoother mobile sidebar drawer animation.** The drawer open/close now animates with a GPU-accelerated `transform` instead of a `left` offset, for a smoother slide on phones. Thanks @franksong2702. (#4645)
|
||||
|
||||
## [v0.51.568] — 2026-06-22 — Release UA (toggle installed extensions)
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -277,11 +277,22 @@ async function switchPanel(name, opts = {}) {
|
|||
// Update main content view. Each entry in MAIN_VIEW_PANELS gets a matching
|
||||
// showing-<name> class on <main>; no class means chat (the default).
|
||||
const mainEl = document.querySelector('main.main');
|
||||
const _mainViewPanels = ['settings','skills','memory','tasks','kanban','workspaces','profiles','insights','logs','plugin'];
|
||||
if (mainEl) {
|
||||
['settings','skills','memory','tasks','kanban','workspaces','profiles','insights','logs','plugin'].forEach(p => {
|
||||
_mainViewPanels.forEach(p => {
|
||||
mainEl.classList.toggle('showing-' + p, nextPanel === p);
|
||||
});
|
||||
}
|
||||
// #4644: close the mobile sidebar drawer after a rail-click panel switch — but
|
||||
// ONLY for panels that have a main-content view (or chat). Sidebar-only panels
|
||||
// like Todos render INSIDE the drawer, so closing it would hide the very panel
|
||||
// the user just opened. Keep the drawer open for those.
|
||||
const _panelHasMainView = nextPanel === 'chat' || _mainViewPanels.indexOf(nextPanel) !== -1;
|
||||
if (_panelHasMainView && opts.fromRailClick && typeof _isDesktopWidth === 'function' && !_isDesktopWidth()) {
|
||||
if (typeof closeMobileSidebar === 'function') {
|
||||
closeMobileSidebar();
|
||||
}
|
||||
}
|
||||
// Lazy-load panel data
|
||||
if (nextPanel === 'tasks') await loadCrons();
|
||||
if (nextPanel === 'kanban') await loadKanban();
|
||||
|
|
@ -298,12 +309,6 @@ async function switchPanel(name, opts = {}) {
|
|||
switchSettingsSection(_currentSettingsSection);
|
||||
loadSettingsPanel();
|
||||
}
|
||||
if (opts.fromRailClick && typeof _isDesktopWidth === 'function' && !_isDesktopWidth()) {
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
const overlay = document.getElementById('mobileOverlay');
|
||||
if (sidebar) sidebar.classList.add('mobile-open');
|
||||
if (overlay) overlay.classList.add('visible');
|
||||
}
|
||||
_resyncChatSidebarAfterPanelSwitch();
|
||||
if (nextPanel === 'chat' && typeof syncTopbar === 'function') syncTopbar();
|
||||
else syncAppTitlebar();
|
||||
|
|
|
|||
|
|
@ -2369,9 +2369,9 @@
|
|||
/* Viewport-phone rules handle app chrome and touch sizing; they intentionally
|
||||
overlap the container compact rules for actual phones. */
|
||||
/* ── Sidebar: slide-in overlay instead of hidden ── */
|
||||
.sidebar{position:fixed;left:-320px;top:0;bottom:0;width:min(320px, 100vw);z-index:200;
|
||||
transition:left .25s ease;}
|
||||
.sidebar.mobile-open{left:0;}
|
||||
.sidebar{position:fixed;left:0;top:0;bottom:0;width:min(320px, 100vw);z-index:200;
|
||||
transform:translateX(-100%);transition:transform .25s ease;will-change:transform;}
|
||||
.sidebar.mobile-open{transform:translateX(0);}
|
||||
.sidebar .resize-handle{display:none;}
|
||||
/* Hamburger button (in app titlebar on mobile) */
|
||||
.app-titlebar{justify-content:space-between;}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,23 @@ def _declarations(rule_body):
|
|||
return declarations
|
||||
|
||||
|
||||
def _js_function_body(source, function_name):
|
||||
"""Return a JavaScript function body using balanced braces."""
|
||||
match = re.search(rf'function\s+{re.escape(function_name)}\s*\([^)]*\)\s*\{{', source)
|
||||
if not match:
|
||||
raise AssertionError(f"Missing JavaScript function {function_name}()")
|
||||
open_brace = match.end() - 1
|
||||
depth = 0
|
||||
for idx in range(open_brace, len(source)):
|
||||
if source[idx] == "{":
|
||||
depth += 1
|
||||
elif source[idx] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[open_brace + 1:idx]
|
||||
raise AssertionError(f"Could not parse JavaScript function {function_name}()")
|
||||
|
||||
|
||||
def _optional_declarations(css, selector):
|
||||
try:
|
||||
return _declarations(_rule_body(css, selector))
|
||||
|
|
@ -209,6 +226,26 @@ def test_rightpanel_mobile_slide_over_css():
|
|||
"mobile workspace header should keep comfortable row spacing"
|
||||
|
||||
|
||||
def test_mobile_sidebar_drawer_uses_transform_instead_of_left():
|
||||
"""Mobile sidebar drawer open/close must animate with transform not left offsets."""
|
||||
mobile_640 = "\n".join(_max_width_media_blocks(640))
|
||||
assert mobile_640, "Missing @media(max-width:640px) block in style.css"
|
||||
|
||||
sidebar_rule = _declarations(_rule_body(mobile_640, ".sidebar"))
|
||||
sidebar_open_rule = _declarations(_rule_body(mobile_640, ".sidebar.mobile-open"))
|
||||
|
||||
assert sidebar_rule.get("left") == "0", \
|
||||
"Mobile .sidebar should keep left:0 in the drawer rules"
|
||||
assert sidebar_rule.get("transform") == "translateX(-100%)", \
|
||||
"Closed mobile .sidebar should use transform:translateX(-100%)"
|
||||
assert sidebar_rule.get("transition") == "transform .25s ease", \
|
||||
"Mobile .sidebar should transition transform for drawer animation"
|
||||
assert sidebar_rule.get("will-change") == "transform", \
|
||||
"Mobile .sidebar should promote the transform layer before drawer animation"
|
||||
assert sidebar_open_rule.get("transform") == "translateX(0)", \
|
||||
"Open mobile .sidebar should use transform:translateX(0)"
|
||||
|
||||
|
||||
def test_workspace_panel_inline_width_is_desktop_only():
|
||||
"""Persisted rightpanel width must only be restored above compact/mobile breakpoints."""
|
||||
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
|
|
@ -467,28 +504,74 @@ def test_mobile_keeps_hamburger_drawer_with_vertical_44px_panel_targets():
|
|||
)
|
||||
|
||||
|
||||
def test_mobile_rail_click_opens_sidebar_for_all_panels():
|
||||
"""Rail clicks on phone must reveal the selected sidebar panel."""
|
||||
def test_mobile_rail_click_closes_sidebar_for_main_view_panels():
|
||||
"""Rail clicks on phone should switch panels and then close the mobile drawer —
|
||||
but ONLY for panels that have a main-content view (or chat). Sidebar-only panels
|
||||
like Todos render inside the drawer, so the drawer must stay open for them (#4644)."""
|
||||
panels_js = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
assert "opts.fromRailClick" in panels_js, (
|
||||
"switchPanel() should distinguish rail clicks from programmatic switches"
|
||||
)
|
||||
assert "!_isDesktopWidth()" in panels_js, (
|
||||
"Rail-click sidebar opening must be limited to mobile widths"
|
||||
"Mobile rail-click sidebar handling must be limited to mobile widths"
|
||||
)
|
||||
mobile_click_block = re.search(
|
||||
r'if\s*\(\s*opts\.fromRailClick[^{}]*!\s*_isDesktopWidth\(\)[\s\S]*?\n\s*\}',
|
||||
panels_js,
|
||||
switch_panel = _js_function_body(panels_js, "switchPanel")
|
||||
mobile_click_match = re.search(
|
||||
r"if\s*\(\s*_panelHasMainView\s*&&\s*opts\.fromRailClick\s*&&\s*typeof\s+_isDesktopWidth\s*===\s*'function'\s*&&\s*!\s*_isDesktopWidth\(\)\s*\)\s*\{(?P<body>.*?)\n\s*\}",
|
||||
switch_panel,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert mobile_click_block, "Missing mobile rail-click sidebar handler"
|
||||
assert "sidebar.classList.add('mobile-open')" in panels_js, (
|
||||
"Phone rail clicks should open the sidebar panel"
|
||||
assert mobile_click_match, "Missing mobile rail-click sidebar handler (gated on _panelHasMainView)"
|
||||
mobile_click_block = mobile_click_match.group("body")
|
||||
assert "typeof closeMobileSidebar === 'function'" in mobile_click_block, (
|
||||
"Phone rail clicks should guard closeMobileSidebar with a typeof check"
|
||||
)
|
||||
assert "overlay.classList.add('visible')" in panels_js, (
|
||||
"Phone rail clicks should show the overlay behind the opened sidebar"
|
||||
assert "closeMobileSidebar();" in mobile_click_block, (
|
||||
"Phone rail clicks should close the mobile sidebar after panel switch"
|
||||
)
|
||||
assert "nextPanel === 'chat'" not in mobile_click_block.group(0), (
|
||||
"Chat rail clicks must open the session list on phone, not close the sidebar"
|
||||
# The close must be gated so sidebar-only panels (e.g. Todos, which has no
|
||||
# main-content view) keep the drawer open instead of hiding themselves.
|
||||
assert "_panelHasMainView" in switch_panel, (
|
||||
"Mobile drawer close must be gated on _panelHasMainView so sidebar-only "
|
||||
"panels like Todos are not hidden by the close (#4644 Todos regression)"
|
||||
)
|
||||
main_view_decl = re.search(r"_panelHasMainView\s*=\s*([^;]+);", switch_panel)
|
||||
assert main_view_decl, "_panelHasMainView guard must be defined"
|
||||
guard_expr = main_view_decl.group(1)
|
||||
assert "nextPanel === 'chat'" in guard_expr, (
|
||||
"chat (default main view) must be treated as having a main view"
|
||||
)
|
||||
assert "_mainViewPanels" in guard_expr, (
|
||||
"the guard must derive from the main-view panel list, not a hardcoded subset"
|
||||
)
|
||||
assert "sidebar.classList.add('mobile-open')" not in mobile_click_block, (
|
||||
"Phone rail-click path in switchPanel should no longer add mobile-open"
|
||||
)
|
||||
assert "overlay.classList.add('visible')" not in mobile_click_block, (
|
||||
"Phone rail-click path in switchPanel should no longer add mobile overlay"
|
||||
)
|
||||
close_idx = switch_panel.index("closeMobileSidebar();")
|
||||
lazy_load_idx = switch_panel.index("if (nextPanel === 'tasks') await loadCrons();")
|
||||
assert close_idx < lazy_load_idx, (
|
||||
"Phone rail clicks should close the drawer before async panel lazy-loads"
|
||||
)
|
||||
|
||||
|
||||
def test_mobile_switch_panel_non_chat_opens_sidebar():
|
||||
"""mobileSwitchPanel() non-chat path should still open the sidebar overlay."""
|
||||
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
fn_body = _js_function_body(boot_js, "mobileSwitchPanel")
|
||||
assert "if(name==='chat')" in fn_body, (
|
||||
"mobileSwitchPanel must close sidebar only on chat target"
|
||||
)
|
||||
assert "closeMobileSidebar();" in fn_body, (
|
||||
"mobileSwitchPanel should still close sidebar on chat"
|
||||
)
|
||||
assert "sidebar.classList.add('mobile-open')" in fn_body, (
|
||||
"mobileSwitchPanel non-chat branch should keep adding mobile-open"
|
||||
)
|
||||
assert "overlay.classList.add('visible')" in fn_body, (
|
||||
"mobileSwitchPanel non-chat branch should keep showing mobile overlay"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue