"""Proposal and issue detail pages scroll with the window. Gabriel's review of muse proposal #12 could not be reached because those pages lock the window (`body.app-shell`) and only a short inner column scrolls. These pages now use a normal document scroll and a 3/4 + 1/4 grid that stacks on narrow screens. The issue list and other app-shell pages stay locked. """ from __future__ import annotations import re from pathlib import Path ROOT = Path(__file__).resolve().parents[1] PROPOSAL_HTML = ROOT / "musehub/templates/musehub/pages/proposal_detail.html" ISSUE_HTML = ROOT / "musehub/templates/musehub/pages/issue_detail.html" ISSUE_LIST_HTML = ROOT / "musehub/templates/musehub/pages/issue_list.html" PROPOSAL_SCSS = ROOT / "src/scss/pages/_proposal-detail.scss" ISSUES_SCSS = ROOT / "src/scss/pages/_issues.scss" APP_CSS = ROOT / "musehub/templates/musehub/static/app.css" MAIN_PY = ROOT / "musehub/main.py" _GRID = "grid-template-columns: minmax(0, 3fr) minmax(0, 1fr)" _NARROW = "grid-template-columns: minmax(0, 1fr)" def _block(source: str, selector: str) -> str: """Return the first rule body whose selector line equals `selector`.""" pattern = rf"(?m)^{re.escape(selector)}\s*\{{" match = re.search(pattern, source) assert match is not None, f"missing selector {selector}" start = match.end() depth = 1 i = start while i < len(source) and depth: if source[i] == "{": depth += 1 elif source[i] == "}": depth -= 1 i += 1 return source[start : i - 1] def test_tier1_templates_do_not_opt_into_app_shell() -> None: """Unit: detail templates must not set the viewport-lock body class.""" for path in (PROPOSAL_HTML, ISSUE_HTML): text = path.read_text(encoding="utf-8") assert "body_class" not in text, path.name assert "{% block body_class %}app-shell{% endblock %}" not in text def test_tier1_issue_list_stays_on_app_shell() -> None: """Unit: this change does not unlock the issue list page.""" text = ISSUE_LIST_HTML.read_text(encoding="utf-8") assert 'body_class %}app-shell' in text def test_tier2_columns_are_three_quarters_and_one_quarter() -> None: """Integration: both detail layouts use the same 3fr / 1fr grid.""" for path, selector in ( (PROPOSAL_SCSS, ".prd-layout"), (ISSUES_SCSS, ".isd-layout"), ): block = _block(path.read_text(encoding="utf-8"), selector) assert _GRID in block, path.name assert "@media (max-width: 900px)" in block assert _NARROW in block def test_tier2_detail_columns_do_not_scroll_themselves() -> None: """Integration: the mouse scrolls the page, not an inner box.""" proposal = PROPOSAL_SCSS.read_text(encoding="utf-8") issues = ISSUES_SCSS.read_text(encoding="utf-8") for source, selector in ( (proposal, ".prd-page"), (proposal, ".prd-main"), (proposal, ".proposal-sidebar"), (issues, ".isd-page"), (issues, ".isd-main"), (issues, ".isd-sidebar"), ): block = _block(source, selector) assert "overflow-y: auto" not in block, selector assert "overflow: hidden" not in block, selector def test_tier3_compiled_css_matches_the_grid() -> None: """End to end: the stylesheet the browser loads contains the new grid.""" css = APP_CSS.read_text(encoding="utf-8") wide = "grid-template-columns:minmax(0, 3fr) minmax(0, 1fr)" assert css.count(wide) >= 2 assert ".prd-layout{" in css assert ".isd-layout{" in css assert ".prd-main{min-width:0;padding-bottom:var(--space-10);overflow:visible}" in css assert ".isd-main{min-width:0;overflow:visible}" in css def test_tier4_narrow_breakpoint_is_present_for_both_pages() -> None: """Stress-shaped contract: both pages collapse at the same breakpoint.""" css = APP_CSS.read_text(encoding="utf-8") narrow = "grid-template-columns:minmax(0, 1fr)" assert "@media(max-width: 900px){.prd-layout{" + narrow + "}" in css assert "@media(max-width: 900px){.isd-layout{" + narrow + "}" in css def test_tier5_page_wrappers_are_not_height_locked() -> None: """Data integrity: page wrappers do not reclaim a viewport height.""" for path, selector in ( (PROPOSAL_SCSS, ".prd-page"), (ISSUES_SCSS, ".isd-page"), ): block = _block(path.read_text(encoding="utf-8"), selector) assert "100dvh" not in block assert "min-height: 0" not in block def test_tier6_compiled_stylesheet_stays_a_single_file() -> None: """Performance: layout change does not add a second stylesheet.""" assert APP_CSS.is_file() assert APP_CSS.stat().st_size < 2_000_000 def test_tier7_csp_still_blocks_inline_scripts() -> None: """Security: this layout fix must not allow unsafe-inline scripts.""" text = MAIN_PY.read_text(encoding="utf-8") start = text.index('response.headers["Content-Security-Policy"]') header = text[start : text.index("upgrade-insecure-requests", start)] script_src = header.split("script-src", 1)[1].split(";", 1)[0] assert "unsafe-inline" not in script_src assert "'self'" in script_src