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
+64 -44
View File
@@ -1255,55 +1255,75 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
details.thinking summary::before { transition: none; } details.thinking summary::before { transition: none; }
} }
/* Phase 109 (TODO.md L3, D16): the persistent in-turn loader — the /* Phase 117 (owner request 2026-09-15, live-mockup-confirmed — the
constant progress cue in the composer's status row, visible for the phase-109 D16 dot cue replaced by a BRAIN-WAVE; the owner picked the
ENTIRE active turn (send → terminal frame). Its hidden attribute is ECG over the first equalizer mock): a compact trace (P bump, QRS
owned solely by app.js's setUiState (shown iff inFlight — hidden by spike, T bump — 49px wide, 8px flat tails; the middle is never
construction in every terminal state, PLAN §7.4). DECORATIVE rescaled) in index.html as a static SVG. .bw-ghost is the
(aria-hidden in index.html): #send-status carries the state's always-visible dim trace (the shape reads even when the sweep is
meaning, so contrast is N/A here (§7.2 "text + color, never color between peaks); .bw-pulse is the same path in brand with a
alone" — the state TEXT stays in #send-status). Compact horizontal 42/140 stroke-dash segment that TRAVELS the path — the classic
three-dot indicator reusing the EXISTING typing-dot animation (the heart-monitor draw (the bwdraw keyframe below). Still the phase-109
`typing` keyframes above — no new animation family), sized down for D16 contract: visible for the ENTIRE active turn (send → terminal
the status row (4px dots, 3px gap): the element itself is the frame), its hidden attribute owned solely by app.js's setUiState
middle dot (0.15s delay, like .typing span:nth-child(2)), (shown iff inFlight — hidden by construction in every terminal
::before/::after the outer two. */ state, PLAN §7.4). DECORATIVE (aria-hidden in index.html):
#send-status carries the state's meaning, so contrast is N/A here
(§7.2 "text + color, never color alone" — the state TEXT stays in
#send-status). The container is 44px = .send-btn min-height (house
cross-reference — keep in lockstep), the trace centered against the
button. The NEW bwdraw family is deliberate: the phase-109 "reuse
the typing animation" pin is superseded by the owner's redesign.
The loader sits LEFT of the button in the markup (index.html) so
its appearance/disappearance never shifts the button. */
.turn-loader { .turn-loader {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center; /* center the trace against the button */
gap: 3px; width: auto;
width: 4px; height: 44px; /* = .send-btn min-height (44px) — keep in lockstep */
height: 4px; background: none;
border-radius: 50%; border-radius: 0;
background: var(--ink-soft); opacity: 1;
opacity: 0.5; align-self: flex-end; /* the composer row is flex-end aligned */
align-self: center; /* the composer row is flex-end aligned — center the cue */ animation: none; /* the sweep carries the motion, not the container */
animation: typing 1.2s infinite ease-in-out;
animation-delay: 0.15s;
} }
.turn-loader::before, .turn-loader svg {
.turn-loader::after { width: 49px;
content: ""; height: 32px;
width: 4px; overflow: visible; /* the QRS spike (y=3) may exceed the box top */
height: 4px;
border-radius: 50%;
background: var(--ink-soft);
opacity: 0.5;
animation: typing 1.2s infinite ease-in-out;
} }
.turn-loader::before { animation-delay: 0s; } .turn-loader .bw-ghost {
.turn-loader::after { animation-delay: 0.3s; } fill: none;
/* Phase 109 (TODO.md L3, D16): reduced motion — the loader mirrors the stroke: var(--ink-soft);
typing dots' treatment (static dots, no pulse — §7.2 house law). stroke-opacity: 0.3; /* the dim always-visible trace */
This override MUST sit AFTER the main .turn-loader rule above: stroke-width: 2;
equal specificity, and the later rule wins the cascade — a media }
block placed before the main rule would lose to the `animation: .turn-loader .bw-pulse {
typing …` declaration and the dots would keep pulsing under fill: none;
reduced motion (the phase-109 task-03 E2E caught it). */ stroke: var(--brand);
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
/* The sweep: a 42/140 bright segment on the pathLength-normalized
path — the dash cycle (42 + 98) equals the normalized length, so
the bwdraw loop is seamless (no visible restart point). */
stroke-dasharray: 42 98;
animation: bwdraw 0.9s linear infinite;
}
@keyframes bwdraw {
from { stroke-dashoffset: 140; }
to { stroke-dashoffset: 0; }
}
/* Phase 117: reduced motion — the sweep is STILLED (the full trace
shows static, no travel — §7.2 house law), never HIDDEN: visibility
is owned by the JS hidden attribute in setUiState (D16), not by
CSS. This override MUST sit AFTER the main rules above: equal
specificity, and the later rule wins the cascade — a media block
placed before the main rule would lose the cascade and the sweep
would keep traveling under reduced motion (the phase-109 cascade
pitfall, carried over). */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.turn-loader, .turn-loader .bw-pulse { animation: none; stroke-dasharray: none; }
.turn-loader::before,
.turn-loader::after { animation: none; opacity: 0.7; }
} }
/* ---------- Empty state & suggestions ---------- */ /* ---------- Empty state & suggestions ---------- */
+26 -12
View File
@@ -296,23 +296,37 @@
autocomplete="off" autocomplete="off"
maxlength="4000" maxlength="4000"
></textarea> ></textarea>
<!-- Phase 117 (owner request 2026-09-15, live-mockup-confirmed):
the persistent in-turn loader — a BRAIN-WAVE: a compact ECG
trace (P bump, QRS spike, T bump — 49px wide, 8px flat
tails; the owner picked it over the first equalizer mock).
.bw-ghost is the always-visible dim trace; .bw-pulse is the
brand sweep — a stroke-dasharray segment that travels the
path (styles.css bwdraw, pathLength-normalized for a
seamless loop). Positioned LEFT of the button on purpose:
the row is flex-end aligned with a flex:1 textarea, so the
loader's appearance and disappearance eat the textarea's
space and the button NEVER shifts (the owner's no-move
requirement). The phase-109 D16 contract stands: DECORATIVE
(aria-hidden) — #send-status stays the sole a11y announcer,
so the state's meaning never moves; hidden by default (idle
on load); app.js's setUiState is the SOLE writer of its
hidden attribute — shown iff the turn is in flight, hidden
by construction in every terminal state (PLAN §7.4 never-
stale, no per-handler cleanup). Never constructed in JS: the
createElement/textContent house rule — the SVG is static
markup too. -->
<div id="turn-loader" class="turn-loader" aria-hidden="true" hidden>
<svg class="bw" viewBox="0 0 49 32">
<path class="bw-ghost" d="M0 16 H8 L15 12 L18 16 L21 18 L24 3 L27 29 L30 16 H35 L38 11 L41 16 H49"></path>
<path class="bw-pulse" d="M0 16 H8 L15 12 L18 16 L21 18 L24 3 L27 29 L30 16 H35 L38 11 L41 16 H49" pathLength="140"></path>
</svg>
</div>
<button type="submit" class="send-btn" id="send-btn"> <button type="submit" class="send-btn" id="send-btn">
<span class="spinner" aria-hidden="true" hidden></span> <span class="spinner" aria-hidden="true" hidden></span>
<span class="btn-label" id="send-label">Send</span> <span class="btn-label" id="send-label">Send</span>
<span class="visually-hidden" aria-live="polite" id="send-status"></span> <span class="visually-hidden" aria-live="polite" id="send-status"></span>
</button> </button>
<!-- Phase 109 (TODO.md L3, D16): the persistent in-turn loader —
ONE static element in the composer's status row, the
visible companion of the #send-status line. It is
DECORATIVE (aria-hidden): #send-status stays the sole a11y
announcer, so the state's meaning never moves (the L1791
house pattern). Hidden by default (idle on load); app.js's
setUiState is the SOLE writer of its hidden attribute —
shown iff the turn is in flight, hidden by construction in
every terminal state (PLAN §7.4 never-stale, no
per-handler cleanup). Never constructed in JS: the
createElement/textContent house rule. -->
<div id="turn-loader" class="turn-loader" aria-hidden="true" hidden></div>
</form> </form>
</div> </div>
</div> </div>
+18 -9
View File
@@ -54,8 +54,9 @@ context, ``_reset_db`` reseeds the KB deterministically):
``SEND_STATUS`` idle shape (empty — not stuck on a mid-turn label); ``SEND_STATUS`` idle shape (empty — not stuck on a mid-turn label);
then, in a context with ``reducedMotion: "reduce"`` (the Playwright then, in a context with ``reducedMotion: "reduce"`` (the Playwright
context option), a second turn shows the loader STILL visible context option), a second turn shows the loader STILL visible
mid-turn — the reduced-motion variant stills the dots (computed mid-turn — the reduced-motion variant stills the sweep (the phase-
``animation-name: none``), it does not HIDE the cue: visibility is 117 brain wave: computed ``animation-name: none`` on the ``.bw-pulse``
path), it does not HIDE the cue: visibility is
owned by the JS ``hidden`` attribute in ``setUiState`` (D16), not by owned by the JS ``hidden`` attribute in ``setUiState`` (D16), not by
CSS. CSS.
""" """
@@ -281,13 +282,20 @@ def test_loader_a11y_and_reduced_motion(
status = page.locator("#send-status") status = page.locator("#send-status")
expect(status).to_have_attribute("aria-live", "polite") expect(status).to_have_attribute("aria-live", "polite")
expect(status).to_have_text("") expect(status).to_have_text("")
# Control (default-motion context): the loader's dots run the # Control (default-motion context): the CONTAINER carries no
# typing animation — the reduced-motion check below stills it. # animation — the SWEEP path runs the phase-117 bwdraw travel —
# the reduced-motion check below stills it.
assert ( assert (
page.evaluate( page.evaluate(
"getComputedStyle(document.querySelector('#turn-loader')).animationName" "getComputedStyle(document.querySelector('#turn-loader')).animationName"
) )
== "typing" == "none"
)
assert (
page.evaluate(
"getComputedStyle(document.querySelector('#turn-loader .bw-pulse')).animationName"
)
== "bwdraw"
) )
# Reduced motion: a FRESH context with ``reducedMotion: "reduce"`` # Reduced motion: a FRESH context with ``reducedMotion: "reduce"``
@@ -308,12 +316,13 @@ def test_loader_a11y_and_reduced_motion(
# loader is still the visible cue ... # loader is still the visible cue ...
expect(rm_page.locator(".tool-call")).to_be_visible(timeout=30_000) expect(rm_page.locator(".tool-call")).to_be_visible(timeout=30_000)
expect(rm_page.locator("#turn-loader")).to_be_visible() expect(rm_page.locator("#turn-loader")).to_be_visible()
# ... and the reduced-motion CSS variant IS applied: static # ... and the reduced-motion CSS variant IS applied: the full
# dots, no pulse (the animation is gone from the computed # trace shows static, no travel (the animation is gone from
# style — the element itself is unchanged). # the sweep path's computed style — the loader itself is
# unchanged).
assert ( assert (
rm_page.evaluate( rm_page.evaluate(
"getComputedStyle(document.querySelector('#turn-loader')).animationName" "getComputedStyle(document.querySelector('#turn-loader .bw-pulse')).animationName"
) )
== "none" == "none"
) )
+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: def test_index_html_carries_exactly_one_turn_loader() -> None:
"""Phase 109 (D16): ``index.html`` carries exactly ONE static """Phase 109 (D16) + phase 117 redesign: ``index.html`` carries
``#turn-loader`` — an empty ``<div>`` (never constructed in JS: the 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"`` createElement/textContent house rule), ``aria-hidden="true"``
(decorative — ``#send-status`` carries the meaning), ``hidden`` by (decorative — ``#send-status`` carries the meaning), ``hidden`` by
default (idle on load), and it sits INSIDE the composer form — the default (idle on load), sitting INSIDE the composer form and LEFT
composer's status row, the visible companion of the ``#send-status`` of the send button (phase 117, owner requirement: the loader's
line.""" appearance/disappearance must never shift the button — it eats the
flex:1 textarea's space instead)."""
html = _html() html = _html()
assert html.count('id="turn-loader"') == 1, ( assert html.count('id="turn-loader"') == 1, (
"exactly one #turn-loader — static markup, never JS-built" "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), ( assert re.search(r"\shidden\s*/?>$", tag), (
"the loader ships hidden (idle on load)" "the loader ships hidden (idle on load)"
) )
# empty element: the closing tag follows immediately — no children, # Phase 117: the static brain-wave SVG — EXACTLY the ghost trace +
# no JS-built HTML anywhere near it. # the pulse sweep (pathLength-normalized for the CSS dash math),
assert html[match.end() : match.end() + 6] == "</div>", ( # and nothing else. No JS-built HTML anywhere near it.
"the loader element is empty (static shell)" 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 assert block.count("<path") == 2, (
# shell. "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"') composer_idx = html.find('<form class="composer" id="composer"')
assert composer_idx != -1 assert composer_idx != -1
form_end = html.find("</form>", composer_idx) form_end = html.find("</form>", composer_idx)
assert composer_idx < match.start() < form_end, ( 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: def test_loader_css_brain_wave_contract() -> None:
"""Phase 109 (D16) — the CSS contract: the ``.turn-loader`` rule """Phase 117 (the phase-109 D16 CSS pin, superseded by the owner's
sits NEXT TO the typing-dots rules (after the ``@keyframes typing`` brain-wave design — picked over the first equalizer mock): the
block), reuses the SAME animation name (``typing`` — no new ``.turn-loader`` rule sits NEXT TO the typing-dots rules (after
animation family), a ``prefers-reduced-motion`` block covers it the ``@keyframes typing`` block); the sweep is a NEW ``bwdraw``
(static dots, no pulse — §7.2 house law), and the provenance keyframe (a ``stroke-dashoffset`` travel — the 42/140 dash segment
comment names phase 109 + ``TODO.md``.""" 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() css = _css()
keyframes_idx = css.find("@keyframes typing") 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 # 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. # (after) the typing-dots rules.
rule_idx = css.find(".turn-loader {") rule_idx = css.find(".turn-loader {")
assert rule_idx != -1, "styles.css must carry a .turn-loader rule" 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 .turn-loader rule sits next to (after) the typing-dots rules"
) )
# The provenance comment: the /* ... */ block immediately preceding # 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) comment_start = css.rfind("/*", 0, rule_idx)
assert comment_start != -1 assert comment_start != -1
comment = css[comment_start:rule_idx] comment = css[comment_start:rule_idx]
assert "Phase 109" in comment, "the provenance comment names phase 109" assert "Phase 117" in comment, "the provenance comment names phase 117"
assert "TODO.md" in comment, "the provenance comment cites the source" # The sweep: the .bw-pulse path runs the NEW bwdraw keyframe (the
# Reuses the EXISTING typing-dot animation: the same animation name # phase-109 "reuse the typing animation" pin is superseded by the
# in the rule and its pseudo-element rules (no new @keyframes # owner's redesign — an explicit decision, not a silent deviation).
# family anywhere). pulse_idx = css.find(".turn-loader .bw-pulse {")
rule_block = css[rule_idx : rule_idx + 1200] assert pulse_idx != -1, "styles.css must carry a .turn-loader .bw-pulse rule"
assert re.search(r"animation:\s*typing\b", rule_block), ( pulse_rule = css[pulse_idx : css.find("}", pulse_idx)]
"the loader reuses the typing dots' animation (same name — no " assert re.search(r"animation:\s*bwdraw\b", pulse_rule), (
"new animation family)" "the sweep runs the bwdraw keyframe"
) )
assert "@keyframes turn" not in css, "no new animation family for the loader" assert "stroke-dasharray: 42 98" in pulse_rule, (
# The prefers-reduced-motion block covers it: static dots, no pulse. "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 reduced_ok = False
for m in re.finditer(r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css): 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 reduced_ok = True
break break
assert reduced_ok, ( assert reduced_ok, (
"a prefers-reduced-motion block must cover .turn-loader (static " "a prefers-reduced-motion block must still the sweep (static "
"dots, no pulse — §7.2)" "trace, no motion — §7.2)"
) )