fix(chat): render typing-indicator elapsed hint as horizontal text
Build and Push Containers / build-and-push-app (push) Successful in 1m43s
Build and Push Containers / build-and-push-db (push) Successful in 15s

The phase-87 ".typing-elapsed" dot-geometry reset (specificity 0,1,0)
lost every shared declaration to the ".typing span" dot rule
(0,1,1): the hint rendered as an 8x8px bouncing dot and the "Ns" text
wrapped one character per line below the bubble (overflow-wrap:
anywhere on .bubble). Phase 87's e2e checked text values only, so the
squish shipped unseen.

- retarget the reset at ".typing span.typing-elapsed" (0,2,1) so it
  actually wins; center the dots while the hint line is taller
- unit: pin the reset's selector context (specificity regression guard)
- e2e: layout pin on the live hint — no dot animation, not an 8px box,
  horizontal single-line bounding box
- before/after verification screenshots in
  .agents/reports/87_big_read_progress/

Verified: unit 41 passed, phase-87 e2e 4 passed (isolated), ruff +
pyright clean.
This commit is contained in:
2026-09-08 10:03:10 -04:00
parent 5abe8871e3
commit 10fd367962
5 changed files with 58 additions and 7 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

+12 -3
View File
@@ -1110,7 +1110,10 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
} }
/* typing indicator */ /* typing indicator */
.typing { display: inline-flex; gap: 5px; padding: 0.9rem 1rem; } /* align-items: center — the dots stay vertically centered when the
phase-87 "Ns" hint (a full text line) makes the row taller; with
only the three 8px dots the centering is a visual no-op. */
.typing { display: inline-flex; align-items: center; gap: 5px; padding: 0.9rem 1rem; }
.typing span { .typing span {
width: 8px; height: 8px; width: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
@@ -1129,8 +1132,14 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
ink-soft on the bubble surface (the AA pairing), small mono like ink-soft on the bubble surface (the AA pairing), small mono like
every status line. Plain text: no animation, no motion opt-out. every status line. Plain text: no animation, no motion opt-out.
The span is a sibling of the dots inside the same .typing bubble, so The span is a sibling of the dots inside the same .typing bubble, so
it resets the dot geometry (.typing span) it would otherwise inherit. */ it resets the dot geometry (.typing span) it would otherwise inherit.
.typing-elapsed { The selector MUST stay ".typing span.typing-elapsed": a bare
".typing-elapsed" (0,1,0) LOSES the specificity war to the dot rule
".typing span" (0,1,1) — every reset below (width/height/border-
radius/background/opacity/animation) would be overridden and the
hint would render as an 8×8px bouncing dot with the "Ns" text
wrapping one character per line (phase-87 regression, 2026-09). */
.typing span.typing-elapsed {
width: auto; width: auto;
height: auto; height: auto;
border-radius: 0; border-radius: 0;
+32 -1
View File
@@ -567,7 +567,15 @@ def test_typing_indicator_shows_visible_elapsed(
the screen-reader channel the unit pins protect) read together at the screen-reader channel the unit pins protect) read together at
the same moment; a second value sample ≥1.5 s later is strictly the same moment; a second value sample ≥1.5 s later is strictly
greater (the clock ticks while the gap holds — the next frame is greater (the clock ticks while the gap holds — the next frame is
≥6 s away).""" ≥6 s away).
GEOMETRY GUARD (2026-09, phase-87 fix): the hint must render as a
single horizontal text line inline with the dots — NOT as the 8×8px
bouncing dot it became when the bare ``.typing-elapsed`` selector
lost the specificity war to the ``.typing span`` dot rule (the
"Ns" text then wrapped one character per line below the bubble).
Phase 87's original run checked text values only, so the squish
shipped unseen; this assertion is the layout pin that was missing."""
page.set_default_timeout(30_000) page.set_default_timeout(30_000)
_reset_db(_seed) _reset_db(_seed)
_submit_tools_turn(page, app_url) _submit_tools_turn(page, app_url)
@@ -580,6 +588,29 @@ def test_typing_indicator_shows_visible_elapsed(
v1 = _wait_visible_hint_and_aria(page, 10, TYPING_HINT_TIMEOUT_MS) v1 = _wait_visible_hint_and_aria(page, 10, TYPING_HINT_TIMEOUT_MS)
assert v1 >= 10, f"the hint appeared below the 10 s gate: {v1}s" assert v1 >= 10, f"the hint appeared below the 10 s gate: {v1}s"
# Layout pin: the hint is a horizontal text line, not the 8×8px
# dot it degrades to when the dot-geometry reset loses the
# specificity war (width 8px + overflow-wrap: anywhere then wraps
# the "Ns" one character per line, spilling below the bubble).
geom = page.locator("#typing-indicator .typing-elapsed").first.evaluate(
"""e => {
const s = getComputedStyle(e);
const r = e.getBoundingClientRect();
return {anim: s.animationName, w: s.width, h: s.height,
bw: r.width, bh: r.height};
}"""
)
assert geom["anim"] == "none", (
f"the hint must be plain text, not the dot animation: {geom}"
)
assert geom["w"] != "8px" and geom["h"] != "8px", (
f"the hint must not be an 8px dot: {geom}"
)
assert geom["bw"] >= 16 and geom["bw"] > geom["bh"], (
"the 'Ns' text must run HORIZONTALLY (one line ≥3ch wide), not "
f"wrap one character per line: {geom}"
)
# The clock ticks while the gap holds: ≥1.5 s later the value is # The clock ticks while the gap holds: ≥1.5 s later the value is
# strictly greater (the read frame is ≥6 s away; the first delta # strictly greater (the read frame is ≥6 s away; the first delta
# — which removes the whole indicator — is ≈25 s from submit). # — which removes the whole indicator — is ≈25 s from submit).
+14 -3
View File
@@ -111,10 +111,21 @@ def test_typing_elapsed_css_rule_is_the_aa_pairing() -> None:
language as every status line. Plain text — ``animation: none`` + language as every status line. Plain text — ``animation: none`` +
no background, and the dot-geometry reset (the span is a sibling of no background, and the dot-geometry reset (the span is a sibling of
the dots inside the .typing bubble, so without the reset it would the dots inside the .typing bubble, so without the reset it would
render as an 8px bouncing dot, not a hint).""" render as an 8px bouncing dot, not a hint).
The reset's SELECTOR is pinned too (phase-87 regression, 2026-09):
a bare ``.typing-elapsed`` (specificity 0,1,0) loses every shared
declaration to the dot rule ``.typing span`` (0,1,1) — the hint
then rendered as an 8×8px bouncing dot with the "Ns" text wrapping
one character per line below the bubble. The reset must carry the
span context (``.typing span.typing-elapsed``, 0,2,1) to win."""
css = _css() css = _css()
block = re.search(r"\.typing-elapsed \{([\s\S]*?)\n\}", css) assert ".typing span.typing-elapsed {" in css, (
assert block, "styles.css must style .typing-elapsed" "the dot-geometry reset must target the span in .typing context "
"(bare .typing-elapsed loses the specificity war to .typing span)"
)
block = re.search(r"\.typing span\.typing-elapsed \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .typing span.typing-elapsed"
body = block.group(1) body = block.group(1)
for prop in ( for prop in (
"font-family: var(--mono)", "font-family: var(--mono)",