Files
brain-of-reese/tests/unit/test_chip_sizing_question_cap.py
T
ducoterra ecc921098a
Build and Push Containers / build-and-push-app (push) Successful in 1m48s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 104_chip_sizing_question_cap
All completion criteria verified. Final report:

**Phase 104 — final verification pass: all green**
- Verified (no code changes needed): single-line ellipsized `.suggestion-chip` CSS + deleted `.maybe-try` override, `renderChips` full-text `title` + `aria-label`-when-clipped, `maxlength="4000"` + `#char-count` counter + `handleSend` over-cap guard, unit pins file, schemas boundary pins, dedicated E2E suite.
- E2E (isolation): `uv run pytest tests/e2e/test_chip_sizing_question_cap.py -v --no-cov` → **6 passed**; regressions: `test_suggestion_chips.py` 8 passed, `test_pinned_composer.py` 4 passed, `test_responsive_polish.py` 7 passed, `test_chat_history.py` 5 passed.
- `uv run pytest` → **2102 passed**; `--cov=app` → **99%** (>90%); `uv run ruff check . && uv run pyright` → clean, 0 errors.
- Criteria: chip E2E (single-line, clipped, title+aria-label full text) ✅; paste caps at exactly 4,000, send streams, counter hides ✅; programmatic 5,000-char fill → banner, no turn, text kept ✅; 4,000/4,001 boundary pinned + HTML maxlength == JS constant cross-file pin ✅.
- Diff scope: `frontend/`, new unit file, `tests/unit/test_schemas.py`, new E2E file, phase files — **no `app/` diff, no migration, no `shared.js` diff**.
- Deviations: 4 regression test files touched — 2 genuine DOM-pin conflicts from the new `#char-count` child (explicitly anticipated by the overview) + 3 documented **pre-existing E2E flake fixes** (smooth-scroll race, tab-walk heuristic, 10 ms timeout), each verified pre-existing on the pre-phase-104 tree.
- No commit made (harness commits per the execution protocol override).
- Next pending phase: `98_sync_summary_visibility`.
2026-09-12 19:45:00 -04:00

551 lines
25 KiB
Python

"""Unit: the phase-104 chip-sizing contract in the static frontend
(owner request 2026-09-12 — "tweak the size of the suggestion chips on
the chat page. Some users submit truly massive queries and the 'chips'
become more like 'chonks'. Hovering over the chips should still show
the full message … put a character cap on the chat submission box").
The browser geometry (the measured clientHeight / scrollWidth / the
title + aria-label / the counter / the guard) is E2E-gated by the
phase's dedicated suite (tests/e2e/test_chip_sizing_question_cap.py,
task 04); this module pins the static sources the contract stands on,
in the house source-pin pattern (the test_pinned_composer.py
``_rule`` style).
Task 01 — single-line chips, never chonks (owner A1): a suggestion
chip is ONE line at every viewport width — long text ellipsizes at the
row edge instead of wrapping the pill into a multi-line "chonk". The
pin is the CSS contract:
* the base ``.suggestion-chip`` rule carries the single-line/ellipsis
set — ``white-space: nowrap``, ``overflow: hidden`` (zeroes the flex
item's automatic minimum size so ``max-width: 100%`` actually binds),
``text-overflow: ellipsis``, ``max-width: 100%`` — plus
``min-width: 0``, and KEEPS the pill it always was (the 44px
``min-height`` floor, the 999px radius);
* the phase-07 override ``.maybe-try .suggestion-chip``
(``min-width: 0; max-width: 100%``) is fully subsumed by the base
rule and DELETED — zero occurrences of the selector remain in the
file (its phase-07 provenance was folded into the base rule's
comment, so the history lives with the contract); the ``.maybe-try``
GROUP rule itself stays (the deflection row still wraps at every
width — only the chip override is gone);
* the ≤640px block keeps its phase-07 contract untouched: the
``.suggestions`` row is the single horizontal-scroll track
(``flex-wrap: nowrap; overflow-x: auto``) and
``.suggestion-chip { flex: 0 0 auto; }`` — with the base rule's
``max-width: 100%`` a long chip now clips at the VISIBLE width while
the row scrolls, instead of letting the chip outgrow the viewport.
Task 02 — the full text is always one hover away (owner A2): the
single-line pill from task 01 CLIPS long questions, so every chip the
shared ``renderChips`` component builds (onboarding row AND "Maybe try"
row) carries:
* ``btn.title = text`` — the native tooltip set to the FULL text,
always (the house source-chip precedent, ``chip.title = label``);
* the truncation-aware accessible name — ONLY when the visible text is
clipped (``btn.scrollWidth > btn.clientWidth``) does the chip get
``aria-label`` = the full text (the source-chip pattern); when the
pill is not clipped the attribute stays absent, because
``textContent`` already reads the full text to screen readers.
Task 03 extends this module with the composer cap pins (``maxlength`` /
the counter / the guard / the single-source cross-file pin).
"""
from __future__ import annotations
import re
from pathlib import Path
from app.schemas import ChatRequest
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
APP_JS = FRONTEND / "assets" / "app.js"
INDEX_HTML = FRONTEND / "index.html"
def _css() -> str:
assert STYLES_CSS.is_file(), f"missing {STYLES_CSS}"
return STYLES_CSS.read_text(encoding="utf-8")
def _app_js() -> str:
assert APP_JS.is_file(), f"missing {APP_JS}"
return APP_JS.read_text(encoding="utf-8")
def _index_html() -> str:
assert INDEX_HTML.is_file(), f"missing {INDEX_HTML}"
return INDEX_HTML.read_text(encoding="utf-8")
def _function_body(js: str, header: str) -> str:
"""The full text of the function whose header is ``header`` — from
the header to its brace-matched closing ``}``. The naive brace count
is safe for the pinned functions: their template literals carry
balanced ``${…}`` pairs and no string literal holds a stray brace."""
start = js.index(header)
body_open = js.index("{", start)
depth = 0
for j in range(body_open, len(js)):
if js[j] == "{":
depth += 1
elif js[j] == "}":
depth -= 1
if depth == 0:
return js[start : j + 1]
raise AssertionError(f"unbalanced braces in {header!r}")
def _single_line_rule(css: str, selector: str) -> str:
"""The body of a ONE-LINE rule (``selector { … }`` on a single
line) — the .char-count rules are written house one-liners."""
block = re.search(rf"^{re.escape(selector)} \{{([^}}]*)\}}", css, re.MULTILINE)
assert block, f"styles.css must carry a `{selector} {{ … }}` rule"
return block.group(1)
def _message_input_tag(html: str) -> str:
"""The FULL opening tag of the ``#message-input`` textarea (the
composer's — index.html carries other textareas too)."""
tag = re.search(r'<textarea\b[^>]*id="message-input"[^>]*>', html)
assert tag, "index.html must carry the #message-input textarea"
return tag.group(0)
def _schema_question_cap() -> int:
"""The server cap the UI mirrors — ``ChatRequest.message``'s
``max_length`` (the single conceptual source of the 4,000)."""
for meta in ChatRequest.model_fields["message"].metadata:
if getattr(meta, "max_length", None):
return int(meta.max_length)
raise AssertionError("ChatRequest.message must keep its max_length")
def _render_chips_body(js: str) -> str:
"""The full text of the ``renderChips`` function — from its
signature to its brace-matched closing ``}`` (brace counting, so
the nested click-handler braces are exact). The body's opening
brace is the LAST ``{`` on the signature line — the ``{ onSelect }
= {}`` destructuring pair sits before it."""
start = js.index("function renderChips(")
line_end = js.index("\n", start)
body_open = js.rindex("{", start, line_end)
depth = 0
for j in range(body_open, len(js)):
if js[j] == "{":
depth += 1
elif js[j] == "}":
depth -= 1
if depth == 0:
return js[start : j + 1]
raise AssertionError("unbalanced braces in renderChips")
def _rule(css: str, selector: str) -> str:
"""The body of the rule whose selector line is exactly `selector`
(multi-line block) — same slicing style as test_pinned_composer.py."""
block = re.search(rf"^{re.escape(selector)} \{{\n([\s\S]*?)\n\}}", css, re.MULTILINE)
assert block, f"styles.css must carry a `{selector} {{ … }}` rule"
return block.group(1)
def _mobile_block(css: str) -> str:
"""The ≤640px media query body (the phase-07 responsive block)."""
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
assert mobile, "the mobile media query must exist"
return mobile.group(1)
# ---------- task 01: the single-line chip contract ----------
def test_base_chip_rule_is_single_line_and_ellipsized() -> None:
"""The base ``.suggestion-chip`` rule carries the phase-104
single-line/ellipsis contract (owner A1) AND keeps the pill it
always was — a chip is a one-line 44px pill at every width, never
a wrapped multi-line block:
* ``white-space: nowrap`` — the text never wraps inside the pill;
* ``overflow: hidden`` (≠ visible) — zeroes the flex item's
automatic minimum size, which is what makes ``max-width: 100%``
bind at all;
* ``text-overflow: ellipsis`` + ``max-width: 100%`` — the text
clips at the row edge (desktop wrap row: the chat column;
≤640px row: the visible width) instead of the pill outgrowing it;
* ``min-width: 0`` — the same auto-min zeroing, pinned for clarity.
"""
body = _rule(_css(), ".suggestion-chip")
assert "white-space: nowrap;" in body, (
"the chip must never wrap — a 400-char question may not turn the "
"pill into a multi-line 'chonk' (owner A1)"
)
assert "overflow: hidden;" in body, (
"`overflow: hidden` zeroes the flex item's automatic minimum size "
"so `max-width: 100%` actually binds (visible overflow would let "
"the pill push past the row edge)"
)
assert "text-overflow: ellipsis;" in body, (
"the clipped text must show the ellipsis — the full text stays "
"one hover away (the title tooltip, task 02)"
)
assert "max-width: 100%;" in body, (
"the chip clips at the row edge — 100% of the row it sits in"
)
assert "min-width: 0;" in body, (
"the flex item's automatic minimum size must be zero — the "
"phase-07 shrink allowance, now on every chip row"
)
# The pill contract survives the sizing change (the 44px touch
# floor + the round pill — the E2E's 44 <= clientHeight <= 60 pin
# stands on this min-height).
assert "min-height: 44px;" in body, (
"the chip keeps its ≥44px touch target (WCAG 2.1 AA, phase 07)"
)
assert "border-radius: 999px;" in body, "the chip stays the round pill"
def test_subsumed_maybe_try_chip_override_is_gone() -> None:
"""The phase-07 ``.maybe-try .suggestion-chip { min-width: 0;
max-width: 100%; }`` override is fully subsumed by the new base
rule and DELETED — zero occurrences of the selector remain anywhere
in the file (comments included: the provenance was folded into the
base rule's comment WITHOUT the selector string, so this pin
doubles as the "no second cap / no drift" guard). The ``.maybe-try``
GROUP rule itself stays exactly once — the deflection row still
wraps at every width (phase 04), only the chip override is gone."""
css = _css()
assert css.count(".maybe-try .suggestion-chip") == 0, (
"the subsumed phase-07 override must be deleted — the base rule "
"carries min-width:0 + max-width:100% now, and a second cap "
"would be dead weight / a drift hazard"
)
assert css.count(".maybe-try {") == 1, (
"the .maybe-try group rule stays (the deflection row's wrap "
"contract) — only the chip override was removed"
)
def test_mobile_chip_row_keeps_the_phase_07_track() -> None:
"""The ≤640px block is UNTOUCHED by task 01: the ``.suggestions``
row keeps the phase-07 single horizontal-scroll track
(``flex-wrap: nowrap; overflow-x: auto``) and
``.suggestion-chip { flex: 0 0 auto; }``. With the base rule's
``max-width: 100%`` + ``overflow: hidden``, a long chip on the
phone clips at the VISIBLE width and the row scrolls — the
phase-07 overflow contract now lives in the base rule (task 01
folded the provenance there), so nothing in the mobile block may
have changed."""
mobile = _mobile_block(_css())
mobile_chip = re.search(r"\.suggestion-chip \{([^}]*)\}", mobile)
assert mobile_chip, "the ≤640px block must keep the .suggestion-chip rule"
assert ".suggestions { flex-wrap: nowrap; overflow-x: auto;" in mobile, (
"the mobile row keeps the phase-07 single horizontal-scroll "
"track (nowrap + overflow-x: auto)"
)
assert mobile_chip.group(1).strip() == "flex: 0 0 auto;", (
"the mobile chip must stay EXACTLY the phase-07 fixed flex item "
"(flex: 0 0 auto) on the scroll track — the chip does not shrink "
"below its content on the phone, the ROW scrolls; the long-chip "
"clip is the base rule's max-width: 100%, not a mobile-local one"
)
# ---------- task 02: the full-text tooltip + accessible name ----------
def test_render_chips_sets_full_text_title_on_every_chip() -> None:
"""Every chip ``renderChips`` builds (onboarding row AND "Maybe try"
row — one shared component) carries ``title`` = the FULL text
(owner A2): the task-01 pill clips a long question to one
ellipsized line, and the native hover tooltip is the way the user
reads the rest. The pin is the house source-chip precedent
(``chip.title = label``) — and it is set to the SAME ``text``
variable ``textContent`` gets, so the tooltip can never drift
short of the full question."""
body = _render_chips_body(_app_js())
assert "btn.textContent = text;" in body, (
"the chip's visible text is the (trimmed) full question"
)
assert "btn.title = text;" in body, (
"every chip must carry the full text as its native title "
"tooltip — hovering is the contract for the clipped text "
"(owner A2: 'Hovering over the chips should still show the "
"full message')"
)
def test_render_chips_sets_aria_label_only_when_clipped() -> None:
"""The accessible name is the full text ONLY when the visible text
is actually clipped (the source-chip truncation pattern, the
``chip.scrollWidth > chip.clientWidth`` → ``aria-label`` loop): a
chip whose text fits carries NO ``aria-label`` — its ``textContent``
already reads the full text to screen readers — while the clipped
chip (the ellipsized one) announces the full question. The pin is
the guard plus the ``setAttribute`` call inside it."""
body = _render_chips_body(_app_js())
guard = re.search(
r'if \(btn\.scrollWidth > btn\.clientWidth\) '
r'btn\.setAttribute\("aria-label", text\);',
body,
)
assert guard, (
"the clipped chip must set aria-label to the FULL text under "
"the scrollWidth > clientWidth guard (the source-chip pattern) "
"— a clipped chip's accessible name must be the full question, "
"and an unclipped chip must stay attribute-free"
)
# ---------- task 03: the visible 4,000-char question cap ----------
def test_message_input_carries_the_4000_maxlength() -> None:
"""The composer's textarea hard-caps the input path (typing AND
paste — the browser enforces maxlength on both, E2E task 04) at
EXACTLY the server's cap, so a user can never meet the 422 blind
through the input path (owner A3). The provenance comment lives
with the attribute — the house pattern (the theme inputs' "
maxlength=300 mirrors the server's 300-char")."""
html = _index_html()
tag = _message_input_tag(html)
assert 'maxlength="4000"' in tag, (
"#message-input must carry maxlength=4000 — the server already "
"rejects >4,000 (ChatRequest.message) and the counter makes it "
"visible; without it the input path 422s with zero feedback"
)
assert "maxlength=4000 mirrors ChatRequest.message max_length=4000" in html, (
"the provenance comment must live with the attribute (house "
"pattern) — the schema is the source of truth the HTML mirrors"
)
def test_char_count_sits_in_chat_bottom_above_the_composer() -> None:
"""The counter element is a child of the .chat-bottom sticky unit —
between the actions row and the composer form (source order) and
``hidden`` by default (it only appears from 80% of the cap). A
hidden ``<p>`` adds zero height, so the pinned-cluster geometry
(tests/unit/test_pinned_composer.py, the .chat-bottom-last-child
pin) is untouched by the new child."""
html = _index_html()
unit = re.search(r'<div class="chat-bottom">([\s\S]*?)</form>\s*</div>', html)
assert unit, "the .chat-bottom sticky unit must exist (phase 65)"
inner = unit.group(1)
el = re.search(r'<p class="char-count" id="char-count"[^>]*></p>', inner)
assert el, "the #char-count counter element must exist in .chat-bottom"
assert "hidden" in el.group(0), (
"the counter ships HIDDEN — it is feedback near the cap, not a "
"permanent chrome line (owner A4: no noise on normal use)"
)
# Source order: AFTER the actions row closes, BEFORE the composer —
# the counter sits between the row and the form inside the unit.
actions_close = inner.index("</div>") # the .chat-actions wrapper closes first
assert actions_close < inner.index('id="char-count"') < inner.index('id="composer"'), (
"the counter must sit inside .chat-bottom between the chat-actions "
"row and the composer form (its flow position above the box)"
)
def test_counter_comment_records_the_not_live_region_decision() -> None:
"""The phase-104 decision record on the element: the .is-max state
is --err-* PLUS a copy change (B3 — text + color, never color
alone) and the counter is NOT a live region (per-keystroke
feedback is decorative; the over-cap failure announces through the
role=alert error banner)."""
comment = re.search(
r"<!--\s*Phase 104\s*\(owner 2026-09-12\): the question-length counter[\s\S]*?-->",
_index_html(),
)
assert comment, "the phase-104 provenance comment must sit on the counter"
text = comment.group(0)
assert "NOT" in text and "live region" in text.lower(), (
"the counter must stay out of the aria-live contract — the "
"over-cap failure path announces through the error banner"
)
assert "B3" in text, (
"the .is-max state must be recorded as text + color (B3), "
"never color alone"
)
def test_cap_constants_mirror_the_server_cap() -> None:
"""The JS cap constants: ``MAX_QUESTION_CHARS = 4000`` (mirrors the
schema — the executor must NOT change app/schemas.py) and
``CHAR_COUNT_SHOW_AT = 3200`` (80% of the cap, owner A4). The
single-source pin below proves the three copies (HTML / JS / schema)
cannot drift."""
js = _app_js()
m = re.search(r"const MAX_QUESTION_CHARS = (\d+);", js)
assert m, "app.js must define the MAX_QUESTION_CHARS constant"
assert int(m.group(1)) == _schema_question_cap(), (
"MAX_QUESTION_CHARS must mirror ChatRequest.message's "
"max_length — the cap lives in one place conceptually"
)
s = re.search(r"const CHAR_COUNT_SHOW_AT = (\d+);", js)
assert s, "app.js must define the CHAR_COUNT_SHOW_AT constant"
assert int(s.group(1)) == 0.8 * int(m.group(1)), (
"the counter appears at exactly 80% of the cap (owner A4) — "
"no noise on normal use, visible when it matters"
)
def test_html_maxlength_equals_js_constant_cross_file() -> None:
"""THE single-source cross-file pin: the HTML ``maxlength`` on
#message-input, the JS ``MAX_QUESTION_CHARS`` constant, and the
server's ``ChatRequest.message`` cap are the SAME number (regex-
parsed from both frontend files + the schema). Any one drifting is
a blind 422 (or a counter that lies about the cap)."""
html_m = re.search(r'maxlength="(\d+)"', _message_input_tag(_index_html()))
js_m = re.search(r"const MAX_QUESTION_CHARS = (\d+);", _app_js())
assert html_m and js_m, "both the HTML maxlength and the JS constant must exist"
html_len = int(html_m.group(1))
js_len = int(js_m.group(1))
assert html_len == js_len == _schema_question_cap() == 4000, (
f"HTML maxlength={html_len}, JS MAX_QUESTION_CHARS={js_len} and the "
"schema cap must all be the one number (4,000)"
)
def test_update_char_count_contract() -> None:
"""The counter's state machine (owner A4): RAW length (no trim —
raw ≤ cap ⟹ trimmed ≤ cap, a safe superset of what the server
validates), hidden below the 80% threshold, plain ``len/4000`` at
and above it, and ``len/4000 — character limit`` + the .is-max
(err family) treatment at/over the cap — the over-cap reading keeps
the HONEST length (the chip-fill path exceeds maxlength, e.g.
``5123/4000 — character limit``)."""
body = _function_body(_app_js(), "function updateCharCount() {")
assert "const len = input.value.length;" in body, (
"the count is the RAW value (no trim) — a raw count is a safe "
"superset of what the server validates"
)
assert "len < CHAR_COUNT_SHOW_AT" in body, (
"below the 80% threshold the counter stays hidden"
)
assert 'charCountEl.hidden = true;' in body
assert 'charCountEl.classList.remove("is-max");' in body, (
"hiding the counter must also drop the .is-max state — a short "
"question after a maxed one must not keep the error color"
)
assert "len >= MAX_QUESTION_CHARS" in body, (
"the at/over-cap branch is >= (4,000 itself is already at the "
"limit — the server accepts exactly 4,000, so the counter says "
"so at 4,000)"
)
assert 'charCountEl.classList.toggle("is-max", atMax);' in body
assert "— character limit" in body, (
"the .is-max state must CHANGE THE COPY (B3) — color alone is "
"not the state; the words carry it"
)
assert "`${len}/${MAX_QUESTION_CHARS}`" in body, (
"the counter text is len/cap from the SAME constants — the "
"displayed cap can never drift from MAX_QUESTION_CHARS"
)
grabber = re.search(
r'const charCountEl = document\.querySelector\("#char-count"\);', _app_js()
)
assert grabber, "app.js must grab #char-count alongside the other controls"
def test_handle_send_guard_blocks_over_cap_questions() -> None:
"""The over-cap guard (owner A5): the one reachable path that
bypasses maxlength is the programmatic chip fill, and it is
blocked in handleSend — trimmed text over the cap gets the
out-of-turn error banner (the saveAsDoc precedent), NO turn, and
the input KEEPS the text (the user trims it — never stale, PLAN
§7.4). The guard sits AFTER the !text guard and BEFORE the clear.
"""
body = _function_body(_app_js(), "async function handleSend(e) {")
guard = re.search(
r"if \(text\.length > MAX_QUESTION_CHARS\) \{\s*"
r"showErrorBanner\(\"Questions are limited to 4,000 characters"
r" — trim the question and try again\.\"\);\s*"
r"return;\s*\}",
body,
)
assert guard, (
"handleSend must refuse a trimmed question over the cap with the "
"4,000-characters banner copy (the UI makes the server's 422 "
"visible BEFORE the request)"
)
assert "if (!text || sendBtn.disabled) return;" in body
assert body.index("text.length > MAX_QUESTION_CHARS") < body.index(
'input.value = "";'
), (
"the guard must run BEFORE the clear — the input keeps the kept "
"text so the user can trim it (a cleared input would be stale)"
)
def test_all_four_mutation_sites_run_update_char_count() -> None:
"""``updateCharCount()`` runs at the EXACT four ``input.value``
mutation sites (each already ran ``autoGrow()`` there) — the
counter can never show a stale length:
* the ``input`` listener — keystrokes + pastes (maxlength caps
both); the listener body now runs autoGrow AND updateCharCount;
* ``submitSuggestion`` — the chip one-tap fill (the programmatic
path maxlength cannot stop);
* the ``handleSend`` post-send clear — the sent question hides the
counter again;
* the ``startNewChat`` clear — same.
"""
js = _app_js()
# (a) the input listener — both calls inside the one listener body
listener = re.search(
r'input\.addEventListener\("input", \(\) => \{[\s\S]*?autoGrow\(\);'
r"[\s\S]*?updateCharCount\(\);[\s\S]*?\}\);",
js,
)
assert listener, (
"the input listener must run autoGrow AND updateCharCount — the "
"counter follows every input-path change"
)
# (b) the chip one-tap fill
sub = _function_body(js, "function submitSuggestion(text) {")
assert sub.index("input.value = text;") < sub.index("autoGrow();") < sub.index(
"updateCharCount();"
), "submitSuggestion must count the (possibly over-cap) filled text"
# (c) the post-send clear
send = _function_body(js, "async function handleSend(e) {")
assert send.index('input.value = "";') < send.index("autoGrow();") < send.index(
"updateCharCount();"
), "the post-send clear must hide the counter with the input"
# (d) the new-chat clear
newchat = _function_body(js, "function startNewChat() {")
assert newchat.index('input.value = "";') < newchat.index("autoGrow();") < newchat.index(
"updateCharCount();"
), "the new-chat clear must hide the counter with the input"
def test_char_count_css_is_aa_on_the_app_background() -> None:
"""The counter's two color pairings are WCAG-AA-verified against
the background it actually sits on — the APP background (--bg)
behind the transparent .chat-bottom unit — with each ratio recorded
in the rule's comment (house style): --ink-soft on --bg = 8.6:1,
--err-ink on --bg = 10.4:1 (both ≥4.5:1). The .is-max state pairs
the color with the "— character limit" copy change (B3)."""
css = _css()
body = _single_line_rule(css, ".char-count")
assert "text-align: right;" in body, (
"the counter right-aligns above the composer (house layout)"
)
assert "color: var(--ink-soft);" in body
is_max = _single_line_rule(css, ".char-count.is-max")
assert "color: var(--err-ink);" in is_max, (
"the at/over-cap state must use the --err-* semantic family (B3)"
)
comment = re.search(r"(/\*[^*]*?\*/)\s*\.char-count \{", css)
assert comment, "the .char-count rule must carry its provenance comment"
note = comment.group(1)
assert "8.6:1" in note, (
"the --ink-soft on --bg ratio must be recorded (verified "
"8.6:1 ≥ 4.5:1, WCAG AA)"
)
assert "10.4:1" in note, (
"the --err-ink on --bg ratio must be recorded (verified "
"10.4:1 ≥ 4.5:1, WCAG AA)"
)