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 ']*>', 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('', btn_start) + len('') + btn_html = html[btn_start:btn_end] + # Must have some directional shape element + has_shape = ('