Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions frontend/src/components/ChatView/ChatInputBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<span className={`chat__action-glyphs chat__action-glyphs--${action}`} aria-hidden="true">
<ArrowUp className="chat__action-glyph chat__action-glyph--send" width={22} height={22} />
<DoubleChevronRight className="chat__action-glyph chat__action-glyph--steer" width={20} height={20} />
<Stop className="chat__action-glyph chat__action-glyph--stop" width={28} height={28} />
</span>
)
}

function PrimaryAction({
sending, listening, hasInput, hasUploading, offline, showSteer, steerReady,
submissionBlocked,
Expand All @@ -155,7 +166,7 @@ function PrimaryAction({
aria-busy={!steerReady}
disabled={!steerReady}
>
<DoubleChevronRight width={20} height={20} />
<PrimaryActionGlyph action="steer" />
</button>
)
}
Expand All @@ -174,7 +185,7 @@ function PrimaryAction({
onClick={onStop}
aria-label="Stop"
>
<Stop width={28} height={28} aria-hidden="true" />
<PrimaryActionGlyph action="stop" />
</button>
)
}
Expand All @@ -196,7 +207,7 @@ function PrimaryAction({
aria-label="Send"
disabled={hasUploading || offline || submissionBlocked}
>
<ArrowUp width={22} height={22} />
<PrimaryActionGlyph action="send" />
</button>
)
}
Expand Down
50 changes: 49 additions & 1 deletion frontend/src/components/ChatView/ChatView.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
45 changes: 41 additions & 4 deletions frontend/src/components/ChatView/__tests__/chatUiPolish.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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,
/<Stop width=\{28\} height=\{28\} aria-hidden="true" \/>/,
/function PrimaryActionGlyph[\s\S]*?<ArrowUp[^>]*width=\{22\} height=\{22\}[\s\S]*?<DoubleChevronRight[^>]*width=\{20\} height=\{20\}[\s\S]*?<Stop[^>]*width=\{28\} height=\{28\}/,
'the stable glyph stack should preserve the established icon sizes',
)
assert.match(
chatInputBar,
/<PrimaryActionGlyph action="steer" \/>[\s\S]*?<PrimaryActionGlyph action="stop" \/>[\s\S]*?<PrimaryActionGlyph action="send" \/>/,
'Steer, Stop, and Send should reuse one mounted glyph-stack component',
)
assert.match(
chatInputBar,
/<Stop className="chat__action-glyph chat__action-glyph--stop" width=\{28\} height=\{28\} \/>/,
'the SDK Stop icon needs a 28px box because its square occupies only part of the viewBox',
)
})
Expand Down Expand Up @@ -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')
Expand All @@ -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', () => {
Expand Down
Loading