diff --git a/static/style.css b/static/style.css index bb98fba2..c5d0e6e3 100644 --- a/static/style.css +++ b/static/style.css @@ -2912,10 +2912,14 @@ .pwa-sidebar-edge-guard{display:none;} @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;} + html[data-workspace-panel="closed"] .rightpanel{width:0 !important;min-width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;} + .layout.workspace-panel-collapsed .rightpanel{width:0 !important;min-width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;} html[data-workspace-panel="closed"] .workspace-panel-edge-toggle{display:inline-flex;opacity:1;pointer-events:auto;transform:translateY(-50%);} html[data-workspace-panel="open"] .workspace-panel-edge-toggle{display:none;} + /* Keep the center readable before the rails hit their resize floor. */ + .main{flex:1 1 420px;min-width:420px;} + .sidebar{flex-shrink:1;min-width:180px;} + .rightpanel{flex-shrink:1;min-width:180px;} } /* Sidebar collapse breakpoint matches `_isDesktopWidth()` (min-width:641px) so diff --git a/tests/test_issue5545_three_panel_layout.py b/tests/test_issue5545_three_panel_layout.py new file mode 100644 index 00000000..cd7fd255 --- /dev/null +++ b/tests/test_issue5545_three_panel_layout.py @@ -0,0 +1,134 @@ +""" +Issue #5545 three-panel desktop layout regression checks. + +These tests stay source-level so they can verify the declared CSS contract +without needing a browser or rendered layout. +""" + +import pathlib +import re + +REPO = pathlib.Path(__file__).parent.parent +CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8") +BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8") + + +def _media_blocks(kind): + pattern = re.compile(rf"@media\s*\(\s*{kind}\s*:\s*(\d+)px\s*\)\s*\{{") + blocks = [] + for match in pattern.finditer(CSS): + width_px = int(match.group(1)) + 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((width_px, CSS[open_brace + 1:idx])) + break + return blocks + + +def _normalize_css(css): + return re.sub(r"\s+", "", css) + + +def _media_block(kind, width_px, needle): + normalized_needle = _normalize_css(needle) + for current_width, block in _media_blocks(kind): + if current_width == width_px and normalized_needle in _normalize_css(block): + return block + raise AssertionError(f"Missing @media({kind}:{width_px}px) block containing {needle!r}") + + +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 test_desktop_three_panel_contract_gives_main_readable_floor(): + desktop = _media_block("min-width", 901, ".main{flex:1 1 420px;min-width:420px;}") + main = _declarations(_rule_body(desktop, ".main")) + + assert main.get("flex") == "1 1 420px" + assert main.get("min-width") == "420px" + + +def test_desktop_side_rails_can_shrink_to_resize_minima(): + desktop = _media_block("min-width", 901, ".sidebar{flex-shrink:1;min-width:180px;}") + sidebar = _declarations(_rule_body(desktop, ".sidebar")) + rightpanel = _declarations(_rule_body(desktop, ".rightpanel")) + closed_rightpanel = _declarations( + _rule_body(desktop, 'html[data-workspace-panel="closed"] .rightpanel') + ) + collapsed_rightpanel = _declarations( + _rule_body(desktop, ".layout.workspace-panel-collapsed .rightpanel") + ) + + assert re.search(r"\bSIDEBAR_MIN\s*=\s*180\b", BOOT_JS) + assert re.search(r"\bPANEL_MIN\s*=\s*180\b", BOOT_JS) + assert sidebar.get("flex-shrink") == "1" + assert sidebar.get("min-width") == "180px" + assert rightpanel.get("flex-shrink") == "1" + assert rightpanel.get("min-width") == "180px" + assert closed_rightpanel.get("width") == "0 !important" + assert closed_rightpanel.get("min-width") == "0 !important" + assert collapsed_rightpanel.get("width") == "0 !important" + assert collapsed_rightpanel.get("min-width") == "0 !important" + + +def test_compact_breakpoint_900px_remains_hidden_right_panel_boundary(): + assert "@media(max-width:900px)" in CSS or "@media (max-width: 900px)" in CSS + + compact = _media_block("max-width", 900, ".rightpanel{display:none}") + rightpanel = _declarations(_rule_body(compact, ".rightpanel")) + workspace_toggle = _declarations(_rule_body(compact, ".workspace-toggle-btn")) + mobile_files = _declarations(_rule_body(compact, ".mobile-files-btn")) + + assert rightpanel.get("display") == "none" + assert workspace_toggle.get("display") == "inline-flex!important" + assert mobile_files.get("display") == "inline-flex!important" + + +def test_mobile_slide_over_breakpoint_640px_remains_intact(): + assert "@media(max-width:640px)" in CSS or "@media (max-width: 640px)" in CSS + + mobile = _media_block("max-width", 640, ".rightpanel.mobile-open") + rightpanel = _declarations(_rule_body(mobile, ".rightpanel")) + rightpanel_open = _declarations(_rule_body(mobile, ".rightpanel.mobile-open")) + + assert rightpanel.get("display") == "flex!important" + assert rightpanel.get("position") == "fixed" + assert rightpanel.get("right") == "calc(-1 * var(--mobile-rightpanel-width))!important" + assert rightpanel.get("width") == "var(--mobile-rightpanel-width)!important" + assert rightpanel.get("box-shadow") == "none!important" + assert rightpanel_open.get("right") == "0!important" + + +def test_no_unmatched_desktop_hide_breakpoint_above_900(): + hidden_widths = [] + for width_px, block in _media_blocks("max-width"): + if re.search(r"\.rightpanel\s*\{\s*display\s*:\s*none", block): + hidden_widths.append(width_px) + + assert 900 in hidden_widths + assert all(width_px <= 900 for width_px in hidden_widths)