feat(ui): swap the in-turn loader for a brain-wave sweep left of the send button
Build and Push Containers / build-and-push-app (push) Successful in 2m11s
Build and Push Containers / build-and-push-db (push) Successful in 13s

Phase 117 (owner request, live-mockup-confirmed): the phase-109 3-dot
cue becomes a compact ECG trace (49px, P/QRS/T) with a brand sweep
traveling the path (bwdraw, 42/140 dash segment, 0.9s loop), the
loader repositioned left of the button so its appearance never shifts
it. setUiState stays the sole owner of the loader's hidden attribute;
the reduced-motion variant stills the sweep. Unit + lifecycle-E2E pins
updated for the new contract.
This commit is contained in:
2026-09-15 11:55:59 -04:00
parent adf31a4a35
commit 2ac3fc89c2
4 changed files with 209 additions and 101 deletions
+101 -36
View File
@@ -177,13 +177,15 @@ def test_follow_the_tail_pin_logic_is_untouched() -> None:
def test_index_html_carries_exactly_one_turn_loader() -> None:
"""Phase 109 (D16): ``index.html`` carries exactly ONE static
``#turn-loader`` — an empty ``<div>`` (never constructed in JS: the
"""Phase 109 (D16) + phase 117 redesign: ``index.html`` carries
exactly ONE static ``#turn-loader`` — the equalizer shell holding
the FIVE static ``.eq`` square spans (never constructed in JS: the
createElement/textContent house rule), ``aria-hidden="true"``
(decorative — ``#send-status`` carries the meaning), ``hidden`` by
default (idle on load), and it sits INSIDE the composer form — the
composer's status row, the visible companion of the ``#send-status``
line."""
default (idle on load), sitting INSIDE the composer form and LEFT
of the send button (phase 117, owner requirement: the loader's
appearance/disappearance must never shift the button — it eats the
flex:1 textarea's space instead)."""
html = _html()
assert html.count('id="turn-loader"') == 1, (
"exactly one #turn-loader — static markup, never JS-built"
@@ -198,18 +200,40 @@ def test_index_html_carries_exactly_one_turn_loader() -> None:
assert re.search(r"\shidden\s*/?>$", tag), (
"the loader ships hidden (idle on load)"
)
# empty element: the closing tag follows immediately — no children,
# no JS-built HTML anywhere near it.
assert html[match.end() : match.end() + 6] == "</div>", (
"the loader element is empty (static shell)"
# Phase 117: the static brain-wave SVG — EXACTLY the ghost trace +
# the pulse sweep (pathLength-normalized for the CSS dash math),
# and nothing else. No JS-built HTML anywhere near it.
close_idx = html.index("</div>", match.end())
block = html[match.end():close_idx]
assert '<svg class="bw"' in block, (
"the loader carries the static brain-wave SVG"
)
# inside the composer form (the status row), not elsewhere in the
# shell.
assert block.count("<path") == 2, (
"the SVG carries exactly two paths (ghost trace + pulse sweep)"
)
assert 'class="bw-ghost"' in block, "the dim ghost trace is pinned"
assert 'class="bw-pulse"' in block, "the brand sweep path is pinned"
assert 'pathLength="140"' in block, (
"pathLength normalizes the sweep's dash math (styles.css)"
)
assert '<span class="eq"' not in block, (
"the first-mock equalizer spans are gone (the owner picked the "
"brain wave)"
)
# inside the composer form, LEFT of the send button (phase 117):
# textarea → loader → button, so the button stays pinned at the
# row's right edge.
composer_idx = html.find('<form class="composer" id="composer"')
assert composer_idx != -1
form_end = html.find("</form>", composer_idx)
assert composer_idx < match.start() < form_end, (
"the loader sits in the composer's status row"
"the loader sits in the composer"
)
input_idx = html.find('id="message-input"', composer_idx)
btn_idx = html.find('id="send-btn"', composer_idx)
assert input_idx < match.start() < btn_idx, (
"the loader sits LEFT of the send button (the button never "
"shifts when the loader appears/disappears)"
)
@@ -248,18 +272,24 @@ def test_set_ui_state_is_the_sole_owner_of_the_loader() -> None:
)
def test_loader_css_reuses_the_typing_animation_and_reduced_motion() -> None:
"""Phase 109 (D16) — the CSS contract: the ``.turn-loader`` rule
sits NEXT TO the typing-dots rules (after the ``@keyframes typing``
block), reuses the SAME animation name (``typing`` — no new
animation family), a ``prefers-reduced-motion`` block covers it
(static dots, no pulse — §7.2 house law), and the provenance
comment names phase 109 + ``TODO.md``."""
def test_loader_css_brain_wave_contract() -> None:
"""Phase 117 (the phase-109 D16 CSS pin, superseded by the owner's
brain-wave design — picked over the first equalizer mock): the
``.turn-loader`` rule sits NEXT TO the typing-dots rules (after
the ``@keyframes typing`` block); the sweep is a NEW ``bwdraw``
keyframe (a ``stroke-dashoffset`` travel — the 42/140 dash segment
loops the pathLength-normalized path seamlessly); the equalizer
machinery (``.eq`` squares + ``eqpulse``) is GONE; the ghost trace
stays decorative-dim; a ``prefers-reduced-motion`` block stills the
sweep (the full trace shows static — §7.2 house law); the
provenance comment names phase 117."""
css = _css()
keyframes_idx = css.find("@keyframes typing")
assert keyframes_idx != -1, "the typing-dot keyframes exist"
assert keyframes_idx != -1, (
"the typing-dot keyframes still exist (the chat-log typing bubble)"
)
# The MAIN rule (".turn-loader {" — the reduced-motion block's
# selector list never starts that exact string) must sit next to
# selector never starts that exact string) must sit next to
# (after) the typing-dots rules.
rule_idx = css.find(".turn-loader {")
assert rule_idx != -1, "styles.css must carry a .turn-loader rule"
@@ -267,30 +297,65 @@ def test_loader_css_reuses_the_typing_animation_and_reduced_motion() -> None:
"the .turn-loader rule sits next to (after) the typing-dots rules"
)
# The provenance comment: the /* ... */ block immediately preceding
# the rule names phase 109 and TODO.md.
# the rule names phase 117 (the brain-wave redesign).
comment_start = css.rfind("/*", 0, rule_idx)
assert comment_start != -1
comment = css[comment_start:rule_idx]
assert "Phase 109" in comment, "the provenance comment names phase 109"
assert "TODO.md" in comment, "the provenance comment cites the source"
# Reuses the EXISTING typing-dot animation: the same animation name
# in the rule and its pseudo-element rules (no new @keyframes
# family anywhere).
rule_block = css[rule_idx : rule_idx + 1200]
assert re.search(r"animation:\s*typing\b", rule_block), (
"the loader reuses the typing dots' animation (same name — no "
"new animation family)"
assert "Phase 117" in comment, "the provenance comment names phase 117"
# The sweep: the .bw-pulse path runs the NEW bwdraw keyframe (the
# phase-109 "reuse the typing animation" pin is superseded by the
# owner's redesign — an explicit decision, not a silent deviation).
pulse_idx = css.find(".turn-loader .bw-pulse {")
assert pulse_idx != -1, "styles.css must carry a .turn-loader .bw-pulse rule"
pulse_rule = css[pulse_idx : css.find("}", pulse_idx)]
assert re.search(r"animation:\s*bwdraw\b", pulse_rule), (
"the sweep runs the bwdraw keyframe"
)
assert "@keyframes turn" not in css, "no new animation family for the loader"
# The prefers-reduced-motion block covers it: static dots, no pulse.
assert "stroke-dasharray: 42 98" in pulse_rule, (
"the 42/140 bright segment is pinned (the owner-tuned sweep — "
"the dash cycle equals the pathLength-normalized path: seamless)"
)
draw_idx = css.find("@keyframes bwdraw")
assert draw_idx != -1, "the bwdraw keyframes must exist"
draw_block = css[draw_idx : css.find("\n}", draw_idx) + 2]
assert "stroke-dashoffset: 140" in draw_block, "the travel starts at 140"
assert "stroke-dashoffset: 0" in draw_block, (
"the travel ends at 0 (one full normalized loop — seamless)"
)
# The ghost trace stays decorative-dim (ink-soft at 30% — the shape
# must be readable between sweep peaks).
ghost_idx = css.find(".turn-loader .bw-ghost {")
assert ghost_idx != -1, "styles.css must carry a .turn-loader .bw-ghost rule"
ghost_rule = css[ghost_idx : css.find("}", ghost_idx)]
assert "stroke-opacity: 0.3" in ghost_rule, (
"the ghost trace stays dim (the sweep carries the attention)"
)
# The old machinery is GONE: the phase-109 pseudo-dots, the
# typing-animation reuse inside the loader's rules, and the first-
# mock equalizer (.eq squares + eqpulse).
assert ".turn-loader::before" not in css, (
"the phase-109 pseudo-dot rules are gone"
)
loader_region = css[rule_idx:draw_idx]
assert "animation: typing" not in loader_region, (
"the loader no longer reuses the typing animation (phase 117)"
)
assert ".turn-loader .eq" not in css, (
"the first-mock equalizer rules are gone (the owner picked the "
"brain wave)"
)
assert "@keyframes eqpulse" not in css, "the eqpulse keyframes are gone"
# The prefers-reduced-motion block stills the SWEEP: the full trace
# shows static — §7.2 (stilling never hides: visibility is the JS
# hidden attribute in setUiState, D16).
reduced_ok = False
for m in re.finditer(r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css):
if ".turn-loader" in m.group(1) and "animation: none" in m.group(1):
if ".turn-loader .bw-pulse" in m.group(1) and "animation: none" in m.group(1):
reduced_ok = True
break
assert reduced_ok, (
"a prefers-reduced-motion block must cover .turn-loader (static "
"dots, no pulse — §7.2)"
"a prefers-reduced-motion block must still the sweep (static "
"trace, no motion — §7.2)"
)