feat(ui): swap the in-turn loader for a brain-wave sweep left of the send button
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:
@@ -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)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user