phase: 156_table_ux_polish
Build and Push Containers / build-and-push-app (push) Successful in 2m15s
Build and Push Containers / build-and-push-db (push) Successful in 11s

Everything is verified. All gates green on the current working tree. Final report:

**Phase 156 — final verification pass (all 4 task files present in `complete/` — no outstanding work)**

- **Verified (not re-implemented):** bounded-panel `.table-wrap` (`max-height: min(70vh, 64rem)`, `overflow: auto`, opaque sticky `th` via `var(--brand-soft)`), `.cell-clip` ellipsis rule + the five label families in the three renderers, the added aria-labels, unit pins (incl. `.md-table-wrap` byte-unchanged scope guard, `#git-sources-table-wrap` harmony — no divergent rule), dedicated E2E suite (sticky ×3 tables, horizontal contract, computed-style ellipsis + reachable full text, mobile hamburger + scroll + pin)
- **Tests/lint/coverage:** `uv run pytest --cov=app --cov-report=term-missing` → **3570 passed, 99% on `app/`** (gate >90%); `uv run pytest tests/e2e/test_table_ux_polish.py -v --no-cov` (DB up) → **6/6 passed in isolation**; `uv run ruff check . && uv run pyright` → **clean** (0 errors)
- **Completion criteria:** sticky header all 3 tables (exact-0 offset while `scrollTop > 0`) ✅ · horizontal scroll unchanged ✅ · ellipsis computed style + actual clip + title/aria reachable ✅ · mobile ≤640px contract ✅ · full suite/E2E/lint/coverage ✅ · atomic commit: **harness commits this phase after the pass** (per pipeline protocol — no git history commands run by me)
- **Documentation:** AGENTS.md phase-156 wording (bounded panels, sticky headers, `.cell-clip` + aria-label idiom) matches the implementation exactly — verified consistent; README.md reviewed in full (pages/URLs vs registered routes, all `.env.example` vars documented, Quick Start, eval/llm scripts, compose prod profile, E2E examples) — verified consistent, no changes needed (README describes no table layout/scroll, so no phase-156 mention required)
- **Deviations:** none — all work was already in the working tree; this pass re-verified every gate and the docs
- **Next pending phase:** `157_chat_chip_cap`
This commit is contained in:
2026-10-04 20:46:57 -04:00
parent 742eef955b
commit 1513981c1c
33 changed files with 1790 additions and 58 deletions
+9 -2
View File
@@ -173,10 +173,17 @@ def test_row_checkbox_is_built_in_the_stored_row_branch() -> None:
assert 'hiddenBox.setAttribute(' in make, "the label is set via setAttribute"
assert "hiddenBox.innerHTML" not in _js(), "XSS contract: no innerHTML on the box"
assert "hiddenLabel.innerHTML" not in _js(), "XSS contract: no innerHTML on the label"
assert 'document.createTextNode("Hidden")' in make, (
# Phase 156 (task 02, A3): the visible text is the .cell-clip
# span — a squeezed cell shows "…" instead of a hard cut; the
# checkbox's aria-label names the full source, so the full text
# stays reachable.
assert "hiddenClip.className = \"cell-clip\"" in make, (
"the clip label span (phase 156, A3)"
)
assert 'hiddenClip.textContent = "Hidden"' in make, (
"the visible text label — never aria-label-only (WCAG)"
)
assert "hiddenLabel.append(hiddenBox, document.createTextNode(\"Hidden\"))" in make
assert "hiddenLabel.append(hiddenBox, hiddenClip)" in make
assert 'hiddenBox.addEventListener("change", () => toggleHidden(s, hiddenBox))' in make
# The env-fallback branch (else) has no checkbox.
else_slice = make[else_i : make.find("tr.appendChild(actTd)", else_i)]
+54 -5
View File
@@ -33,6 +33,14 @@ documents) + ``header.js``'s reveal-for-admin block;
between Updated and Share in the shell's History view, and the
``.stale-pill`` rose-family CSS in ``styles.css``.
Phase 156 (task 02): the ellipsis hint on the History action labels —
the ``Create link`` / ``Copy`` / ``Unshare`` / ``Delete`` visible
texts are ``.cell-clip`` spans (a squeezed cell shows "…" instead of
a hard cut) and the audit's accessible-name check lands: every one
of the four buttons' ``aria-label`` names the chat title (the
phase-66 idiom — nothing was missing, the pins keep it), so the
full text stays reachable.
The Containerfile stage-1 coverage (the shell copied, the view modules
bundled into the router) is pinned dynamically by
``tests/integration/test_containerfile_assets.py`` — a page or module
@@ -410,6 +418,15 @@ def test_two_step_delete_confirm_pair() -> None:
fn = _fn(js, "makeDeleteControl")
assert 'del.className = "history-delete"' in fn
assert 'del.setAttribute("aria-label", `Delete saved chat: ${chat.title}`)' in fn
# Phase 156 (task 02, A3) audit: the Delete button's aria-label
# names the row's identity (the chat title — the phase-66 idiom),
# and the visible text is the .cell-clip span — a squeezed cell
# shows "…" instead of a hard cut (the full text stays reachable).
assert 'delClip.className = "cell-clip"' in fn, (
"the clip label span (phase 156, A3)"
)
assert 'delClip.textContent = "Delete"' in fn, "the visible text"
assert "del.append(delClip)" in fn, "the span is the button's label"
assert 'label.textContent = "Delete?"' in fn
assert 'yes.className = "history-confirm-yes"' in fn
assert 'no.className = "history-confirm-no"' in fn
@@ -489,7 +506,10 @@ def test_history_table_css_full_width_and_palette() -> None:
title link is the accent link (brand-ink, focus-visible); the
confirm pair is Yes-on-error-rose + No-ghost; the empty-state row
is the muted centered message. Every pair is Phase-08 AA
(brand-ink/brand-soft 6.9:1, err 9.1:1, ink-soft >=6.9:1)."""
(brand-ink/brand-soft 6.9:1, err 9.1:1, ink-soft >=6.9:1).
Phase 156 (task 01): the thead's brand-soft background moved to the
ONE .table-wrap table thead th sticky rule — .history-table th
keeps the text styling only."""
css = _css()
block = re.search(r"\.history-table \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .history-table"
@@ -497,7 +517,14 @@ def test_history_table_css_full_width_and_palette() -> None:
assert "width: 100%" in body, "the table is FULL-WIDTH (AGENTS.md rule 5)"
assert "min-width: 640px" in body
th = re.search(r"\.history-table th \{([\s\S]*?)\n\}", css)
assert th and "var(--brand-soft)" in th.group(1) and "var(--brand-ink)" in th.group(1)
assert th and "var(--brand-ink)" in th.group(1), (
"the th keeps its brand-ink text styling"
)
sticky = re.search(r"\.table-wrap table thead th \{([\s\S]*?)\n\}", css)
assert sticky and "var(--brand-soft)" in sticky.group(1), (
"the thead's brand-soft background is the ONE .table-wrap "
"table thead th rule (phase 156)"
)
hover = re.search(r"\.history-table tbody tr:hover \{([^}]*)\}", css)
assert hover, "row hover is part of the table family"
link = re.search(r"\.history-title-link \{([\s\S]*?)\n\}", css)
@@ -639,17 +666,39 @@ def test_share_control_three_states_and_two_step_unshare() -> None:
assert "renderShareShared(chat, cell)" in make
assert "renderShareUnshared(chat, cell)" in make
# Unshared state: the Create link button (labeled, textContent).
# Phase 156 (task 02, A3): the visible text is the .cell-clip
# span (a squeezed Share cell shows "…" instead of a hard cut);
# the aria-label names the chat title (the phase-66 idiom — the
# audit: every one of the four History action buttons already
# carries an accessible name that names the row's identity, so
# the full text stays reachable and nothing was missing).
unshared = _fn(js, "renderShareUnshared")
assert 'create.className = "history-share-create"' in unshared
assert 'create.textContent = "Create link"' in unshared
assert 'createClip.className = "cell-clip"' in unshared, (
"the clip label span (phase 156, A3)"
)
assert 'createClip.textContent = "Create link"' in unshared
assert "create.append(createClip)" in unshared
assert 'create.setAttribute("aria-label", `Create share link: ${chat.title}`)' in unshared
assert "innerHTML" not in unshared, "XSS contract: textContent only"
# Shared state: Copy + Unshare, then the two-step confirm.
shared = _fn(js, "renderShareShared")
assert 'copy.className = "history-share-copy"' in shared
assert 'copy.textContent = "Copy"' in shared
assert 'copyClip.className = "cell-clip"' in shared, (
"the clip label span (phase 156, A3)"
)
assert 'copyClip.textContent = "Copy"' in shared
assert "copy.append(copyClip)" in shared
assert 'unshare.className = "history-unshare"' in shared
assert 'unshare.textContent = "Unshare"' in shared
assert 'unshareClip.className = "cell-clip"' in shared, (
"the clip label span (phase 156, A3)"
)
assert 'unshareClip.textContent = "Unshare"' in shared
assert "unshare.append(unshareClip)" in shared
# The audit's aria-label additions — present (the phase-66 idiom
# predated the audit; the pin keeps them):
assert 'copy.setAttribute("aria-label", `Copy share link: ${chat.title}`)' in shared
assert 'unshare.setAttribute("aria-label", `Unshare saved chat: ${chat.title}`)' in shared
assert 'label.textContent = "Unshare?"' in shared
assert 'yes.className = "history-confirm-yes"' in shared, (
"the unshare two-step reuses the phase-50 .history-confirm-* pair"
+9 -1
View File
@@ -811,7 +811,15 @@ def test_row_description_cell_builds_text_and_always_present_edit() -> None:
)
assert 'btn.type = "button"' in body
assert 'btn.className = "kb-summary-edit"' in body
assert 'btn.textContent = "Edit"' in body
# Phase 156 (task 02, A3): the visible text is the .cell-clip
# span (a squeezed Description cell shows "…" instead of a hard
# cut) — the aria-label names the row, so the full text stays
# reachable.
assert 'clip.className = "cell-clip"' in body, (
"the clip label span (phase 156, A3)"
)
assert 'clip.textContent = "Edit"' in body, "the visible text (never innerHTML)"
assert "btn.append(clip)" in body, "the span is the button's label"
assert 'btn.setAttribute("aria-label", `Edit description: ${label}`)' in body
assert 'wrap.className = "kb-desc-cell"' in body, "the ONE flex wrapper"
assert "wrap.append(text, btn)" in body, "text + button, in order, in the wrapper"
+11
View File
@@ -238,6 +238,17 @@ def test_row_button_opens_the_modal_and_the_row_error_span_is_retired() -> None:
js = _js()
make = _fn(js, "makeRow")
assert "openRemoveConfirm(s, btn)" in make, "the row's Remove opens the modal"
# Phase 156 (task 02, A3) audit: the Remove button's aria-label
# names the row's identity (the source's URL/path — the phase-89
# idiom), so a clipped label's full text stays reachable.
assert 'btn.setAttribute("aria-label", `Remove ${kindLabel} source: ${value}`)' in make
# The visible text is the .cell-clip span (a static template —
# the icon is static SVG, the established exception to the
# textContent house rule); a squeezed cell shows "…" instead of
# a hard cut.
assert "'<span class=\"cell-clip\">Remove</span>'" in make, (
"the clip label span (phase 156, A3)"
)
assert "removeSource(" not in js, "the window.confirm-era flow is gone"
assert "git-source-row-error" not in js
assert "git-source-row-error" not in _css(), "the dead per-row error CSS is retired"
+8 -1
View File
@@ -242,9 +242,16 @@ def test_row_button_exists_only_in_the_stored_row_branch() -> None:
assert f'ignoreBtn.className = "{ROW_BTN_CLASS}"' in make
assert "ignoreBtn.type = \"button\"" in make
assert ARIA_LABEL_TEMPLATE in make, "the aria-label template (the only value site)"
assert "ignoreBtn.textContent = \"Ignore paths\"" in make, (
# Phase 156 (task 02, A3): the static text label is the .cell-clip
# span — a squeezed cell shows "…" instead of a hard cut (the
# aria-label above keeps the full context reachable).
assert "ignoreClip.className = \"cell-clip\"" in make, (
"the clip label span (phase 156, A3)"
)
assert "ignoreClip.textContent = \"Ignore paths\"" in make, (
"a static text label — never icon-only"
)
assert "ignoreBtn.append(ignoreClip)" in make, "the span is the button's label"
assert "ignoreBtn.innerHTML" not in _js(), "XSS contract: no innerHTML on the button"
# The env-fallback branch (else) has no ignore button.
else_slice = make[else_i : make.find("tr.appendChild(actTd)", else_i)]
+40 -17
View File
@@ -34,6 +34,15 @@ This module pins the source-level contract (the house pattern of
``tests/unit/test_hamburger_nav.py``: read ``styles.css`` as text, no
browser) so the regression that omitted the containing block cannot
return.
Phase 156 (task 01) superseded two of these pins: the card's
``overflow-x: auto`` became ``overflow: auto`` (both axes — the
tables' min-width still triggers the in-card horizontal scroll) plus
the bounded-panel ``max-height: min(70vh, 64rem)`` (owner-locked A1:
the card is the vertical scroll region the sticky ``th`` pins to),
and the ``#git-sources-table-wrap`` id override was harmonized into
the base ``.table-wrap`` (the wrapper carries ``class="table-wrap"`,
so its duplicate declarations were removed — one-rule contract).
"""
from __future__ import annotations
@@ -90,26 +99,40 @@ def test_table_wrap_is_a_positioned_containing_block() -> None:
def test_table_wrap_keeps_the_horizontal_scroll_contract() -> None:
"""The fix preserves the phase-07 scroll contract: the card keeps
overflow-x:auto — at any viewport narrower than the tables' 640px
min-width the table scrolls INSIDE the card (AGENTS.md rule 5:
full-width tables stay) while the document itself no longer pans."""
"""Phase 156 (task 01) supersedes the phase-86 overflow-x pin:
the card carries overflow:auto (BOTH axes — at any viewport
narrower than the tables' min-width the table still scrolls
INSIDE the card, AGENTS.md rule 5: full-width tables stay) plus
the bounded-panel max-height: min(70vh, 64rem) (owner-locked A1:
the card is the vertical scroll region — a 15-row table scrolls,
a 3-row one fits without a scrollbar) so the sticky th pins to
the card instead of scrolling away with the page."""
block = _rule_block(_css(), ".table-wrap")
assert "overflow-x: auto" in block, (
"the .table-wrap card must keep overflow-x:auto (the in-card "
"scroll the fix protects)"
assert "overflow: auto" in block, (
"the .table-wrap card must carry overflow: auto (both axes — "
"the in-card horizontal scroll + the bounded vertical scroll)"
)
assert "max-height: min(70vh, 64rem)" in block, (
"the .table-wrap card must be bounded: max-height: "
"min(70vh, 64rem) (the A1 bounded-panel design)"
)
def test_the_fix_touched_only_the_shared_card() -> None:
"""The one-rule scope (A3): the fix lives on .table-wrap ONLY —
.md-table-wrap (chat answer tables — different class, different
context) and the #git-sources-table-wrap id card keep their
pre-phase-86 rules (no position declaration acquired)."""
"""The one-rule scope (A3, harmonized by phase 156 task 01): the
positioning fix lives on .table-wrap ONLY — .md-table-wrap (chat
answer tables — different class, different context) keeps its
pre-phase-86 rules (no position declaration), and the
#git-sources-table-wrap id override is GONE: the wrapper carries
class="table-wrap" and inherits the base card, so a divergent
unbounded id rule cannot return."""
css = _css()
for sel in (".md-table-wrap", "#git-sources-table-wrap"):
block = _rule_block(css, sel)
assert not re.search(r"position\s*:", block), (
f"{sel} must not acquire a position declaration (the fix is "
"the ONE .table-wrap rule)"
)
block = _rule_block(css, ".md-table-wrap")
assert not re.search(r"position\s*:", block), (
".md-table-wrap must not acquire a position declaration (the "
"fix is the ONE .table-wrap rule)"
)
assert not re.search(r"(?<![\w-])#git-sources-table-wrap\s*\{", css), (
"the #git-sources-table-wrap id override must be harmonized "
"into the base .table-wrap (phase 156 — no divergent rule)"
)
+183
View File
@@ -19,6 +19,16 @@ DB-over-built-in resolver shared by ``/api/ui-settings`` and
(the resolver treats "" as unset, B1). House DB-test pattern (the
``test_tokens`` precedent): the real compose Postgres, skipped with
clear instructions when the stack is not up.
* Phase 156 (task 03): the catalogue-table CSS contract pins, parsed
straight out of ``styles.css`` (the :root-drift idiom extended — a
later "cleanup" cannot silently unstick the header, unbound the
panel, or transparent the thead): the bounded ``.table-wrap``
panel, the sticky opaque ``thead th``, the
``#git-sources-table-wrap`` one-rule harmony, the
``.md-table-wrap`` byte-unchanged scope guard, the ``.cell-clip``
ellipsis. The :root 17-variable set itself stays pinned by
``test_builtin_colors_match_styles_css_root`` (UNCHANGED — this
phase adds no palette variable, B3).
"""
from __future__ import annotations
@@ -389,3 +399,176 @@ def test_docstring_pair_table_matches_theme_js_pairs() -> None:
# surface in BOTH places (decorative borders — no contrast duty).
assert "err_line" not in [f for f, _ in pairs]
assert "accent_line" not in [f for f, _ in pairs]
# ---------------------------------------------------------------------------
# Phase 156 (task 03): the catalogue-table CSS contract pins — the
# bounded-panel sticky header + the clipped-label ellipsis, parsed
# straight out of styles.css (the :root-drift idiom extended: a later
# "cleanup" cannot silently unstick the header, unbound the panel, or
# transparent the thead). The :root 17-variable set itself is pinned
# by test_builtin_colors_match_styles_css_root above (UNCHANGED —
# this phase adds no palette variable, B3: the 17-variable set stays
# byte-identical).
# ---------------------------------------------------------------------------
def _css_rule(selector: str) -> dict[str, str] | None:
"""The ``prop: value`` declarations (comments stripped) of the FIRST
styles.css rule whose selector ends with ``selector`` (a bare
``.table-wrap`` never matches ``.table-wrap table thead th`` —
the selector must end right at the rule's ``{``), or ``None``
when the selector is absent from the file."""
css = re.sub(r"/\*.*?\*/", "", STYLES_CSS.read_text(encoding="utf-8"), flags=re.S)
match = re.search(re.escape(selector) + r"(?![\w.#-])\s*\{", css)
if match is None:
return None
block = css[match.end() : css.index("}", match.end())]
return dict(re.findall(r"([a-z-]+)\s*:\s*([^;]+);", block))
def _assert_opaque_background(value: str, root: dict[str, str]) -> None:
"""The sticky th's OPACITY pin (the exact token is the phase's
choice — ``var(--brand-soft)`` today; the opacity is the pin):
the background token must paint a solid, alpha-less color —
``transparent``/``none`` never, a ``var(--x)`` token must resolve
to a solid six-hex ``:root`` value, a literal must carry no
alpha channel (``rgba(`` / ``hsla(`` / 8-digit hex all let rows
show through the pinned header)."""
v = value.strip()
assert v.lower() not in {"transparent", "none", "inherit", "unset", "initial"}, (
f"the sticky th background is {value!r} — rows would show through"
)
var = re.fullmatch(r"var\(\s*(--[a-z-]+)\s*\)", v)
if var:
name = var.group(1)
assert name in root, f"the sticky th background {value!r} names an unknown {name}"
resolved = root[name]
assert re.fullmatch(r"#[0-9a-f]{6}", resolved), (
f"the sticky th background {value!r} resolves to {resolved!r} — it must "
f"be a solid alpha-less hex (OPAQUE)"
)
return
assert not re.search(r"rgba?\(\s|hsla?\(\s|#[0-9a-f]{8}\b", v), (
f"the sticky th background {value!r} carries an alpha channel — "
f"it must be OPAQUE"
)
def test_table_wrap_bounded_panel_pin() -> None:
"""The ONE rule all three catalogue tables share (the RAG
``.docs-table``, the Sources ``.git-sources-table``, the History
``.history-table``): the card is the vertical scroll region —
``max-height: min(70vh, 64rem)`` (the exact A2 value pinned: a
15-row table scrolls while a 3-row one fits without a scrollbar)
+ ``overflow: auto`` (both axes — the tables' min-width still
triggers the in-card horizontal scroll at narrow widths). The
pre-phase ``overflow-x: auto`` is SUPERSEDED — its return would
unbound the panel's vertical contract (a sticky header in an
unbounded card scrolls away with the page)."""
rule = _css_rule(".table-wrap")
assert rule is not None, "styles.css must define .table-wrap"
assert rule.get("max-height") == "min(70vh, 64rem)", (
f".table-wrap max-height drifted: {rule.get('max-height')!r} — the "
f"bounded-panel pin is min(70vh, 64rem)"
)
assert rule.get("overflow") == "auto", (
f".table-wrap overflow drifted: {rule.get('overflow')!r} — the card "
f"must be the sticky th's scrolling ancestor (overflow: auto)"
)
assert "overflow-x" not in rule, (
".table-wrap must not carry a bare overflow-x — the pre-phase "
"overflow-x: auto is superseded by overflow: auto (both axes)"
)
def test_table_wrap_thead_sticky_pin() -> None:
"""The ONE sticky-header rule for every catalogue table:
``position: sticky; top: 0; z-index: 1`` (the header row pinned
to the card top while the card's own vertical scroll moves, the
pinned th above the scrolled cells) + an OPAQUE background — the
parse asserts the background is a non-``transparent`` token (the
exact token is the phase's choice — ``var(--brand-soft)`` today;
the OPACITY is the pin: a ``var`` token must resolve to a solid
alpha-less :root hex, a literal must carry no alpha channel —
rows must never show through the pinned header)."""
rule = _css_rule(".table-wrap table thead th")
assert rule is not None, "styles.css must define .table-wrap table thead th"
assert rule.get("position") == "sticky", (
f"the catalogue thead th must stay position: sticky, "
f"got {rule.get('position')!r}"
)
assert rule.get("top") == "0", (
f"the catalogue thead th must pin at top: 0, got {rule.get('top')!r}"
)
assert rule.get("z-index") == "1", (
f"the catalogue thead th must sit at z-index: 1, got {rule.get('z-index')!r}"
)
assert "background" in rule, (
"the catalogue thead th must carry an explicit background "
"(the opaque pin — the default is transparent)"
)
_assert_opaque_background(rule["background"], _root_declarations())
def test_git_sources_table_wrap_no_divergent_override() -> None:
"""The one-rule contract: the Sources card
(``#git-sources-table-wrap``) must not carry a DIVERGENT UNBOUNDED
override — if the id rule exists it carries the SAME
``max-height: min(70vh, 64rem)`` + ``overflow: auto`` as the base
``.table-wrap`` (a divergent ``overflow-x: auto`` without the
bound would make the Sources table scroll away with the page
while the other two stick). The current form (task 01): the id
rule is REMOVED — the wrapper carries ``class="table-wrap"`` and
inherits the base card's bound + overflow + sticky rule, so the
rule's absence IS the harmony."""
rule = _css_rule("#git-sources-table-wrap")
if rule is None:
return # the one-rule contract: the id carries no rule of its own
assert rule.get("max-height") == "min(70vh, 64rem)", (
f"#git-sources-table-wrap max-height diverges: "
f"{rule.get('max-height')!r} — the one-rule contract pins min(70vh, 64rem)"
)
assert rule.get("overflow") == "auto", (
f"#git-sources-table-wrap overflow diverges: {rule.get('overflow')!r} — "
f"the one-rule contract pins overflow: auto"
)
def test_md_table_wrap_byte_unchanged_pin() -> None:
"""The scope guard: the CHAT view's markdown table wrapper
(``.md-table-wrap`` — the shared renderer's horizontal scroller)
is a DIFFERENT class from the catalogue card — UNTOUCHED by this
phase (no card, no height bound; the bounded-panel design is for
the catalogue tables only). The pin asserts the rule's VERBATIM
pre-phase text (byte-identical)."""
css = STYLES_CSS.read_text(encoding="utf-8")
assert ".md-table-wrap { overflow-x: auto; }" in css, (
".md-table-wrap must stay byte-identical to its pre-phase rule "
"(.md-table-wrap { overflow-x: auto; }) — the chat scope guard"
)
def test_cell_clip_ellipsis_pin() -> None:
"""The clipped-label ellipsis (A3): the ``.cell-clip`` label span
(the five control families — the Sources Hidden checkbox label +
Ignore paths + Remove, the RAG folder table's Edit, the History
Copy / Unshare / Create link / Delete) shows "…" instead of the
card edge's hard cut: ``text-overflow: ellipsis`` +
``overflow: hidden`` + ``white-space: nowrap`` (the house
.sync-label shrinkable-item mechanism; the max-width clamp
engages only in the clipped case)."""
rule = _css_rule(".cell-clip")
assert rule is not None, "styles.css must define .cell-clip"
assert rule.get("text-overflow") == "ellipsis", (
f".cell-clip text-overflow drifted: {rule.get('text-overflow')!r} — "
f"the clipped label must show the ellipsis hint"
)
assert rule.get("overflow") == "hidden", (
f".cell-clip overflow drifted: {rule.get('overflow')!r} — "
f"the ellipsis needs the hidden overflow"
)
assert rule.get("white-space") == "nowrap", (
f".cell-clip white-space drifted: {rule.get('white-space')!r} — "
f"the ellipsis needs the nowrap"
)