diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 081337e..e9fff26 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -1255,55 +1255,75 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } details.thinking summary::before { transition: none; } } -/* Phase 109 (TODO.md L3, D16): the persistent in-turn loader — the - constant progress cue in the composer's status row, visible for the - ENTIRE active turn (send → terminal frame). Its hidden attribute is - owned solely by app.js's setUiState (shown iff inFlight — hidden by - construction in every terminal 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). Compact horizontal - three-dot indicator reusing the EXISTING typing-dot animation (the - `typing` keyframes above — no new animation family), sized down for - the status row (4px dots, 3px gap): the element itself is the - middle dot (0.15s delay, like .typing span:nth-child(2)), - ::before/::after the outer two. */ +/* Phase 117 (owner request 2026-09-15, live-mockup-confirmed — the + phase-109 D16 dot cue replaced by a BRAIN-WAVE; the owner picked the + ECG over the first equalizer mock): a compact trace (P bump, QRS + spike, T bump — 49px wide, 8px flat tails; the middle is never + rescaled) in index.html as a static SVG. .bw-ghost is the + always-visible dim trace (the shape reads even when the sweep is + between peaks); .bw-pulse is the same path in brand with a + 42/140 stroke-dash segment that TRAVELS the path — the classic + heart-monitor draw (the bwdraw keyframe below). Still the phase-109 + D16 contract: visible for the ENTIRE active turn (send → terminal + frame), its hidden attribute owned solely by app.js's setUiState + (shown iff inFlight — hidden by construction in every terminal + 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 { display: inline-flex; - align-items: center; - gap: 3px; - width: 4px; - height: 4px; - border-radius: 50%; - background: var(--ink-soft); - opacity: 0.5; - align-self: center; /* the composer row is flex-end aligned — center the cue */ - animation: typing 1.2s infinite ease-in-out; - animation-delay: 0.15s; + align-items: center; /* center the trace against the button */ + width: auto; + height: 44px; /* = .send-btn min-height (44px) — keep in lockstep */ + background: none; + border-radius: 0; + opacity: 1; + align-self: flex-end; /* the composer row is flex-end aligned */ + animation: none; /* the sweep carries the motion, not the container */ } -.turn-loader::before, -.turn-loader::after { - content: ""; - width: 4px; - height: 4px; - border-radius: 50%; - background: var(--ink-soft); - opacity: 0.5; - animation: typing 1.2s infinite ease-in-out; +.turn-loader svg { + width: 49px; + height: 32px; + overflow: visible; /* the QRS spike (y=3) may exceed the box top */ } -.turn-loader::before { animation-delay: 0s; } -.turn-loader::after { animation-delay: 0.3s; } -/* Phase 109 (TODO.md L3, D16): reduced motion — the loader mirrors the - typing dots' treatment (static dots, no pulse — §7.2 house law). - This override MUST sit AFTER the main .turn-loader rule above: - equal specificity, and the later rule wins the cascade — a media - block placed before the main rule would lose to the `animation: - typing …` declaration and the dots would keep pulsing under - reduced motion (the phase-109 task-03 E2E caught it). */ +.turn-loader .bw-ghost { + fill: none; + stroke: var(--ink-soft); + stroke-opacity: 0.3; /* the dim always-visible trace */ + stroke-width: 2; +} +.turn-loader .bw-pulse { + fill: none; + 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) { - .turn-loader, - .turn-loader::before, - .turn-loader::after { animation: none; opacity: 0.7; } + .turn-loader .bw-pulse { animation: none; stroke-dasharray: none; } } /* ---------- Empty state & suggestions ---------- */ diff --git a/frontend/index.html b/frontend/index.html index f4a314b..0f9b61d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -296,23 +296,37 @@ autocomplete="off" maxlength="4000" > + + - - diff --git a/tests/e2e/test_turn_progress_loader.py b/tests/e2e/test_turn_progress_loader.py index e2682c6..190a1fb 100644 --- a/tests/e2e/test_turn_progress_loader.py +++ b/tests/e2e/test_turn_progress_loader.py @@ -54,8 +54,9 @@ context, ``_reset_db`` reseeds the KB deterministically): ``SEND_STATUS`` idle shape (empty — not stuck on a mid-turn label); then, in a context with ``reducedMotion: "reduce"`` (the Playwright context option), a second turn shows the loader STILL visible - mid-turn — the reduced-motion variant stills the dots (computed - ``animation-name: none``), it does not HIDE the cue: visibility is + mid-turn — the reduced-motion variant stills the sweep (the phase- + 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 CSS. """ @@ -281,13 +282,20 @@ def test_loader_a11y_and_reduced_motion( status = page.locator("#send-status") expect(status).to_have_attribute("aria-live", "polite") expect(status).to_have_text("") - # Control (default-motion context): the loader's dots run the - # typing animation — the reduced-motion check below stills it. + # Control (default-motion context): the CONTAINER carries no + # animation — the SWEEP path runs the phase-117 bwdraw travel — + # the reduced-motion check below stills it. assert ( page.evaluate( "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"`` @@ -308,12 +316,13 @@ def test_loader_a11y_and_reduced_motion( # loader is still the visible cue ... expect(rm_page.locator(".tool-call")).to_be_visible(timeout=30_000) expect(rm_page.locator("#turn-loader")).to_be_visible() - # ... and the reduced-motion CSS variant IS applied: static - # dots, no pulse (the animation is gone from the computed - # style — the element itself is unchanged). + # ... and the reduced-motion CSS variant IS applied: the full + # trace shows static, no travel (the animation is gone from + # the sweep path's computed style — the loader itself is + # unchanged). assert ( rm_page.evaluate( - "getComputedStyle(document.querySelector('#turn-loader')).animationName" + "getComputedStyle(document.querySelector('#turn-loader .bw-pulse')).animationName" ) == "none" ) diff --git a/tests/unit/test_frontend_turn_loader.py b/tests/unit/test_frontend_turn_loader.py index 2c7ca7c..7d66075 100644 --- a/tests/unit/test_frontend_turn_loader.py +++ b/tests/unit/test_frontend_turn_loader.py @@ -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 ``
`` (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] == "
", ( - "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("", match.end()) + block = html[match.end():close_idx] + assert '", 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)" )