"""Regression tests for two related sidebar/panel UI fixes. 1. Workspace panel header collapse priority — as the right panel narrows, the git-badge must vanish first, the "Workspace" label second, and the icon buttons last. Previously all three compressed simultaneously because `.panel-header` used `justify-content:space-between` with no flex-shrink ratios or container queries. 2. Project color dot truncation — the dot used to be appended INSIDE the `.session-title` span (which is `overflow:hidden;text-overflow:ellipsis`), so the dot got clipped along with long titles. Fix moves the dot to a flex sibling in `.session-title-row` between title and timestamp, and moves `.session-time` from `position:absolute` to flex flow so the title's `flex:1` bound stops at the timestamp's left edge. """ import pathlib REPO = pathlib.Path(__file__).parent.parent SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8") def _extract_js_function_body(src: str, name: str) -> str: start = src.find(f"function {name}(") assert start >= 0, f"function {name} not found" brace = src.find("{", start) assert brace >= 0, f"function {name} body not found" depth = 1 i = brace + 1 while depth > 0 and i < len(src): if src[i] == "{": depth += 1 elif src[i] == "}": depth -= 1 i += 1 assert depth == 0, f"function {name} body did not close" return src[start:i] # ── Bug 1: workspace panel header collapse priority ────────────────────────── class TestWorkspacePanelCollapsePriority: def test_rightpanel_is_a_size_container(self): """The right panel must declare itself as an inline-size container so its descendants can run @container queries against the panel's width.""" # Look at the .rightpanel rule body idx = STYLE_CSS.find(".rightpanel{") assert idx >= 0, ".rightpanel rule not found" rule = STYLE_CSS[idx: idx + 1200] assert "container-type:inline-size" in rule, ( ".rightpanel must declare container-type:inline-size for the " "header collapse-priority @container queries to work." ) assert "container-name:rightpanel" in rule, ( ".rightpanel should be named 'rightpanel' so descendants can " "scope @container queries explicitly." ) def test_panel_header_no_longer_uses_space_between(self): """`justify-content:space-between` was the root cause of the simultaneous-shrink behaviour. The header now uses `gap` and `margin-left:auto` on `.panel-actions` to push them right.""" idx = STYLE_CSS.find(".panel-header{") rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx) + 1] assert "justify-content:space-between" not in rule, ( "panel-header still uses justify-content:space-between — that " "compresses all three children simultaneously." ) assert "gap:6px" in rule # Note: `.panel-header` was changed from overflow:hidden to overflow:visible # in #1775 so its tooltip pseudo-elements can escape the header bar # (otherwise the workspace-panel header tooltips like "New file" get # clipped). The title-text ellipsis is preserved by the inner span # `.panel-header > span:first-child` which has its own # overflow:hidden + text-overflow:ellipsis. So we check that EITHER # the parent uses overflow:hidden (legacy) or that the inner span # handles its own ellipsis (current). if "overflow:hidden" not in rule: inner_span_idx = STYLE_CSS.find(".panel-header > span:first-child{") assert inner_span_idx != -1, ( ".panel-header lost overflow:hidden but no inner span " "rule (.panel-header > span:first-child) handles the " "title-text ellipsis as a fallback." ) inner_rule = STYLE_CSS[inner_span_idx: STYLE_CSS.find("}", inner_span_idx) + 1] assert "overflow:hidden" in inner_rule and "text-overflow:ellipsis" in inner_rule, ( ".panel-header > span:first-child must own the ellipsis " "behaviour now that the parent is overflow:visible." ) def test_panel_actions_pushed_right_and_never_shrinks(self): """`.panel-actions` must have flex-shrink:0 and margin-left:auto so the icon buttons stay visible no matter how narrow the panel gets, and they sit at the right edge once `space-between` is removed.""" idx = STYLE_CSS.find(".panel-actions{") rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx)] assert "flex-shrink:0" in rule, ( ".panel-actions must not shrink — icons are the priority." ) assert "margin-left:auto" in rule, ( ".panel-actions must use margin-left:auto to push to the right " "now that justify-content:space-between is gone." ) def test_workspace_label_shrinks_with_ellipsis(self): """The "Workspace" label (`panel-header > span:first-child`) must shrink with ellipsis truncation rather than overflow uncontrollably.""" # Find the rule sel = ".panel-header > span:first-child" idx = STYLE_CSS.find(sel) assert idx >= 0, f"Selector {sel!r} not found in style.css" rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx)] assert "text-overflow:ellipsis" in rule assert "min-width:0" in rule assert "flex-shrink:2" in rule # shrinks before icons (icons are 0) def test_git_badge_shrinks_first(self): """`.git-badge` must shrink faster than the label so it disappears first as the panel narrows.""" idx = STYLE_CSS.find(".git-badge{") rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx)] assert "flex-shrink:3" in rule, ( ".git-badge must have flex-shrink:3 so it shrinks before the " "label (flex-shrink:2) and the icons (flex-shrink:0)." ) def test_container_query_hides_git_badge_first(self): """At narrow widths the git badge gets `display:none` BEFORE the label is hidden — git badge first.""" # The container query block for hiding git badge assert "@container rightpanel (max-width: 220px)" in STYLE_CSS, ( "Missing @container rule to hide .git-badge at narrow widths" ) # Find the block and check git-badge is targeted idx = STYLE_CSS.find("@container rightpanel (max-width: 220px)") block = STYLE_CSS[idx: idx + 200] assert ".git-badge{display:none" in block def test_container_query_hides_label_at_narrower_width(self): """The label hides at a NARROWER threshold than the git badge — confirms collapse priority order.""" assert "@container rightpanel (max-width: 160px)" in STYLE_CSS idx = STYLE_CSS.find("@container rightpanel (max-width: 160px)") block = STYLE_CSS[idx: idx + 200] assert ".panel-header > span:first-child{display:none" in block def test_breakpoints_in_correct_order(self): """Sanity: the git-badge breakpoint (220px) must be wider than the label breakpoint (160px). Otherwise the label would vanish first.""" # Both queries exist — extract numeric thresholds import re matches = re.findall( r"@container rightpanel \(max-width:\s*(\d+)px\)", STYLE_CSS ) assert len(matches) >= 2 thresholds = [int(m) for m in matches] # First threshold (git badge) must be larger than label threshold assert thresholds[0] > thresholds[1], ( f"Git badge breakpoint ({thresholds[0]}px) must be wider than " f"label breakpoint ({thresholds[1]}px) so git-badge hides first." ) # ── Bug 2: project color dot placement ─────────────────────────────────────── class TestProjectDotPlacement: def test_dot_appended_to_title_row_not_title(self): """The project dot must be appended to `titleRow` (a flex sibling of the title and timestamp), not to the title span (which truncates with ellipsis and would clip the dot off long titles).""" # Find _renderOneSession body body = _extract_js_function_body(SESSIONS_JS, "_renderOneSession") # Must append dot to titleRow assert "titleRow.appendChild(dot)" in body, ( "Project dot must be appended to titleRow as a flex sibling, " "not inside the truncating title span" ) # Must NOT append dot to title (the truncating span) assert "title.appendChild(dot)" not in body, ( "Old behaviour — dot inside title span gets clipped by the " "ellipsis truncation. Dot must live in titleRow instead." ) def test_dot_placed_between_title_and_timestamp(self): """The dot is appended AFTER title.appendChild and BEFORE ts append — that ordering puts the dot between the title and the timestamp in the flex row.""" body = _extract_js_function_body(SESSIONS_JS, "_renderOneSession") title_pos = body.find("titleRow.appendChild(title);") dot_pos = body.find("titleRow.appendChild(dot);") ts_pos = body.find("titleRow.appendChild(ts);") assert title_pos >= 0 and dot_pos >= 0 and ts_pos >= 0 assert title_pos < dot_pos < ts_pos, ( f"Order must be title → dot → ts in the title row " f"(positions: {title_pos}, {dot_pos}, {ts_pos})" ) def test_session_time_uses_flex_flow_not_absolute(self): """`.session-time` must use margin-left:auto in flex flow, not position:absolute. Without this the title's flex:1 runs underneath the absolute-positioned timestamp and the dot has no anchor.""" # Get the bare .session-time rule (not .session-time.is-hidden, not # .session-item:hover .session-time) idx = STYLE_CSS.find(".session-time{") rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx)] assert "position:absolute" not in rule, ( ".session-time must not be position:absolute — bug 2 requires " "it to live in the flex flow of .session-title-row." ) assert "margin-left:auto" in rule, ( ".session-time must use margin-left:auto to push to the right " "edge of the flex row." ) def test_session_project_dot_no_inline_block_baggage(self): """`.session-project-dot` is now a flex sibling — the row's gap:6px handles spacing, so the old `margin-left:4px` and `vertical-align:middle` are unnecessary and only confuse layout.""" idx = STYLE_CSS.find(".session-project-dot{") assert idx >= 0 rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx)] assert "margin-left:4px" not in rule, ( "Old margin-left:4px is unnecessary now — gap:6px on " ".session-title-row handles spacing" ) assert "vertical-align:middle" not in rule, ( "vertical-align is meaningless inside flex flow" ) assert "flex-shrink:0" in rule, ( "Dot must not shrink (would disappear at narrow sidebar widths)" ) def test_session_item_padding_at_rest_no_longer_reserves_86px(self): """At rest (no hover, no streaming, no unread), the session item no longer reserves 86px for the absolute timestamp — that space was wasted now that the timestamp lives in flex flow.""" # Find the FIRST .session-item{ rule (the desktop one, not the # mobile-touch override). idx = STYLE_CSS.find(".session-item{padding:8px") assert idx >= 0, "Could not find desktop .session-item padding rule" rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx)] assert "padding:8px 8px" in rule, ( f"Expected 'padding:8px 8px' for at-rest session items, got: {rule!r}" ) # Mobile also drops from 86px to 40px — the absolute timestamp is # gone (now flex-flow), so only the always-visible action button's # footprint (26px + 6px gap ≈ 32px, rounded to 40px) needs reservation. assert ".session-item{min-height:44px;padding:10px 40px 10px 12px;}" in STYLE_CSS def test_session_item_expands_padding_on_hover_and_attention(self): """PR #1110: Touch layout-shift fix — :hover removed from the COMBINED padding-right selector. Touch devices (iPad, phone) see hover:none so they skip the @media (hover:hover) block below. Mouse devices see hover:hover and get the padding-right on hover. streaming/unread/focus-within/menu-open expand to 40px for all devices.""" # Touch-safe combined rule (no :hover in this one) sel = ( ".session-item.streaming,.session-item.unread," ".session-item:focus-within," ".session-item.menu-open" ) idx = STYLE_CSS.find(sel) assert idx >= 0, ( "Combined streaming/unread/focus-within/menu-open padding rule not found" ) rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx)] assert "padding-right:40px" in rule # Desktop hover padding restored via @media (hover:hover) — mouse devices only assert "@media (hover:hover)" in STYLE_CSS assert ".session-item:hover{padding-right:40px;}" in STYLE_CSS