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; }
|
||||
}
|
||||
|
||||
/* 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 ---------- */
|
||||
|
||||
+26
-12
@@ -296,23 +296,37 @@
|
||||
autocomplete="off"
|
||||
maxlength="4000"
|
||||
></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">
|
||||
<span class="spinner" aria-hidden="true" hidden></span>
|
||||
<span class="btn-label" id="send-label">Send</span>
|
||||
<span class="visually-hidden" aria-live="polite" id="send-status"></span>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user