"""
Mobile layout regression tests — run on every QA pass.
These tests check that the CSS and HTML structure required for correct
mobile rendering (375px–640px viewport widths) is intact after every change.
They are static checks (no server needed) that catch common regressions:
- Mobile breakpoints present for key layout elements
- Right panel slide-over markup and CSS intact
- Profile dropdown not clipped by overflow on mobile
- Composer footer chips scroll correctly on narrow viewports
- Mobile sidebar navigation stays available on phones
- No full-viewport overflow that would break scroll
Run as part of the standard test suite:
./scripts/test.sh tests/test_mobile_layout.py -v
"""
import pathlib
import re
from html.parser import HTMLParser
REPO = pathlib.Path(__file__).parent.parent
HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
def _max_width_media_blocks(width_px):
"""Return all @media(max-width:Npx) bodies using balanced braces."""
pattern = re.compile(rf'@media\s*\(\s*max-width\s*:\s*{width_px}px\s*\)\s*\{{')
blocks = []
for match in pattern.finditer(CSS):
open_brace = match.end() - 1
depth = 0
for idx in range(open_brace, len(CSS)):
if CSS[idx] == "{":
depth += 1
elif CSS[idx] == "}":
depth -= 1
if depth == 0:
blocks.append(CSS[open_brace + 1:idx])
break
return blocks
def _composer_phone_media_block():
for block in _max_width_media_blocks(640):
if ".composer-footer" in block:
return block
raise AssertionError("Missing composer rules in @media(max-width:640px)")
def _strip_css_comments(css):
return re.sub(r'/\*.*?\*/', '', css, flags=re.DOTALL)
def _rule_body(css, selector):
for match in re.finditer(r'([^{}]+)\{([^{}]*)\}', _strip_css_comments(css)):
selectors = {part.strip() for part in match.group(1).split(",")}
if selector in selectors:
return match.group(2)
raise AssertionError(f"Missing CSS rule for {selector}")
def _declarations(rule_body):
declarations = {}
for item in rule_body.split(";"):
if ":" not in item:
continue
prop, value = item.split(":", 1)
declarations[prop.strip()] = re.sub(r'\s+', ' ', value.strip())
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))
except AssertionError:
return {}
def _js_function_body(src, name):
signature = f"function {name}("
start = src.find(signature)
if start == -1:
raise AssertionError(f"Missing JS function {name}()")
brace = src.find("{", start)
if brace == -1:
raise AssertionError(f"Missing function body for {name}()")
depth = 0
for idx in range(brace, len(src)):
char = src[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return src[brace + 1:idx]
raise AssertionError(f"Unterminated JS function {name}()")
def _display_hidden(declarations):
return declarations.get("display", "").replace(" ", "") in {"none", "none!important"}
def _display_inline_flex(declarations):
return declarations.get("display", "").replace(" ", "") in {"inline-flex", "inline-flex!important"}
class _ComposerLeftDropdownParser(HTMLParser):
_VOID_TAGS = {
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr",
}
def __init__(self):
super().__init__()
self.stack = []
self.violations = []
def handle_starttag(self, tag, attrs):
self._handle_element(tag, attrs, push=True)
def handle_startendtag(self, tag, attrs):
self._handle_element(tag, attrs, push=False)
def handle_endtag(self, tag):
tag = tag.lower()
for idx in range(len(self.stack) - 1, -1, -1):
if self.stack[idx]["tag"] == tag:
del self.stack[idx:]
break
def _handle_element(self, tag, attrs, push):
tag = tag.lower()
attrs = dict(attrs)
classes = set((attrs.get("class") or "").split())
element_id = attrs.get("id") or ""
inside_composer_left = any(
"composer-left" in item["classes"] for item in self.stack
)
is_dropdown = (
element_id.endswith("Dropdown") or
any("dropdown" in class_name for class_name in classes)
)
if inside_composer_left and is_dropdown:
label = f"#{element_id}" if element_id else "." + ".".join(sorted(classes))
self.violations.append(label)
if push and tag not in self._VOID_TAGS:
self.stack.append({"tag": tag, "classes": classes})
# ── Mobile breakpoint rules ───────────────────────────────────────────────────
def test_mobile_breakpoint_900px_present():
"""@media(max-width:900px) must hide the right panel and show mobile-files-btn."""
assert "@media(max-width:900px)" in CSS or "@media (max-width: 900px)" in CSS, \
"Missing @media(max-width:900px) breakpoint in style.css"
# Right panel should be hidden at 900px, replaced by slide-over
assert ".rightpanel{display:none" in CSS or ".rightpanel {display:none" in CSS or \
re.search(r'max-width:900px\).*?\.rightpanel\{display:none', CSS, re.DOTALL), \
".rightpanel must be display:none at max-width:900px (slide-over replaces it)"
def test_mobile_breakpoint_640px_present():
"""@media(max-width:640px) must exist for narrow phone layouts."""
assert "@media(max-width:640px)" in CSS or "@media (max-width: 640px)" in CSS, \
"Missing @media(max-width:640px) breakpoint in style.css"
def test_settings_system_version_controls_wrap_on_phone_widths():
"""Settings -> System version badges must wrap instead of overflowing phones."""
mobile_css = "\n".join(_max_width_media_blocks(768))
assert ".settings-section-head" in mobile_css, (
"Settings section header needs a mobile rule so title and update controls stack."
)
assert "flex-direction:column" in mobile_css.replace(" ", ""), (
"Settings section header should stack vertically on mobile."
)
assert "#checkUpdatesBlock" in mobile_css, (
"Settings update/version controls need a mobile rule."
)
assert "flex-wrap:wrap" in mobile_css.replace(" ", ""), (
"Version badges and Check now button must wrap instead of overflowing."
)
assert "width:100%" in mobile_css.replace(" ", ""), (
"The update controls row should take the available mobile width."
)
assert ".settings-version-badge" in mobile_css and "white-space:nowrap" in mobile_css.replace(" ", ""), (
"Individual version badges should stay intact while the group wraps."
)
def test_rightpanel_mobile_slide_over_css():
"""Right panel must have position:fixed slide-over CSS for mobile."""
# At max-width:900px the rightpanel should be position:fixed, off-screen right
assert "position:fixed" in CSS, \
"style.css must have position:fixed for rightpanel mobile slide-over"
assert ".rightpanel.mobile-open{right:0" in CSS or ".rightpanel.mobile-open {right:0" in CSS, \
".rightpanel.mobile-open must set right:0 to slide panel in from right"
assert "min(300px, 100vw)" in CSS or "min(300px,100vw)" in CSS, \
"rightpanel mobile width should be capped defensively with 100vw"
assert "var(--mobile-rightpanel-width)" in CSS, \
"mobile rightpanel width variable should be used in compact mode rules"
assert "calc(-1 * var(--mobile-rightpanel-width))" in CSS, \
"closed mobile rightpanel should be off-canvas using a width-based negative offset"
mobile_640 = re.search(r'@media\(max-width:640px\)\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}', CSS, re.DOTALL)
assert mobile_640, "@media(max-width:640px) block missing from style.css"
rightpanel_block = mobile_640.group(1)
assert re.search(r'\.rightpanel\{[^}]*width:\s*var\(--mobile-rightpanel-width\)\s*!important',
rightpanel_block, re.DOTALL), \
".rightpanel width must use var(--mobile-rightpanel-width) with !important in mobile block"
assert re.search(r'\.rightpanel\.mobile-open\{[^}]*right:\s*0\s*!important',
rightpanel_block, re.DOTALL), \
"mobile-open mobile rightpanel must force right:0 with !important"
assert re.search(r'\.rightpanel\{[^}]*box-shadow:\s*none\s*!important',
rightpanel_block, re.DOTALL), \
"closed mobile rightpanel should have no shadow to avoid right-edge bleed"
assert re.search(r'\.rightpanel\{[^}]*padding-top:\s*var\(--app-titlebar-safe-top\)',
rightpanel_block, re.DOTALL), \
"mobile rightpanel should reserve the same PWA top inset as the titlebar"
assert re.search(r'\.rightpanel\{[^}]*box-sizing:\s*border-box',
rightpanel_block, re.DOTALL), \
"mobile rightpanel safe-area padding must stay inside its fixed height"
assert re.search(r'\.rightpanel\.mobile-open\{[^}]*box-shadow:\s*-4px 0 24px rgba\(0,\s*0,\s*0,\s*\.?4\)',
rightpanel_block, re.DOTALL), \
"open mobile rightpanel should keep the edge shadow"
assert re.search(r'\.rightpanel\s+\.panel-header\{[^}]*row-gap:\s*8px', rightpanel_block), \
"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")
assert "function _syncWorkspacePanelInlineWidth()" in boot_js, \
"_syncWorkspacePanelInlineWidth() must exist to keep panel width mobile-safe"
assert "_syncWorkspacePanelInlineWidth();" in boot_js, \
"_syncWorkspacePanelInlineWidth() must be called when viewport changes"
assert "localStorage.getItem('hermes-panel-w')" in boot_js, \
"Panel width helper must source hermes-panel-w from localStorage"
assert "_workspacePanelEls();" in boot_js and "style.removeProperty('width')" in boot_js, \
"Panel helper must clear inline width while in compact/mobile viewport"
def test_workspace_panel_boot_restore_is_desktop_only():
"""Persisted workspace panels should not auto-cover compact/mobile launch."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "if(_ephPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse';" in boot_js
assert "if(S.session&&S.session.workspace&&panelPref&&!_isCompactWorkspaceViewport()){" in boot_js
assert "if(_freshPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse';" in boot_js
def _container_query_block(css: str, container_query: str):
query_pattern = re.compile(
rf'@container\s+{re.escape(container_query)}\s*\{{',
re.DOTALL,
)
for match in query_pattern.finditer(css):
start = match.end() - 1
end = css.find("@container", start + 1)
if end == -1:
end = css.find("@media", start + 1)
if end == -1:
end = len(css)
block = css[start + 1:end]
return block
return ""
def _container_media_block(css: str, media_query: str):
query_pattern = re.compile(
rf'@media\s*\(\s*max-width:\s*{re.escape(media_query)}\s*\)\s*\{{',
re.DOTALL,
)
def _media_block_end(css_text: str, open_brace_idx: int) -> int:
depth = 0
for idx in range(open_brace_idx, len(css_text)):
if css_text[idx] == "{":
depth += 1
elif css_text[idx] == "}":
depth -= 1
if depth == 0:
return idx
return -1
def _strip_nested_media(block: str) -> str:
parts = []
cursor = 0
while True:
nested = block.find("@media", cursor)
if nested == -1:
parts.append(block[cursor:])
break
parts.append(block[cursor:nested])
nested_open = block.find("{", nested)
if nested_open == -1:
break
nested_close = _media_block_end(block, nested_open)
if nested_close == -1:
break
cursor = nested_close + 1
return "".join(parts)
for match in query_pattern.finditer(css):
start = match.end() - 1
end = _media_block_end(css, start)
if end == -1:
continue
block = css[start + 1:end]
block = _strip_nested_media(block)
if ".composer-profile-label" in block or ".composer-profile-chip" in block:
return block
return ""
def test_composer_controls_switch_to_fit_stage_classes():
"""Composer controls should progressively compact based on measured overflow."""
assert re.search(r'\.composer-footer\s*\{[^}]*container-type:inline-size[^}]*container-name:composer-footer[^}]*\}', CSS), \
".composer-footer should define container-type:inline-size and container-name:composer-footer"
assert "@container composer-footer (max-width: 700px)" not in CSS
assert "@container composer-footer (max-width: 520px)" not in CSS
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
fit_body = _js_function_body(ui_js, "_fitComposerFooter")
assert "scrollWidth" in fit_body and "clientWidth" in fit_body, \
"_fitComposerFooter() should measure actual left-cluster overflow"
assert "cf-icons" in fit_body and "cf-burger" in fit_body, \
"_fitComposerFooter() should toggle the compact stage classes"
for selector in (
".composer-footer.cf-icons .composer-profile-label",
".composer-footer.cf-icons .composer-profile-chevron",
".composer-footer.cf-icons #composerProfileLabel",
".composer-footer.cf-icons .composer-workspace-label",
".composer-footer.cf-icons #composerWorkspaceLabel",
".composer-footer.cf-icons .composer-model-label",
".composer-footer.cf-icons #composerModelLabel",
".composer-footer.cf-icons .composer-profile-chip",
".composer-footer.cf-icons .composer-model-chip",
".composer-footer.cf-icons .composer-divider",
):
assert selector in CSS, f"{selector} should be present in the .cf-icons rules"
assert ".composer-footer.cf-icons .composer-profile-chip{box-sizing:border-box;width:44px" in CSS, \
".cf-icons should collapse the profile chip to an icon-sized control"
assert ".composer-footer.cf-icons .composer-workspace-chip{display:none!important" not in CSS.replace(" ", ""), \
".cf-icons should keep the workspace switch visible rather than blanking it"
for selector in (
".composer-footer.cf-burger .composer-workspace-group",
".composer-footer.cf-burger .composer-workspace-files-btn",
".composer-footer.cf-burger .composer-workspace-chip",
".composer-footer.cf-burger .composer-left > .composer-model-wrap",
".composer-footer.cf-burger .provider-quota-chip",
".composer-footer.cf-burger .composer-left > .composer-reasoning-wrap",
".composer-footer.cf-burger .composer-left > .composer-toolsets-wrap",
".composer-footer.cf-burger .composer-mobile-config-btn",
".composer-footer.cf-burger .composer-mobile-config-panel.open",
".composer-footer.cf-burger .composer-mobile-context-action",
".composer-footer.cf-burger .ctx-indicator-wrap",
):
assert selector in CSS, f"{selector} should be present in the .cf-burger rules"
assert ".composer-footer.cf-burger .composer-workspace-chip{display:none!important".replace(" ", "") in CSS.replace(" ", ""), \
".cf-burger must remove the blank workspace switch slot"
assert ".composer-footer.cf-burger .provider-quota-chip" in CSS and ".composer-footer.cf-burger .composer-left > .composer-toolsets-wrap{display:none!important" in CSS, \
".cf-burger must fold the inline quota chip into the shared config menu"
assert ".composer-footer.cf-burger .composer-mobile-config-btn{box-sizing:border-box;position:relative;display:inline-flex!important" in CSS, \
".cf-burger must expose the config button even on wider viewports"
# Regression intent:
# - this measured rule should not depend on right-panel open/closed state.
# - left-sidebar-only constriction must still collapse composer controls together.
assert ".layout:not(.workspace-panel-collapsed)" not in CSS, \
"composer-footer compact stages should be state-agnostic"
def test_composer_icon_stage_workspace_switch_does_not_become_blank_chip():
"""The icon stage may hide workspace text, but keeps a visible switch affordance."""
css_ns = CSS.replace(" ", "")
assert ".composer-footer.cf-icons .composer-workspace-label" in CSS
assert ".composer-footer.cf-icons #composerWorkspaceLabel" in CSS
assert ".composer-footer.cf-icons .composer-workspace-chip{display:none!important" not in css_ns, \
".cf-icons must not hide the visible workspace switch chip"
assert ".composer-footer.cf-icons .composer-workspace-chevron{display:none" not in css_ns, \
".cf-icons must not leave the visible workspace switch chip without its chevron"
def test_composer_compact_switch_is_not_viewport_only():
"""Compact controls should be content-triggered, not bound to viewport width alone."""
assert ".composer-footer.cf-icons" in CSS, \
"Icon-chip stage should be expressed as the .cf-icons class"
assert ".composer-footer.cf-burger" in CSS, \
"Hamburger stage should be expressed as the .cf-burger class"
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
assert "_fitComposerFooter" in ui_js and "scrollWidth" in ui_js, \
"Composer compacting should be driven by measured overflow"
assert "ResizeObserver" in ui_js and "MutationObserver" in ui_js, \
"Composer fitting should rerun when footer size or chip content changes"
assert "composer-footer (max-width: 700px)" not in CSS
assert "composer-footer (max-width: 520px)" not in CSS
assert re.search(r'@container\s+composer-footer\s*\(max-width:\s*860px\)', CSS) is None, \
"Full icon-only should not be tied to a 860px threshold any more"
assert re.search(r'@container\s+composer-footer\s*\(max-width:\s*1000px\)', CSS) is None, \
"Full icon-only/first-stage container gate should not be tied to 1000px"
media_860 = _container_media_block(CSS, "860px")
assert media_860 == "", \
"Composer compact breakpoint should not be a dedicated 860px viewport media query"
media_900 = _container_media_block(CSS, "900px")
assert media_900 == "", \
"Composer compact breakpoint should use container queries, not viewport media at 900px"
def test_mobile_overlay_present():
"""Legacy mobile overlay stays hidden because the phone sidebar is full-screen."""
assert 'id="mobileOverlay"' in HTML, \
"#mobileOverlay element missing from index.html"
assert "mobile-overlay" in CSS, \
".mobile-overlay CSS rule missing from style.css"
mobile_css = "\n".join(_max_width_media_blocks(640))
assert re.search(r'\.mobile-overlay\.visible\{[^}]*display:\s*none', mobile_css), (
"Full-screen mobile sidebar must not dim the PWA status/safe-area with a backdrop"
)
def test_mobile_sidebar_edge_guard_claims_body_edge_only():
"""A narrow body-only edge guard helps iOS hand left swipes to WebUI."""
assert 'id="pwaSidebarEdgeGuard"' in HTML, (
"mobile sidebar edge guard missing from index.html"
)
assert ".pwa-sidebar-edge-guard{display:none;}" in CSS.replace(" ", ""), (
"edge guard should be hidden outside the phone layout"
)
mobile_css = "\n".join(_max_width_media_blocks(640))
guard = _declarations(_rule_body(mobile_css, ".pwa-sidebar-edge-guard"))
assert guard.get("display") == "block"
assert guard.get("position") == "fixed"
assert guard.get("left") == "0"
assert guard.get("top") == "calc(52px + var(--app-titlebar-safe-top))", (
"edge guard should start below the PWA titlebar so it does not block hamburger"
)
assert guard.get("width") == "24px"
assert guard.get("pointer-events") == "none", (
"edge guard must be pointer-events:none so taps/vertical scrolls starting in the "
"strip fall through to the .messages scroller; the edge-swipe gesture is handled by "
"window-level capture listeners, not by the guard intercepting hit-testing (#4660 review)"
)
assert guard.get("z-index") == "198", (
"edge guard should sit below the full-screen sidebar but above the page body"
)
def test_sidebar_nav_present():
"""Sidebar top navigation tabs must be present."""
assert 'class="sidebar-nav"' in HTML, \
".sidebar-nav missing from index.html"
assert ".sidebar-nav{" in CSS or ".sidebar-nav {" in CSS, \
".sidebar-nav CSS rule missing from style.css"
def test_mobile_keeps_panel_navigation_available():
"""Phone breakpoint must keep panel navigation available inside the drawer."""
mobile_css = "\n".join(_max_width_media_blocks(640))
assert mobile_css, "Missing @media(max-width:640px) block in style.css"
assert ".sidebar-nav" in mobile_css, \
"Phone panel navigation must remain available in the hamburger drawer"
def test_mobile_sidebar_opens_as_full_screen_surface_with_panel_rail():
"""Phone sidebar should open full-screen while keeping the panel rail visible."""
mobile_css = "\n".join(_max_width_media_blocks(640))
assert re.search(r'\.app-titlebar-hamburger,\s*\.app-titlebar-spacer\{[^}]*display:\s*flex', mobile_css), (
"Phone titlebar hamburger must stay visible"
)
assert not re.search(r'\.rail\{[^}]*display:\s*flex[^}]*position:\s*fixed', mobile_css), (
"Phone must not use a persistent left rail that consumes chat width"
)
sidebar_rule = _declarations(_rule_body(mobile_css, ".sidebar"))
sidebar_open_rule = _declarations(_rule_body(mobile_css, ".sidebar.mobile-open"))
assert sidebar_rule.get("left") == "0", (
"Mobile sidebar should stay at left:0 and move with transform"
)
assert sidebar_rule.get("width") == "100vw", (
"Mobile sidebar should fill the viewport like a session page"
)
assert sidebar_rule.get("max-width") == "none", (
"Mobile sidebar must not retain desktop/drawer max width"
)
assert sidebar_rule.get("transform") == "translateX(-100%)", (
"Closed mobile sidebar should sit fully offscreen"
)
assert sidebar_rule.get("transition") == "transform .25s ease", (
"Mobile sidebar should animate with transform"
)
assert sidebar_rule.get("will-change") == "transform", (
"Mobile sidebar should promote the transform layer before opening"
)
assert sidebar_open_rule.get("transform") == "translateX(0)", (
"Open mobile sidebar should slide the full session page into view"
)
assert not re.search(r'\.sidebar\s+\.sidebar-nav\{[^}]*display:\s*none', mobile_css), (
"Full-screen mobile sidebar should keep the panel rail visible"
)
assert re.search(r'\.sidebar-nav\{[^}]*position:\s*absolute', mobile_css), (
"Phone drawer panel tabs should be laid out as an internal side strip"
)
assert re.search(r'\.sidebar-nav\{[^}]*width:\s*52px', mobile_css), (
"Phone drawer panel strip should reserve stable space for 44px targets"
)
assert re.search(r'\.sidebar-nav\{[^}]*flex-direction:\s*column', mobile_css), (
"Phone drawer panel tabs must be vertical, not horizontal"
)
assert re.search(r'\.sidebar-nav\s+\.nav-tab\{[^}]*min-width:\s*44px', mobile_css), (
"Phone drawer panel tabs must be at least 44px wide"
)
assert re.search(r'\.sidebar-nav\s+\.nav-tab\{[^}]*min-height:\s*44px', mobile_css), (
"Phone drawer panel tabs must be at least 44px tall"
)
assert re.search(r'\.sidebar\s+\.panel-view\{[^}]*height:\s*100%[^}]*margin-left:\s*52px', mobile_css), (
"Full-screen mobile sidebar content should start beside the vertical icon strip"
)
assert re.search(r'\.mobile-sidebar-close\{[^}]*display:\s*inline-flex\s*!important', mobile_css), (
"Full-screen mobile session page needs a visible close affordance"
)
assert re.search(r'\.sidebar\s+\.panel-icon-btn\{[^}]*min-width:\s*44px', mobile_css), (
"Sidebar panel buttons must min-width:44px on phone"
)
assert re.search(r'\.sidebar\s+\.panel-icon-btn\{[^}]*min-height:\s*44px', mobile_css), (
"Sidebar panel buttons must min-height:44px on phone"
)
assert re.search(r'\.sidebar\s+\.panel-icon-btn\{[^}]*width:\s*auto', mobile_css), (
"Sidebar panel buttons must override their base 24px width on phone"
)
assert re.search(r'\.sidebar\s+\.panel-icon-btn\{[^}]*height:\s*auto', mobile_css), (
"Sidebar panel buttons must override their base 24px height on phone"
)
assert not re.search(r'(?= 100, \
f".profile-dropdown z-index {m.group(1)} is too low — must be >= 100 to clear topbar"
def test_composer_dropdowns_are_not_nested_inside_left_control_row():
"""Composer dropdown surfaces should remain outside .composer-left.
The left row can wrap/scroll on phones; dropdowns need to be siblings so
that overflow rules on the control row cannot clip them.
"""
parser = _ComposerLeftDropdownParser()
parser.feed(HTML)
assert not parser.violations, (
"Composer dropdowns must not be nested inside .composer-left: "
+ ", ".join(parser.violations)
)
def test_topbar_chips_mobile_overflow():
"""topbar-chips must use overflow-x:auto on mobile for chip scrolling.
Chips (profile, workspace, model, files) must scroll horizontally on narrow
viewports rather than wrapping onto a second line which would break the topbar layout.
"""
# At narrow viewport, topbar-chips should scroll
assert "overflow-x:auto" in CSS or "overflow-x: auto" in CSS, \
"topbar-chips must have overflow-x:auto for mobile chip scrolling"
# ── Workspace panel close ─────────────────────────────────────────────────────
def test_workspace_close_button_present():
"""Workspace panel must have a close/hide button accessible on mobile."""
# Accept handleWorkspaceClose() (two-step close: file→browse→closed), or the
# lower-level functions directly. handleWorkspaceClose is preferred because
# it dismisses a file preview first before closing the panel.
has_close = (
'onclick="handleWorkspaceClose()"' in HTML or
'onclick="closeWorkspacePanel()"' in HTML or
'onclick="toggleWorkspacePanel()"' in HTML
)
assert has_close, \
"handleWorkspaceClose() or closeWorkspacePanel() must be wired to a button to close the workspace panel on mobile"
def test_toggle_mobile_files_js_defined():
"""toggleMobileFiles() must be defined in boot.js."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "function toggleMobileFiles()" in boot_js, \
"toggleMobileFiles() missing from static/boot.js"
assert "mobile-open" in boot_js, \
"toggleMobileFiles() must toggle mobile-open class on the right panel"
assert "function closeMobileWorkspacePanelFromChat(e)" in boot_js
assert "$('mainChat')?.addEventListener('pointerdown', closeMobileWorkspacePanelFromChat);" in boot_js
def test_new_conversation_closes_mobile_sidebar():
"""New conversation must close the mobile drawer so the chat pane is visible immediately."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
# Handler is now multi-line — search for the full block rather than a single line.
assert "$('btnNewChat').onclick" in boot_js, "btnNewChat onclick handler missing from static/boot.js"
# Find the handler block and verify closeMobileSidebar appears in it.
# The handler grew comments after #1432 (in-flight guard refactor), so use a
# generous window to cover the full handler body.
idx = boot_js.find("$('btnNewChat').onclick")
handler_block = boot_js[idx:idx+1500]
assert "closeMobileSidebar" in handler_block, \
"btnNewChat handler must closeMobileSidebar() after creating the new session"
shortcut_line = next((ln for ln in boot_js.splitlines() if "e.key==='k'" in ln or "e.key === 'k'" in ln), "")
assert shortcut_line, "Cmd/Ctrl+K new chat shortcut missing from static/boot.js"
shortcut_block = "\n".join(boot_js.splitlines()[boot_js.splitlines().index(shortcut_line):boot_js.splitlines().index(shortcut_line)+24])
assert "closeMobileSidebar" in shortcut_block, \
"Cmd/Ctrl+K new chat shortcut must closeMobileSidebar() after creating the new session"
def test_new_conversation_shortcut_works_while_busy():
"""Cmd/Ctrl+K should still create a new conversation while the current one is busy.
The previous behavior gated the shortcut on !S.busy, which meant users had
to wait for a long generation to finish before they could start something
new — the exact moment they want to switch context.
"""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
shortcut_line = next((ln for ln in boot_js.splitlines() if "e.key==='k'" in ln or "e.key === 'k'" in ln), "")
assert shortcut_line, "Cmd/Ctrl+K new chat shortcut missing from static/boot.js"
# Inspect the next 10 lines after the keybinding match — the gating block
# would live there if it had been kept.
idx = boot_js.splitlines().index(shortcut_line)
shortcut_block = "\n".join(boot_js.splitlines()[idx:idx + 10])
# Strip the existing message-count guard (which is unrelated and stays) so
# we only check for an S.busy gate on the newSession() call itself.
assert "if(!S.busy)" not in shortcut_block, (
"Cmd/Ctrl+K must not be blocked by the current session's busy state"
)
assert "if (!S.busy)" not in shortcut_block, (
"Cmd/Ctrl+K must not be blocked by the current session's busy state"
)
def test_mobile_titlebar_has_new_conversation_button():
"""Mobile titlebar shows the New Conversation action and keeps it next to reload."""
header_match = re.search(
r'