feat: polish send button — hidden until content, icon-circle, pop-in animation

- index.html: btnSend hidden by default (display:none), icon-only (upward
  arrow SVG, no text label), title attribute for accessibility

- style.css: new send-btn design — 34px circle, blue fill (#7cb9ff),
  subtle glow box-shadow, scale() hover/active for tactile feel,
  .send-btn.visible with @keyframes send-pop-in (scale+opacity spring
  using cubic-bezier(.34,1.56,.64,1) for a satisfying pop). Mobile
  override updated to preserve circle dimensions.

- ui.js: updateSendBtn() — shows button with pop-in animation when
  textarea has content OR files are attached and agent is not busy;
  hides instantly when content is cleared. Hooked into setBusy() and
  renderTray() so button state tracks all content sources correctly.

- boot.js: input event listener calls updateSendBtn() on every keystroke.

- messages.js: autoResize() calls updateSendBtn() so button disappears
  immediately after send clears the textarea.

- tests/test_sprint21.py: 33 tests covering HTML structure, CSS design
  (circle shape, colors, animations, keyframes), JS logic (updateSendBtn,
  setBusy, renderTray, autoResize integration), and regressions
  (363 total, all pass).
This commit is contained in:
Nathan Esquenazi
2026-04-03 14:10:59 +00:00
parent 59a92e03d8
commit dcb21dfd37
6 changed files with 378 additions and 10 deletions

View File

@@ -145,6 +145,7 @@ $('modelSelect').onchange=async()=>{
}; };
$('msg').addEventListener('input',()=>{ $('msg').addEventListener('input',()=>{
autoResize(); autoResize();
updateSendBtn();
const text=$('msg').value; const text=$('msg').value;
if(text.startsWith('/')&&text.indexOf('\n')===-1){ if(text.startsWith('/')&&text.indexOf('\n')===-1){
const prefix=text.slice(1); const prefix=text.slice(1);

View File

@@ -231,9 +231,8 @@
</button> </button>
</div> </div>
<div class="composer-right"> <div class="composer-right">
<button class="send-btn" id="btnSend"> <button class="send-btn" id="btnSend" title="Send message" style="display:none">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
Send
</button> </button>
</div> </div>
</div> </div>

View File

@@ -237,7 +237,7 @@ function transcript(){
return lines.join('\n'); return lines.join('\n');
} }
function autoResize(){const el=$('msg');el.style.height='auto';el.style.height=Math.min(el.scrollHeight,200)+'px';} function autoResize(){const el=$('msg');el.style.height='auto';el.style.height=Math.min(el.scrollHeight,200)+'px';updateSendBtn();}
// ── Approval polling ── // ── Approval polling ──

View File

@@ -193,10 +193,12 @@
.mic-status{font-size:11px;color:#e94560;padding:4px 12px;display:flex;align-items:center;gap:6px;} .mic-status{font-size:11px;color:#e94560;padding:4px 12px;display:flex;align-items:center;gap:6px;}
.mic-dot{width:6px;height:6px;border-radius:50%;background:#e94560;animation:mic-pulse 1.2s ease-in-out infinite;flex-shrink:0;} .mic-dot{width:6px;height:6px;border-radius:50%;background:#e94560;animation:mic-pulse 1.2s ease-in-out infinite;flex-shrink:0;}
.status-text{font-size:11px;color:var(--muted);padding-left:4px;} .status-text{font-size:11px;color:var(--muted);padding-left:4px;}
.send-btn{padding:7px 18px;border-radius:10px;font-size:13px;font-weight:600;background:linear-gradient(135deg,#5ba8f5,#7cb9ff);border:none;color:#0a1628;cursor:pointer;display:flex;align-items:center;gap:6px;transition:all .15s;flex-shrink:0;letter-spacing:.01em;} .send-btn{width:34px;height:34px;border-radius:50%;background:#7cb9ff;border:none;color:#0a1628;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s,transform .15s,box-shadow .15s;box-shadow:0 2px 8px rgba(124,185,255,.35);}
.send-btn:hover{background:linear-gradient(135deg,#7cb9ff,#a0d0ff);transform:translateY(-1px);} .send-btn:hover{background:#a0d0ff;transform:scale(1.08);box-shadow:0 4px 14px rgba(124,185,255,.5);}
.send-btn:active{transform:translateY(0);} .send-btn:active{transform:scale(0.95);box-shadow:0 1px 4px rgba(124,185,255,.25);}
.send-btn:disabled{opacity:.4;cursor:not-allowed;} .send-btn:disabled{opacity:.35;cursor:not-allowed;transform:none;box-shadow:none;}
.send-btn.visible{animation:send-pop-in .18s cubic-bezier(.34,1.56,.64,1) forwards;}
@keyframes send-pop-in{from{opacity:0;transform:scale(.55);}to{opacity:1;transform:scale(1);}}
.upload-bar-wrap{display:none;height:3px;background:rgba(255,255,255,.06);border-radius:0 0 16px 16px;overflow:hidden;} .upload-bar-wrap{display:none;height:3px;background:rgba(255,255,255,.06);border-radius:0 0 16px 16px;overflow:hidden;}
.upload-bar-wrap.active{display:block;} .upload-bar-wrap.active{display:block;}
.upload-bar{height:100%;background:linear-gradient(90deg,var(--blue),#a0d0ff);width:0%;transition:width .3s ease;} .upload-bar{height:100%;background:linear-gradient(90deg,var(--blue),#a0d0ff);width:0%;transition:width .3s ease;}
@@ -276,7 +278,7 @@
.composer-wrap{padding:8px 10px 12px!important;} .composer-wrap{padding:8px 10px 12px!important;}
.composer-box{border-radius:12px;} .composer-box{border-radius:12px;}
.composer-box textarea{font-size:16px;min-height:40px;} .composer-box textarea{font-size:16px;min-height:40px;}
.send-btn{padding:6px 14px;font-size:13px;} .send-btn{width:32px;height:32px;}
/* Empty state */ /* Empty state */
.empty-state h2{font-size:18px;} .empty-state h2{font-size:18px;}
.empty-state p{font-size:13px;} .empty-state p{font-size:13px;}

View File

@@ -187,9 +187,25 @@ function setStatus(t){
if(dismiss)dismiss.style.display=(!transient && !S.busy)?'inline':'none'; if(dismiss)dismiss.style.display=(!transient && !S.busy)?'inline':'none';
} }
} }
function updateSendBtn(){
const btn=$('btnSend');
if(!btn) return;
const hasContent=$('msg').value.trim().length>0||S.pendingFiles.length>0;
const shouldShow=hasContent&&!S.busy;
if(shouldShow&&btn.style.display==='none'){
btn.style.display='';
// Remove then re-add class to retrigger animation each time
btn.classList.remove('visible');
requestAnimationFrame(()=>btn.classList.add('visible'));
} else if(!shouldShow&&btn.style.display!=='none'){
btn.style.display='none';
btn.classList.remove('visible');
}
}
function setBusy(v){ function setBusy(v){
S.busy=v; S.busy=v;
$('btnSend').disabled=v; $('btnSend').disabled=v;
updateSendBtn();
const dots=$('activityDots'); const dots=$('activityDots');
if(dots) dots.style.display=v?'flex':'none'; if(dots) dots.style.display=v?'flex':'none';
if(!v){ if(!v){
@@ -920,8 +936,9 @@ async function promptNewFolder(){
function renderTray(){ function renderTray(){
const tray=$('attachTray');tray.innerHTML=''; const tray=$('attachTray');tray.innerHTML='';
if(!S.pendingFiles.length){tray.classList.remove('has-files');return;} if(!S.pendingFiles.length){tray.classList.remove('has-files');updateSendBtn();return;}
tray.classList.add('has-files'); tray.classList.add('has-files');
updateSendBtn();
S.pendingFiles.forEach((f,i)=>{ S.pendingFiles.forEach((f,i)=>{
const chip=document.createElement('div');chip.className='attach-chip'; const chip=document.createElement('div');chip.className='attach-chip';
chip.innerHTML=`&#128206; ${esc(f.name)} <button title="Remove">&#10005;</button>`; chip.innerHTML=`&#128206; ${esc(f.name)} <button title="Remove">&#10005;</button>`;

349
tests/test_sprint21.py Normal file
View File

@@ -0,0 +1,349 @@
"""
Sprint 21 Tests: Send button polish — hidden until content, pop-in animation,
icon-only circle design.
"""
import re
import urllib.request
BASE = "http://127.0.0.1:8788"
def get_text(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return r.read().decode(), r.status
# ── index.html ────────────────────────────────────────────────────────────
def test_send_button_present():
"""btnSend must still exist in the DOM."""
html, status = get_text("/")
assert status == 200
assert 'id="btnSend"' in html
def test_send_button_hidden_by_default():
"""btnSend must start hidden (display:none) — only shown when there is content."""
html, _ = get_text("/")
btn_match = re.search(r'id="btnSend"[^>]*>', html)
assert btn_match, "btnSend element not found"
assert 'display:none' in btn_match.group(0)
def test_send_button_no_text_label():
"""Send button must be icon-only — no visible 'Send' text label."""
html, _ = get_text("/")
# Find the full button element (from opening tag to closing tag)
btn_open_end = html.find('>', html.find('id="btnSend"')) + 1
btn_end = html.find('</button>', btn_open_end) + len('</button>')
btn_inner = html[btn_open_end:btn_end]
# Strip SVG content and any remaining tags; check visible text
no_svg = re.sub(r'<svg[^>]*>.*?</svg>', '', btn_inner, flags=re.DOTALL)
visible_text = re.sub(r'<[^>]+>', '', no_svg).strip()
assert visible_text == '', f"Send button has visible text: {visible_text!r}"
def test_send_button_has_svg_icon():
"""Send button must have an SVG icon."""
html, _ = get_text("/")
btn_start = html.find('id="btnSend"')
btn_end = html.find('</button>', btn_start) + len('</button>')
btn_html = html[btn_start:btn_end]
assert '<svg' in btn_html
def test_send_button_has_title_attribute():
"""btnSend must have a title attribute for accessibility (replaces text label)."""
html, _ = get_text("/")
btn_match = re.search(r'id="btnSend"[^>]*>', html)
assert btn_match
assert 'title=' in btn_match.group(0)
def test_send_button_svg_arrow_up():
"""Send button SVG should use an upward arrow (line + polyline or path)."""
html, _ = get_text("/")
btn_start = html.find('id="btnSend"')
btn_end = html.find('</button>', btn_start) + len('</button>')
btn_html = html[btn_start:btn_end]
# Must have some directional shape element
has_shape = ('<line' in btn_html or '<polyline' in btn_html or
'<polygon' in btn_html or '<path' in btn_html)
assert has_shape, "Send button SVG missing directional shape"
# ── style.css ────────────────────────────────────────────────────────────
def test_send_btn_is_circle():
"""send-btn must use border-radius:50% for the circle shape."""
css, status = get_text("/static/style.css")
assert status == 200
send_idx = css.find('.send-btn{')
brace_open = css.find('{', send_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
assert 'border-radius:50%' in rule or 'border-radius: 50%' in rule
def test_send_btn_fixed_dimensions():
"""send-btn must have explicit width and height (icon-circle, not text-padded)."""
css, _ = get_text("/static/style.css")
send_idx = css.find('.send-btn{')
brace_open = css.find('{', send_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
assert 'width:' in rule or 'width :' in rule
assert 'height:' in rule or 'height :' in rule
def test_send_btn_no_old_padding():
"""send-btn must not use text padding layout (old pill style removed)."""
css, _ = get_text("/static/style.css")
send_idx = css.find('.send-btn{')
brace_open = css.find('{', send_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
# Old style used padding:7px 18px — should be gone
assert 'padding:7px' not in rule and 'padding: 7px' not in rule
def test_send_btn_blue_background():
"""send-btn background must use the blue accent (#7cb9ff or similar)."""
css, _ = get_text("/static/style.css")
send_idx = css.find('.send-btn{')
brace_open = css.find('{', send_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
assert '7cb9ff' in rule or '5ba8f5' in rule or 'var(--blue)' in rule
def test_send_btn_has_transition():
"""send-btn must have transition for smooth hover/active states."""
css, _ = get_text("/static/style.css")
send_idx = css.find('.send-btn{')
brace_open = css.find('{', send_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
assert 'transition' in rule
def test_send_btn_has_box_shadow():
"""send-btn must have a box-shadow glow effect."""
css, _ = get_text("/static/style.css")
send_idx = css.find('.send-btn{')
brace_open = css.find('{', send_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
assert 'box-shadow' in rule
def test_send_btn_hover_has_scale():
"""send-btn:hover must use transform:scale for a satisfying hover effect."""
css, _ = get_text("/static/style.css")
hover_idx = css.find('.send-btn:hover{')
brace_open = css.find('{', hover_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
assert 'scale' in rule
def test_send_btn_active_shrinks():
"""send-btn:active must scale down slightly for tactile press feedback."""
css, _ = get_text("/static/style.css")
active_idx = css.find('.send-btn:active{')
brace_open = css.find('{', active_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
assert 'scale' in rule
def test_send_btn_disabled_rule_exists():
"""send-btn:disabled must still be styled."""
css, _ = get_text("/static/style.css")
assert '.send-btn:disabled' in css
def test_send_btn_visible_class_defined():
""".send-btn.visible class must be defined for the pop-in animation."""
css, _ = get_text("/static/style.css")
assert '.send-btn.visible' in css
def test_send_pop_in_keyframes_defined():
"""@keyframes send-pop-in must be defined."""
css, _ = get_text("/static/style.css")
assert 'send-pop-in' in css
assert '@keyframes' in css
def test_send_pop_in_uses_scale():
"""send-pop-in keyframe must animate from a scaled-down state."""
css, _ = get_text("/static/style.css")
kf_idx = css.find('send-pop-in')
kf_start = css.rfind('@keyframes', 0, kf_idx)
# Find matching closing brace (simple scan)
depth = 0
kf_end = kf_start
for i, ch in enumerate(css[kf_start:], kf_start):
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
kf_end = i
break
kf_rule = css[kf_start:kf_end]
assert 'scale' in kf_rule
def test_send_pop_in_uses_opacity():
"""send-pop-in keyframe must fade in (opacity transition)."""
css, _ = get_text("/static/style.css")
kf_idx = css.find('send-pop-in')
kf_start = css.rfind('@keyframes', 0, kf_idx)
depth = 0
kf_end = kf_start
for i, ch in enumerate(css[kf_start:], kf_start):
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
kf_end = i
break
kf_rule = css[kf_start:kf_end]
assert 'opacity' in kf_rule
def test_send_btn_mobile_override_no_padding():
"""Mobile override for send-btn must not add text padding (keeps circle shape)."""
css, _ = get_text("/static/style.css")
# Find the @media block
media_idx = css.find('@media')
send_mobile_idx = css.find('.send-btn', media_idx)
if send_mobile_idx == -1:
return # No mobile override, fine
brace_open = css.find('{', send_mobile_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
assert 'padding:' not in rule and 'font-size' not in rule
# ── ui.js ─────────────────────────────────────────────────────────────────
def test_ui_js_update_send_btn_function():
"""ui.js must define updateSendBtn() function."""
js, status = get_text("/static/ui.js")
assert status == 200
assert 'function updateSendBtn' in js
def test_update_send_btn_checks_content():
"""updateSendBtn must check textarea value length."""
js, _ = get_text("/static/ui.js")
fn_idx = js.find('function updateSendBtn')
fn_end = js.find('\n}', fn_idx) + 2
fn_body = js[fn_idx:fn_end]
assert 'msg' in fn_body
assert '.value' in fn_body
assert '.length' in fn_body or '.trim()' in fn_body
def test_update_send_btn_checks_pending_files():
"""updateSendBtn must also show send button when files are attached."""
js, _ = get_text("/static/ui.js")
fn_idx = js.find('function updateSendBtn')
fn_end = js.find('\n}', fn_idx) + 2
fn_body = js[fn_idx:fn_end]
assert 'pendingFiles' in fn_body
def test_update_send_btn_uses_visible_class():
"""updateSendBtn must add .visible class to trigger the pop-in animation."""
js, _ = get_text("/static/ui.js")
fn_idx = js.find('function updateSendBtn')
fn_end = js.find('\n}', fn_idx) + 2
fn_body = js[fn_idx:fn_end]
assert 'visible' in fn_body
def test_update_send_btn_uses_display_none():
"""updateSendBtn must hide the button with display:none when no content."""
js, _ = get_text("/static/ui.js")
fn_idx = js.find('function updateSendBtn')
fn_end = js.find('\n}', fn_idx) + 2
fn_body = js[fn_idx:fn_end]
assert 'display' in fn_body
assert 'none' in fn_body
def test_set_busy_calls_update_send_btn():
"""setBusy must call updateSendBtn() so button hides while agent is responding."""
js, _ = get_text("/static/ui.js")
busy_idx = js.find('function setBusy')
busy_end = js.find('\n}', busy_idx) + 2
busy_body = js[busy_idx:busy_end]
assert 'updateSendBtn' in busy_body
def test_render_tray_calls_update_send_btn():
"""renderTray must call updateSendBtn() so button appears when files are attached."""
js, _ = get_text("/static/ui.js")
tray_idx = js.find('function renderTray')
tray_end = js.find('\n}', tray_idx) + 2
tray_body = js[tray_idx:tray_end]
assert 'updateSendBtn' in tray_body
# ── boot.js ──────────────────────────────────────────────────────────────
def test_boot_js_input_calls_update_send_btn():
"""boot.js input event listener must call updateSendBtn()."""
js, status = get_text("/static/boot.js")
assert status == 200
assert 'updateSendBtn' in js
# ── messages.js ───────────────────────────────────────────────────────────
def test_auto_resize_calls_update_send_btn():
"""autoResize() must call updateSendBtn() so button hides after send clears textarea."""
js, status = get_text("/static/messages.js")
assert status == 200
assert 'updateSendBtn' in js
# ── Regression: existing behaviour unchanged ──────────────────────────────
def test_send_button_still_has_send_btn_class():
"""btnSend must still carry class='send-btn' for CSS targeting."""
html, _ = get_text("/")
assert 'class="send-btn"' in html
def test_ui_js_set_busy_still_disables_btn():
"""setBusy must still set btnSend.disabled (not just hide it)."""
js, _ = get_text("/static/ui.js")
busy_idx = js.find('function setBusy')
busy_end = js.find('\n}', busy_idx) + 2
busy_body = js[busy_idx:busy_end]
assert "btnSend" in busy_body
assert 'disabled' in busy_body
def test_index_html_attach_button_unchanged():
"""btnAttach must still be present (no regression)."""
html, _ = get_text("/")
assert 'id="btnAttach"' in html
def test_send_function_still_exists():
"""send() function must still be defined in messages.js."""
js, _ = get_text("/static/messages.js")
assert 'async function send()' in js