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:
+64
-44
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user