diff --git a/frontend/src/components/ChatView/ChatInputBar.jsx b/frontend/src/components/ChatView/ChatInputBar.jsx
index 2182f6914..c27a1ad0f 100644
--- a/frontend/src/components/ChatView/ChatInputBar.jsx
+++ b/frontend/src/components/ChatView/ChatInputBar.jsx
@@ -131,9 +131,20 @@ _touchMql?.addEventListener('change', (e) => { _isTouchPrimary = e.matches })
*
* Send, Steer, and Stop are states of the same primary action. They
* deliberately share the `primary` key so React preserves the 40px action
- * target and swaps only its icon, label, handler, and semantic colour—there
- * must be no empty/black replacement frame between any of them. Mic remains
- * distinct because it is the idle input affordance rather than a turn action. */
+ * target. PrimaryActionGlyph likewise preserves all three glyph layers: this
+ * lets transitions into Stop crossfade continuously instead of replacing the
+ * SVG in one frame. Send → Steer remains immediate. Mic stays distinct because
+ * it is the idle input affordance rather than a turn action. */
+function PrimaryActionGlyph({ action }) {
+ return (
+
+
+
+
+
+ )
+}
+
function PrimaryAction({
sending, listening, hasInput, hasUploading, offline, showSteer, steerReady,
submissionBlocked,
@@ -155,7 +166,7 @@ function PrimaryAction({
aria-busy={!steerReady}
disabled={!steerReady}
>
-
+
)
}
@@ -174,7 +185,7 @@ function PrimaryAction({
onClick={onStop}
aria-label="Stop"
>
-
+
)
}
@@ -196,7 +207,7 @@ function PrimaryAction({
aria-label="Send"
disabled={hasUploading || offline || submissionBlocked}
>
-
+
)
}
diff --git a/frontend/src/components/ChatView/ChatView.css b/frontend/src/components/ChatView/ChatView.css
index c31fb51b6..7dc60bd77 100644
--- a/frontend/src/components/ChatView/ChatView.css
+++ b/frontend/src/components/ChatView/ChatView.css
@@ -2142,6 +2142,48 @@
transition: transform 0.16s ease;
}
+/* Keep the primary action's three SVGs mounted in one grid cell. Send → Steer
+ stays an immediate swap. Transitions into Stop are deliberately sequential:
+ the directional glyph leaves first, then the square settles in, so the two
+ meanings never mix while the shared button shell changes colour. */
+.chat__action-glyphs {
+ width: 28px;
+ height: 28px;
+ display: grid;
+ place-items: center;
+}
+
+.chat__action-glyph {
+ grid-area: 1 / 1;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.chat__action-glyphs--send .chat__action-glyph--send,
+.chat__action-glyphs--steer .chat__action-glyph--steer {
+ opacity: 1;
+}
+
+.chat__action-glyph--stop {
+ transform: scale(0.76) rotate(-8deg);
+}
+
+.chat__action-glyphs--stop .chat__action-glyph--send,
+.chat__action-glyphs--stop .chat__action-glyph--steer {
+ transform: scale(0.82);
+ transition:
+ opacity 0.1s ease-in,
+ transform 0.1s ease-in;
+}
+
+.chat__action-glyphs--stop .chat__action-glyph--stop {
+ opacity: 1;
+ transform: none;
+ transition:
+ opacity 0.12s ease-out 0.1s,
+ transform 0.16s var(--ease-out-soft, ease-out) 0.1s;
+}
+
.chat__send {
background: var(--accent); /* CONTRACT: vivid opaque accent fill */
color: #fff;
@@ -2173,7 +2215,7 @@
.chat__stop:focus-visible {
outline: none;
}
-.chat__stop:focus-visible svg {
+.chat__stop:focus-visible .chat__action-glyph--stop {
outline: 2px solid var(--accent);
/* Offset is measured from the SVG box, so it has to shrink as the glyph
grows: at 28px a 3px offset draws 28 + 2*(3 + 2) = 38px, exactly the
@@ -2183,6 +2225,12 @@
border-radius: 4px;
}
+@media (prefers-reduced-motion: reduce) {
+ .chat__action-glyphs--stop .chat__action-glyph {
+ transition: none;
+ }
+}
+
/* Steer (fast-forward) button — replaces Stop while a turn is streaming
and there are queued messages ready to inject into the live turn. Accent
fill (not danger red) because this is a forward/primary affordance — it
diff --git a/frontend/src/components/ChatView/__tests__/chatUiPolish.test.js b/frontend/src/components/ChatView/__tests__/chatUiPolish.test.js
index 0c4354e40..2c66775b4 100644
--- a/frontend/src/components/ChatView/__tests__/chatUiPolish.test.js
+++ b/frontend/src/components/ChatView/__tests__/chatUiPolish.test.js
@@ -44,7 +44,9 @@ test('stop action has no visible circular shell', () => {
const stopRules = css.match(/\.chat__stop\s*\{[^}]*\}/g) || []
const stopRule = stopRules.find((rule) => /background:\s*transparent/.test(rule)) || ''
const stopFocusRule = css.match(/\.chat__stop:focus-visible\s*\{[^}]*\}/)?.[0] || ''
- const stopGlyphFocusRule = css.match(/\.chat__stop:focus-visible svg\s*\{[^}]*\}/)?.[0] || ''
+ const stopGlyphFocusRule = css.match(
+ /\.chat__stop:focus-visible \.chat__action-glyph--stop\s*\{[^}]*\}/,
+ )?.[0] || ''
assert.match(stopRule, /background:\s*transparent/,
'Stop keeps the touch target but removes the visible circular fill')
@@ -56,10 +58,20 @@ test('stop action has no visible circular shell', () => {
'Stop keyboard focus should move to the square glyph')
})
-test('stop action keeps a legible glyph inside its full touch target', () => {
+test('primary action keeps all glyph layers mounted at their legible sizes', () => {
assert.match(
chatInputBar,
- //,
+ /function PrimaryActionGlyph[\s\S]*?]*width=\{22\} height=\{22\}[\s\S]*?]*width=\{20\} height=\{20\}[\s\S]*?]*width=\{28\} height=\{28\}/,
+ 'the stable glyph stack should preserve the established icon sizes',
+ )
+ assert.match(
+ chatInputBar,
+ /[\s\S]*?[\s\S]*?/,
+ 'Steer, Stop, and Send should reuse one mounted glyph-stack component',
+ )
+ assert.match(
+ chatInputBar,
+ //,
'the SDK Stop icon needs a 28px box because its square occupies only part of the viewBox',
)
})
@@ -124,11 +136,20 @@ test('message references use a bounded responsive two-column grid', () => {
'narrow panes should fall back to one reference column')
})
-test('Send, Steer, and Stop never fade through an empty replacement frame', () => {
+test('transitions into Stop are sequential while Send to Steer stays immediate', () => {
const css = stripComments(chatCss)
const sendRule = css.match(/\.chat__send\s*\{[^}]*\}/)?.[0] || ''
const steerRule = css.match(/\.chat__steer\s*\{[^}]*\}/)?.[0] || ''
const stopRules = css.match(/\.chat__stop\s*\{[^}]*\}/g)?.join('\n') || ''
+ const staticDirectionalRule = css.match(
+ /\.chat__action-glyphs--send \.chat__action-glyph--send,\s*\.chat__action-glyphs--steer \.chat__action-glyph--steer\s*\{[^}]*\}/,
+ )?.[0] || ''
+ const outgoingStopRule = css.match(
+ /\.chat__action-glyphs--stop \.chat__action-glyph--send,\s*\.chat__action-glyphs--stop \.chat__action-glyph--steer\s*\{[^}]*\}/,
+ )?.[0] || ''
+ const visibleStopRule = css.match(
+ /\.chat__action-glyphs--stop \.chat__action-glyph--stop\s*\{[^}]*\}/,
+ )?.[0] || ''
assert.doesNotMatch(sendRule, /animation:/,
'Send must keep the shared action target continuously visible')
@@ -138,6 +159,22 @@ test('Send, Steer, and Stop never fade through an empty replacement frame', () =
'Stop must appear immediately instead of starting at opacity zero')
assert.doesNotMatch(css, /@keyframes\s+chat-action-reveal/,
'the empty-frame reveal must not remain available to a primary action')
+ assert.doesNotMatch(staticDirectionalRule, /transition:/,
+ 'Send to Steer should retain its existing immediate icon swap')
+ assert.match(outgoingStopRule, /opacity 0\.1s ease-in/,
+ 'the directional glyph should leave before Stop appears')
+ assert.match(outgoingStopRule, /transform 0\.1s ease-in/)
+ assert.match(visibleStopRule, /opacity:\s*1/,
+ 'Stop must be the visible layer at the end of the transition')
+ assert.match(visibleStopRule, /transform:\s*none/)
+ assert.match(visibleStopRule, /opacity 0\.12s ease-out 0\.1s/,
+ 'Stop should wait for the directional glyph to finish before appearing')
+ assert.match(visibleStopRule,
+ /transform 0\.16s var\(--ease-out-soft, ease-out\) 0\.1s/,
+ 'the delayed Stop glyph should still settle softly')
+ assert.match(css,
+ /@media\s*\(prefers-reduced-motion:\s*reduce\)[\s\S]*?\.chat__action-glyphs--stop \.chat__action-glyph\s*\{\s*transition:\s*none/,
+ 'reduced-motion users should get the final glyph without the crossfade')
})
test('running activity uses a masked solid-text sweep, not gradient-clipped text', () => {