phase: 130_chat_answer_clusters
**Phase 130 — final verification pass: 100% green, complete.** - **Verified:** chips-only row (no chip for image docs) → images-only row (`.msg-images`) → related row → `.msg-actions` last; `getActionsRow` used by all 3 buttons + stopped/failed notes; `markLastRetryable` save-re-append hack gone; in-place chip fallback on image-load failure; CSS right-align with per-button auto margins removed — implementation matches the design in every detail. - **Completion criteria:** (1) mixed citation renders chips→images→actions-last, DOM-asserted ✅ (2) all 3 buttons in one right-aligned `.msg-actions`; non-admin sees Retry-only; restore path identical ✅ (3) 404-bytes figure → plain chip, unit-pinned ✅ (4) E2E isolated ✅ (5) no behavior change (modal affordances, related tier, ARIA names) ✅ - **Tests/lint/coverage:** `uv run pytest tests/e2e/test_chat_answer_clusters.py -v --no-cov` → 4 passed; `uv run pytest --cov=app --cov-report=term-missing` → 3061 passed, **99%** app coverage; the 4 E2E files this phase modified (test_answer_images, test_image_documents, test_pdf_support, test_responsive_polish) → 22 passed in isolation; `uv run ruff check .` clean, `uv run pyright` → 0 errors. - **Docs:** README.md — verified consistent in full (page table vs routes, env-var coverage vs `.env.example`, phase-130 cluster wording already in tree); no changes needed. AGENTS.md — fixed one pre-existing inconsistency: rule 5 still cited the retired "centered 46rem chat column"; now states the 72rem container contract (phase 100). - **Decisions:** PLAN.md §7.1 still says 46rem, but it's owner-managed (phase 100 records the supersession pending owner sign-off) and out of my editable scope — AGENTS.md now cross-references the supersession. - **Next pending phase:** `131_read_line_ranges` (todo/).
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
"""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 ``<selector> { … }`` 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 (<query>)`` 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
|
||||
Reference in New Issue
Block a user