diff --git a/static/boot.js b/static/boot.js
index 42de86d..2aa4b09 100644
--- a/static/boot.js
+++ b/static/boot.js
@@ -145,6 +145,7 @@ $('modelSelect').onchange=async()=>{
};
$('msg').addEventListener('input',()=>{
autoResize();
+ updateSendBtn();
const text=$('msg').value;
if(text.startsWith('/')&&text.indexOf('\n')===-1){
const prefix=text.slice(1);
diff --git a/static/index.html b/static/index.html
index 84d04d7..0a6ab94 100644
--- a/static/index.html
+++ b/static/index.html
@@ -231,9 +231,8 @@
diff --git a/static/messages.js b/static/messages.js
index 6e7cbc0..fd37cb8 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -237,7 +237,7 @@ function transcript(){
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 ──
diff --git a/static/style.css b/static/style.css
index 1bd8879..0269f41 100644
--- a/static/style.css
+++ b/static/style.css
@@ -193,10 +193,12 @@
.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;}
.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:hover{background:linear-gradient(135deg,#7cb9ff,#a0d0ff);transform:translateY(-1px);}
- .send-btn:active{transform:translateY(0);}
- .send-btn:disabled{opacity:.4;cursor:not-allowed;}
+ .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:#a0d0ff;transform:scale(1.08);box-shadow:0 4px 14px rgba(124,185,255,.5);}
+ .send-btn:active{transform:scale(0.95);box-shadow:0 1px 4px rgba(124,185,255,.25);}
+ .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.active{display:block;}
.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-box{border-radius:12px;}
.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 h2{font-size:18px;}
.empty-state p{font-size:13px;}
diff --git a/static/ui.js b/static/ui.js
index e71352f..f18c6d2 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -187,9 +187,25 @@ function setStatus(t){
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){
S.busy=v;
$('btnSend').disabled=v;
+ updateSendBtn();
const dots=$('activityDots');
if(dots) dots.style.display=v?'flex':'none';
if(!v){
@@ -920,8 +936,9 @@ async function promptNewFolder(){
function renderTray(){
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');
+ updateSendBtn();
S.pendingFiles.forEach((f,i)=>{
const chip=document.createElement('div');chip.className='attach-chip';
chip.innerHTML=`📎 ${esc(f.name)} `;
diff --git a/tests/test_sprint21.py b/tests/test_sprint21.py
new file mode 100644
index 0000000..5aa70bf
--- /dev/null
+++ b/tests/test_sprint21.py
@@ -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('', btn_open_end) + len('')
+ btn_inner = html[btn_open_end:btn_end]
+ # Strip SVG content and any remaining tags; check visible text
+ no_svg = re.sub(r'', '', 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('', btn_start) + len('')
+ btn_html = html[btn_start:btn_end]
+ assert '