"""Unit: the phase-130 (task 03, TODO L6) chat cluster rows — CSS. The bubble's metadata reads as organized rows, top to bottom: the answer text → the answer figure (phase 124, unchanged) → the source chips row (text refs only) → the source images row (`.msg-images`, phase 130 task 01) → the "Nearby docs" row (phase 113, unchanged) → the actions row (`.msg-actions`, phase 130 task 02) — never one soup div. The DOM is built by app.js (pinned by test_frontend_actions_cluster and test_source_chip_quality); THIS module pins the row-level layout that makes the clusters read as clusters: * the actions row right-aligns its buttons as a cluster — the "bottom right" is the row's ``justify-content: flex-end``, and the per-button auto margins (the phase-49/59 ``.tune-btn`` / ``.retry-btn`` / ``.save-as-doc-btn`` margin mechanisms) are GONE; * the images row wraps its figures with the chips row's rhythm (the base ``.msg-meta`` wrap + 0.35rem gap + center align — pinned explicitly so the two clusters cannot drift apart); * the audit (task 03 item 3): the chips row and the related row are untouched, the ``.source-image`` figure styles are untouched, and the mobile squeeze pins nothing about the actions row left — the cluster stays right-aligned at every width. House source-pin pattern (no browser); the browser-level bounding-box assertions are E2E-gated by tests/e2e/test_chat_answer_clusters.py (task 04). """ from __future__ import annotations import re from pathlib import Path STYLES_CSS = Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css" def _css() -> str: return STYLES_CSS.read_text(encoding="utf-8") def _block(css: str, selector: str) -> str: """The first `` { … }`` rule body — the house pin pattern (the cluster selectors are unique in the sheet).""" m = re.search(re.escape(selector) + r" \{([^}]*)\}", css) assert m, f"styles.css must style {selector}" return m.group(1) def _media_block(css: str, query: str) -> str: """The body of the ``@media ()`` block — the house non- greedy pattern (the sheet indents the block's nested closing braces, so the FIRST column-0 ``}`` is the block's own end).""" pattern = "@media \\(" + re.escape(query) + r"\) \{([\s\S]*?)\n\}" m = re.search(pattern, css) assert m, f"{query} media query missing" return m.group(1) # --------------------------------------------------------------------------- # the actions cluster — right-aligned by the ROW, no per-button margins # --------------------------------------------------------------------------- def test_actions_row_rule_right_aligns_the_cluster() -> None: """``.msg-meta.msg-actions`` right-aligns its contents — the TODO's "bottom right" is the bubble's LAST row (the DOM order, phase 130 task 02) + this row-level justify. No positioning tricks: the row keeps the base .msg-meta flex/wrap/rhythm, only the justify moves.""" css = _css() assert re.search(r"\.msg-meta\.msg-actions \{", css), ( "the .msg-meta.msg-actions row rule is missing" ) body = _block(css, ".msg-meta.msg-actions") assert "justify-content: flex-end" in body, ( "the row's justify does the 'bottom right' (right edge)" ) # Row-level only — no absolute/float/transform tricks. for prop in ("position", "float", "transform", "margin", "padding"): assert prop not in body, ( f"the actions row is a plain flex row ({prop} is a " "positioning trick — the row order is DOM order)" ) def test_no_per_button_auto_margins_remain() -> None: """The per-button auto margins (the pre-phase-130 mechanism the markLastRetryable comment named) are gone from ALL THREE action buttons — grep-confirmed: the buttons are plain flex items of the actions row, and the row's justify does the alignment. (Completion criterion: 'right-aligned by .msg-actions alone'.)""" css = _css() for selector in (".tune-btn", ".retry-btn", ".save-as-doc-btn"): body = _block(css, selector) assert "margin-left: auto" not in body, ( f"{selector}: the per-button auto margin must be gone — " "the .msg-actions row's justify right-aligns the cluster" ) assert "margin-inline-start: auto" not in body, ( f"{selector}: the per-button auto margin must be gone" ) assert "margin-right: auto" not in body and "margin-inline-end: auto" not in body def test_button_family_is_untouched() -> None: """Removing the auto margins is the ONLY change to the button rules — the phase-49/59 visual family (pill, ≥44px, line border, ink-soft palette, brand/neutral hovers, 14px glyph) is intact.""" css = _css() for selector in (".tune-btn", ".retry-btn", ".save-as-doc-btn"): body = _block(css, selector) for prop in ( "display: inline-flex", "min-height: 44px", "padding: 0.35rem 0.8rem", "border-radius: 999px", "border: 1px solid var(--line)", "color: var(--ink-soft)", "font-size: 0.82rem", "cursor: pointer", ): assert prop in body, f"{selector} must keep the family ({prop})" assert f"{selector} svg {{ width: 14px; height: 14px" in css assert f"{selector}:hover" in css # --------------------------------------------------------------------------- # the images cluster — wraps like the chips row # --------------------------------------------------------------------------- def test_images_row_rule_wraps_like_the_chips_row() -> None: """``.msg-meta.msg-images`` pins the chips row's rhythm EXPLICITLY (wrap + the base .msg-meta 0.35rem gap + center align) — no new spacing values (task 03: 'match the EXISTING .msg-meta gap/align values'). A future base-rule change cannot drift the two clusters apart.""" css = _css() assert re.search(r"\.msg-meta\.msg-images \{", css), ( "the .msg-meta.msg-images row rule is missing" ) body = _block(css, ".msg-meta.msg-images") assert "flex-wrap: wrap" in body, "the figures wrap (many image refs)" assert "gap: 0.35rem" in body, ( "the images row's gap MUST equal the chips row's gap " "(the base .msg-meta 0.35rem — no invented spacing)" ) assert "align-items: center" in body, "the row aligns like the chips row" def test_base_msg_meta_rhythm_is_unchanged() -> None: """The base ``.msg-meta`` rule (the chips row, the related row, and every meta row's rhythm) is untouched: flex + wrap + 0.35rem gap + center align + the soft-ink 0.75rem text.""" css = _css() body = _block(css, ".msg-meta") for prop in ( "font-size: 0.75rem", "color: var(--ink-soft)", "display: flex", "flex-wrap: wrap", "gap: 0.35rem", "align-items: center", ): assert prop in body, f"the base .msg-meta rhythm must keep {prop}" # --------------------------------------------------------------------------- # the audit (task 03 item 3) — neighbors untouched # --------------------------------------------------------------------------- def test_chips_row_is_untouched() -> None: """The chips row's look is the completed phase-26/113 surface — mono pill, brand-soft fill, line border, nowrap ellipsis (the min-width: 0 escape). Phase 130 only removed the buttons from this row's neighbors, never the chip's styling.""" css = _css() body = _block(css, ".source-chip") for prop in ( "font-family: var(--mono)", "background: var(--brand-soft)", "border: 1px solid var(--line)", "border-radius: 999px", "white-space: nowrap", "min-width: 0", "text-overflow: ellipsis", ): assert prop in body, f".source-chip must keep {prop}" assert ".source-chip:hover" in css def test_related_row_is_untouched() -> None: """The phase-113 "Nearby docs" row keeps its de-emphasized look (dashed border, transparent fill, muted mono links — never a citation) and still joins the .msg-meta family.""" css = _css() body = _block(css, ".related-doc") for prop in ( "border: 1px dashed var(--line)", "background: transparent", "font-family: var(--mono)", "white-space: nowrap", ): assert prop in body, f".related-doc must keep {prop}" assert ".related-docs-label" in css assert "Nearby docs" not in css # the label text lives in app.js, not CSS def test_source_image_figure_is_untouched() -> None: """The ``.source-image`` figure (phase 122) was already sized for a row context — the 96px contain img on the theme's surface, the clamped muted caption. Phase 130 moves it to its OWN row, not to a new scale.""" css = _css() assert "max-height: 96px" in _block(css, ".source-image-img"), ( "the compact source figure keeps its 96px row-scale cap" ) assert "object-fit: contain" in _block(css, ".source-image-img") assert "-webkit-line-clamp: 2" in _block(css, ".source-image-caption") # The answer figure (phase 124) is a DIFFERENT scale — untouched. assert "max-height: 320px" in _block(css, ".answer-image-img") def test_mobile_pins_the_actions_row_left_nowhere() -> None: """The mobile squeeze (≤640px) may keep the ≥44px touch floor but must NOT pin the actions row left or restyle it: the cluster stays right-aligned at every width (the row's justify is width-agnostic, the .msg-body flex gap stacks the rows).""" css = _css() mobile = _media_block(css, "max-width: 640px") for selector in (".tune-btn", ".retry-btn", ".msg-actions", ".msg-images"): for m in re.finditer(re.escape(selector) + r" \{([^}]*)\}", mobile): body = m.group(1) assert "justify-content" not in body, ( f"{selector}: the mobile block must not re-justify the " "cluster (the actions row stays right-aligned)" ) assert "margin-left" not in body and "margin-inline" not in body, ( f"{selector}: the mobile block must not re-margin the " "cluster (no left pin, no margin reset)" ) assert "position" not in body # The mobile squeeze's only tune/retry rules are the 44px floors # (pre-existing, untouched by this phase). assert ".tune-btn { min-height: 44px; }" in mobile assert ".retry-btn { min-height: 44px; }" in mobile