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:
@@ -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);
|
||||
|
||||
@@ -231,9 +231,8 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="composer-right">
|
||||
<button class="send-btn" id="btnSend">
|
||||
<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>
|
||||
Send
|
||||
<button class="send-btn" id="btnSend" title="Send message" style="display:none">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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;}
|
||||
|
||||
19
static/ui.js
19
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)} <button title="Remove">✕</button>`;
|
||||
|
||||
349
tests/test_sprint21.py
Normal file
349
tests/test_sprint21.py
Normal 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
|
||||
Reference in New Issue
Block a user