From 4d7fa305fa160a1ecbffae45e4af5f243c5642f6 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Sat, 16 May 2026 13:45:15 -0600 Subject: [PATCH] Polish mobile session swipe feedback --- static/sessions.js | 126 ++++++++++++++++++++++------ static/style.css | 52 +++++++++++- tests/test_session_touch_actions.py | 67 +++++++++++++-- 3 files changed, 210 insertions(+), 35 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 1ad78214..ba496d0a 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1524,7 +1524,7 @@ function closeSessionActionMenu(){ _sessionActionAnchor.classList.remove('active'); } const row=_sessionActionAnchor.closest('.session-item'); - if(row) row.classList.remove('menu-open'); + if(row) row.classList.remove('menu-open','long-pressing'); _sessionActionAnchor = null; } _sessionActionSessionId = null; @@ -1598,7 +1598,7 @@ function _playSessionActionMenuEntrance(menu){ {opacity:0, transform:'translate3d(0,-4px,0) scale(.985)'}, {opacity:1, transform:'translate3d(0,0,0) scale(1)'} ], - {duration:500, easing:'cubic-bezier(.2,.8,.2,1)'} + {duration:450, easing:'cubic-bezier(.2,.8,.2,1)'} ); if(anim&&anim.finished) anim.finished.catch(()=>{}); return; @@ -1608,14 +1608,15 @@ function _playSessionActionMenuEntrance(menu){ } async function _archiveSession(session, archived=true){ - if(_isReadOnlySession(session)){ if(typeof showToast==='function') showToast('Read-only imported sessions cannot be modified.',3000); return; } + if(_isReadOnlySession(session)){ if(typeof showToast==='function') showToast('Read-only imported sessions cannot be modified.',3000); return false; } try{ const response=await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived})}); session.archived=archived; if(S.session&&S.session.session_id===session.session_id) S.session.archived=archived; await renderSessionList(); showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored')); - }catch(err){showToast(t('session_archive_failed')+err.message);} + return true; + }catch(err){showToast(t('session_archive_failed')+err.message);return false;} } function _openSessionActionMenu(session, anchorEl){ @@ -3191,24 +3192,35 @@ function renderSessionListFromCache(){ let _longPressTimer=null; let _longPressMenuOpened=false; let _swipeHandled=false; - const _longPressDelay=560; - const _swipeActionThreshold=96; + let _swipeTracking=false; + let _pointerX=0; + let _pointerY=0; + let _gesturePointerType=''; + const _longPressDelay=400; + const _swipeActionThreshold=144; const _swipeCancelRatio=0.75; + const _committedSwipeDuration=420; const _clearLongPressTimer=()=>{ if(_longPressTimer){clearTimeout(_longPressTimer);_longPressTimer=null;} + if(!_longPressMenuOpened) el.classList.remove('long-pressing'); }; - const _beginSessionTouchGesture=(clientX,clientY)=>{ + const _beginSessionGesture=(clientX,clientY)=>{ _pointerActive=true; + _gesturePointerType=''; _pointerDownX=clientX; _pointerDownY=clientY; + _pointerX=clientX; + _pointerY=clientY; _isDragging=false; + _swipeTracking=false; _longPressMenuOpened=false; _swipeHandled=false; if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;} - el.classList.remove('dragging'); + el.classList.remove('dragging','swipe-committed'); }; const _scheduleSessionLongPressMenu=()=>{ _clearLongPressTimer(); + el.classList.add('long-pressing'); _longPressTimer=setTimeout(()=>{ if(!_pointerActive||_isDragging||_renamingSid||_sessionSelectMode||readOnly) return; _longPressMenuOpened=true; @@ -3219,11 +3231,37 @@ function renderSessionListFromCache(){ },_longPressDelay); }; const _isSessionSwipeTarget=()=>{ - return !readOnly&&!_renamingSid&&!_sessionSelectMode; + return _gesturePointerType!=='mouse'&&!readOnly&&!_renamingSid&&!_sessionSelectMode; + }; + const _trackHorizontalSwipe=(dx,dy)=>{ + if(dx>16&&dx>dy*1.2) _swipeTracking=true; }; const _canSwipeDeleteSession=()=>{ return _isSessionSwipeTarget()&&!_isMessagingSession(s)&&!_isCliSession(s); }; + const _paintSessionSwipe=(signedDx)=>{ + const offset=Math.max(-72,Math.min(72,signedDx*.55)); + const progress=Math.min(1,Math.abs(offset)/72); + el.style.setProperty('--session-swipe-offset',offset+'px'); + el.style.setProperty('--session-swipe-progress',Math.pow(progress,1.5)); + el.classList.toggle('swiping-right',offset>0); + el.classList.toggle('swiping-left',offset<0); + }; + const _clearSessionSwipePaint=()=>{ + el.style.removeProperty('--session-swipe-offset'); + el.style.removeProperty('--session-swipe-progress'); + el.classList.remove('swiping-right','swiping-left','swipe-committed'); + }; + const _settleSessionSwipePaint=()=>{ + el.classList.remove('dragging'); + requestAnimationFrame(()=>requestAnimationFrame(_clearSessionSwipePaint)); + }; + const _completeSessionSwipePaint=(signedDx)=>{ + el.classList.remove('dragging'); + el.classList.add('swipe-committed'); + el.style.setProperty('--session-swipe-progress','0'); + el.style.setProperty('--session-swipe-offset',(signedDx>0?1:-1)*window.innerWidth+'px'); + }; const _handleSessionSwipe=(signedDx,signedDy)=>{ if(_swipeHandled||!_isSessionSwipeTarget()) return false; if(Math.abs(signedDx)<_swipeActionThreshold) return false; @@ -3234,26 +3272,42 @@ function renderSessionListFromCache(){ _tapTimer=null; _lastTapTime=0; if(signedDx>0){ - _archiveSession(s,true); + _completeSessionSwipePaint(signedDx); + setTimeout(async()=>{ + const archived=await _archiveSession(s,!s.archived); + if(!archived) _settleSessionSwipePaint(); + },_committedSwipeDuration); }else if(_canSwipeDeleteSession()){ - deleteSession(s.session_id); + el.classList.remove('dragging'); + deleteSession(s.session_id,async()=>{ + _completeSessionSwipePaint(signedDx); + await new Promise(resolve=>setTimeout(resolve,_committedSwipeDuration)); + }).then((deleted)=>{ + if(!deleted) _settleSessionSwipePaint(); + }); }else if(typeof showToast==='function'){ showToast('Imported sessions cannot be deleted here.',3000); + _swipeHandled=false; + _settleSessionSwipePaint(); } return true; }; + const _commitSessionSwipe=()=>{ + return _handleSessionSwipe(_pointerX-_pointerDownX,_pointerY-_pointerDownY); + }; const _clearPointerDragState=()=>{ _pointerActive=false; _clearLongPressTimer(); if(_isDragging){ _isDragging=false; if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;} - _clearDragTimer=setTimeout(()=>{el.classList.remove('dragging');_clearDragTimer=null;},50); + _clearDragTimer=setTimeout(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50); } }; el.onpointerdown=(e)=>{ if(e.pointerType==='mouse' && e.button!==0) return; - _beginSessionTouchGesture(e.clientX,e.clientY); + _beginSessionGesture(e.clientX,e.clientY); + _gesturePointerType=e.pointerType||''; if(e.pointerType==='touch'||e.pointerType==='pen'){ _scheduleSessionLongPressMenu(); } @@ -3263,6 +3317,8 @@ function renderSessionListFromCache(){ // after an actual press starts on this row; otherwise hovered rows stay // faded until the next sidebar rerender clears their DOM nodes. if(!_pointerActive) return; + _pointerX=e.clientX; + _pointerY=e.clientY; const dx=Math.abs(e.clientX-_pointerDownX); const dy=Math.abs(e.clientY-_pointerDownY); if(!_isDragging&&(dx>5||dy>5)){ @@ -3274,7 +3330,8 @@ function renderSessionListFromCache(){ } const signedDx=e.clientX-_pointerDownX; const signedDy=e.clientY-_pointerDownY; - _handleSessionSwipe(signedDx,signedDy); + _trackHorizontalSwipe(Math.abs(signedDx),Math.abs(signedDy)); + if(_isSessionSwipeTarget()&&(_swipeTracking||Math.abs(signedDx)>Math.abs(signedDy))) _paintSessionSwipe(signedDx); }; el.onpointercancel=_clearPointerDragState; el.onpointerleave=()=>{ if(_pointerActive) _clearPointerDragState(); }; @@ -3284,6 +3341,9 @@ function renderSessionListFromCache(){ _clearLongPressTimer(); if(_renamingSid) return; if(actions&&actions.contains(e.target)) return; + _pointerX=e.clientX; + _pointerY=e.clientY; + _commitSessionSwipe(); if(_longPressMenuOpened||_swipeHandled){e.stopPropagation();return;} if(_sessionActionMenu&&!_sessionActionMenu.contains(e.target)){ closeSessionActionMenu(); @@ -3293,7 +3353,7 @@ function renderSessionListFromCache(){ if(e.target&&e.target.closest&&e.target.closest('.session-child-count,.session-child-sessions,.session-child-session,.session-lineage-count,.session-lineage-segments,.session-lineage-segment')) return; if(_sessionSelectMode){e.stopPropagation();if(!readOnly)toggleSessionSelect(s.session_id);return;} // If the pointer moved enough to be a drag, cancel any pending tap - if(_isDragging){clearTimeout(_tapTimer);_tapTimer=null;_lastTapTime=0;_clearDragTimer=setTimeout(()=>{el.classList.remove('dragging');_clearDragTimer=null;},50);return;} + if(_isDragging){clearTimeout(_tapTimer);_tapTimer=null;_lastTapTime=0;_clearDragTimer=setTimeout(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50);return;} const now=Date.now(); if(now-_lastTapTime<350){ // Double-tap: rename @@ -3321,8 +3381,12 @@ function renderSessionListFromCache(){ await api('/api/session/import_cli',{method:'POST',body:JSON.stringify({session_id:s.session_id})}); }catch(e){ /* import failed -- fall through to read-only view */ } } - await loadSession(s.session_id);renderSessionListFromCache(); - if(typeof closeMobileSidebar==='function')closeMobileSidebar(); + try{ + await loadSession(s.session_id);renderSessionListFromCache(); + if(typeof closeMobileSidebar==='function')closeMobileSidebar(); + }finally{ + el.classList.remove('loading'); + } }, delay); }; // Add ondblclick for more reliable double-click detection @@ -3343,13 +3407,16 @@ function renderSessionListFromCache(){ el.addEventListener('touchstart',(e)=>{ const touch=e.changedTouches&&e.changedTouches[0]; if(!touch) return; - _beginSessionTouchGesture(touch.clientX,touch.clientY); + _beginSessionGesture(touch.clientX,touch.clientY); + _gesturePointerType='touch'; _scheduleSessionLongPressMenu(); },{passive:true}); el.addEventListener('touchmove',(e)=>{ if(!_pointerActive) return; const touch=e.changedTouches&&e.changedTouches[0]; if(!touch) return; + _pointerX=touch.clientX; + _pointerY=touch.clientY; const signedDx=touch.clientX-_pointerDownX; const signedDy=touch.clientY-_pointerDownY; const dx=Math.abs(signedDx); @@ -3360,15 +3427,22 @@ function renderSessionListFromCache(){ el.classList.add('dragging'); if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;} } - if(dx>16&&dx>dy*1.2) e.preventDefault(); - _handleSessionSwipe(signedDx,signedDy); + _trackHorizontalSwipe(dx,dy); + if(_isSessionSwipeTarget()&&(_swipeTracking||dx>dy)) _paintSessionSwipe(signedDx); + if(_swipeTracking) e.preventDefault(); },{passive:false}); - el.addEventListener('touchend',()=>{ + el.addEventListener('touchend',(e)=>{ _pointerActive=false; _clearLongPressTimer(); + const touch=e.changedTouches&&e.changedTouches[0]; + if(touch){ + _pointerX=touch.clientX; + _pointerY=touch.clientY; + } + _commitSessionSwipe(); if(_isDragging){ _isDragging=false; - _clearDragTimer=setTimeout(()=>{el.classList.remove('dragging');_clearDragTimer=null;},50); + if(!_swipeHandled) _clearDragTimer=setTimeout(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50); } },{passive:true}); return el; @@ -3474,19 +3548,20 @@ async function removeWorktree(session){ } } -async function deleteSession(sid){ +async function deleteSession(sid, beforeDelete=null){ const session=_sessionSnapshotById(sid); const ok=await showConfirmDialog({ message:session&&session.worktree_path?t('session_delete_worktree_confirm',session.worktree_path):t('session_delete_confirm'), confirmLabel:t('delete_title'), danger:true }); - if(!ok)return; + if(!ok)return false; + if(beforeDelete) await beforeDelete(); let response=null; try{ response=await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})}); _clearHandoffStorageForSession(sid); - }catch(e){setStatus(`Delete failed: ${e.message}`);return;} + }catch(e){setStatus(`Delete failed: ${e.message}`);return false;} if(S.session&&S.session.session_id===sid){ S.session=null;S.messages=[];S.entries=[]; localStorage.removeItem('hermes-webui-session'); @@ -3506,6 +3581,7 @@ async function deleteSession(sid){ } showToast(_sessionResponseRetainsWorktree(response,session)?t('session_deleted_worktree'):t('session_deleted')); await renderSessionList(); + return true; } // ── Project helpers ───────────────────────────────────────────────────── diff --git a/static/style.css b/static/style.css index 87196b48..5ac6ae79 100644 --- a/static/style.css +++ b/static/style.css @@ -429,18 +429,65 @@ and attention indicator (26x26 at right:6px) still need 40px reserved when they're visible — covered by the hover / streaming / unread / menu-open / focus-within rule below. */ - .session-item{padding:8px 8px;margin-bottom:2px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);transition:background .15s,color .15s;display:flex;align-items:flex-start;gap:8px;min-width:0;position:relative;touch-action:pan-y;-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;} + .session-item{padding:8px 8px;margin-bottom:2px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);transition:background .15s,color .15s,transform .5s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;display:flex;align-items:flex-start;gap:8px;min-width:0;position:relative;touch-action:pan-y;-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;transform:translateX(var(--session-swipe-offset,0));} .session-item.streaming,.session-item.unread,.session-item:focus-within,.session-item.menu-open{padding-right:40px;} @media (hover:hover){.session-item:hover{background:var(--hover-bg);color:var(--text);}} .session-item.loading{background:var(--hover-bg);color:var(--text);} + .session-item.long-pressing{ + background:var(--hover-bg); + color:var(--text); + box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 38%,transparent); + animation:session-long-press .4s cubic-bezier(.2,.8,.2,1) both; + } + .session-item.swiping-right{background:color-mix(in srgb,var(--warning) 18%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--warning) 60%,transparent);} + .session-item.swiping-left{background:color-mix(in srgb,var(--error) 16%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--error) 60%,transparent);} + .session-item.swiping-right::after,.session-item.swiping-left::after{ + position:absolute; + top:0; + bottom:0; + display:flex; + align-items:center; + width:72px; + padding:0 10px; + box-sizing:border-box; + opacity:var(--session-swipe-progress,0); + font-size:10px; + font-weight:700; + letter-spacing:.06em; + text-transform:uppercase; + pointer-events:none; + } + .session-item.swiping-right::after{ + content:"Archive"; + left:0; + justify-content:flex-start; + transform:translateX(calc(-1 * var(--session-swipe-offset,0px))); + color:var(--warning); + background:transparent; + } + .session-item.swiping-left::after{ + content:"Delete"; + right:0; + justify-content:flex-end; + transform:translateX(calc(-1 * var(--session-swipe-offset,0px))); + color:var(--error); + background:transparent; + } + .session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;} + .session-item.swipe-committed{transition:background .15s,color .15s,transform .42s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;} + .session-item.swipe-committed::after{transition:opacity .18s ease;} /* Suppress hover highlight during drag to avoid visual noise mid-scroll */ .session-item.dragging:hover{background:transparent;color:var(--muted);} + .session-item.dragging.swiping-right{background:color-mix(in srgb,var(--warning) 18%,var(--surface));} + .session-item.dragging.swiping-left{background:color-mix(in srgb,var(--error) 16%,var(--surface));} /* Restore hover padding-right only for mouse (hover:hover) devices. Touch/iPad (hover:none) must NOT expand padding-right on :hover — the expansion causes a layout-reflow mid-tap that moves session-actions under the finger, triggering stopPropagation and swallowing navigation. */ @media (hover:hover){:root:not(.dark) .session-item:hover{background:var(--hover-bg);color:var(--text);}.session-item:hover{padding-right:40px;}} .session-item.active{background:var(--accent-bg);color:var(--accent);} + .session-item.active.swiping-right{background:color-mix(in srgb,var(--warning) 22%,var(--accent-bg));} + .session-item.active.swiping-left{background:color-mix(in srgb,var(--error) 20%,var(--accent-bg));} .session-item.streaming .session-title{color:var(--accent);} .session-item.streaming .session-title-row{color:var(--text);} .session-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;overflow:hidden;} @@ -500,7 +547,7 @@ .session-actions-trigger.active{background:var(--accent-bg);border-color:var(--accent-bg-strong);color:var(--text);} .session-actions-trigger svg{display:block;} .session-action-menu{display:block;position:fixed;left:0;top:0;right:auto;bottom:auto;min-width:220px;max-width:min(280px,calc(100vw - 16px));background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:999;overflow:hidden;max-height:calc(100vh - 16px);overflow-y:auto;transform-origin:top right;will-change:opacity,transform;} - .session-action-menu.open-animated{animation:session-menu-in .5s cubic-bezier(.2,.8,.2,1);} + .session-action-menu.open-animated{animation:session-menu-in .45s cubic-bezier(.2,.8,.2,1);} .session-action-opt{width:100%;background:none;border:none;text-align:left;font:inherit;color:var(--text);flex-direction:row!important;gap:0!important;padding:0!important;} .session-action-opt .ws-opt-action{display:flex;flex-direction:row;align-items:center;gap:10px;width:100%;padding:10px 14px;} .session-action-opt .ws-opt-icon{color:var(--muted);transition:color .12s,opacity .12s;flex-shrink:0;display:flex;align-items:center;width:16px;} @@ -516,6 +563,7 @@ /* Hide overlay during inline rename */ .session-item:has(.session-title-input) .session-actions{display:none;} @keyframes session-menu-in{from{opacity:0;transform:translate3d(0,-4px,0) scale(.985);}to{opacity:1;transform:translate3d(0,0,0) scale(1);}} + @keyframes session-long-press{from{filter:brightness(1);}to{filter:brightness(1.08);}} @keyframes newflash{0%{background:var(--accent-bg-strong);color:var(--accent);}100%{background:transparent;color:var(--muted);}} @keyframes spin{to{transform:rotate(360deg);}} .session-item.new-flash{animation:newflash 1.4s ease-out forwards;} diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index c4f917aa..64d96cc7 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -19,20 +19,23 @@ def test_session_menu_has_subtle_open_animation(): assert "function _playSessionActionMenuEntrance(menu){" in SESSIONS_JS assert "typeof menu.animate==='function'" in SESSIONS_JS assert "{opacity:0, transform:'translate3d(0,-4px,0) scale(.985)'}" in SESSIONS_JS - assert "{duration:500, easing:'cubic-bezier(.2,.8,.2,1)'}" in SESSIONS_JS + assert "{duration:450, easing:'cubic-bezier(.2,.8,.2,1)'}" in SESSIONS_JS assert "menu.classList.add('open-animated')" in SESSIONS_JS - assert ".session-action-menu.open-animated{animation:session-menu-in .5s cubic-bezier(.2,.8,.2,1);}" in STYLE_CSS + assert ".session-action-menu.open-animated{animation:session-menu-in .45s cubic-bezier(.2,.8,.2,1);}" in STYLE_CSS assert "@keyframes session-menu-in" in STYLE_CSS assert "@media (prefers-reduced-motion:reduce)" in STYLE_CSS assert ".session-action-menu{animation:none;will-change:auto;}" in STYLE_CSS def test_mobile_session_menu_opens_from_long_press_and_hides_dots(): - assert "_longPressDelay=560" in SESSIONS_JS + assert "_longPressDelay=400" in SESSIONS_JS + assert "el.classList.add('long-pressing')" in SESSIONS_JS + assert "if(!_longPressMenuOpened) el.classList.remove('long-pressing')" in SESSIONS_JS + assert "row.classList.remove('menu-open','long-pressing')" in SESSIONS_JS assert "_openSessionActionMenu(s, el)" in SESSIONS_JS assert "@media (hover:none) and (pointer:coarse)" in STYLE_CSS assert ".session-actions{display:none;}" in STYLE_CSS - assert "const _beginSessionTouchGesture=(clientX,clientY)=>{" in SESSIONS_JS + assert "const _beginSessionGesture=(clientX,clientY)=>{" in SESSIONS_JS assert "const _scheduleSessionLongPressMenu=()=>{" in SESSIONS_JS mobile_touch = STYLE_CSS[STYLE_CSS.find("@media (hover:none) and (pointer:coarse)"):STYLE_CSS.find("@media (max-width: 340px)")] assert ".session-item{padding-right:6px;}" in mobile_touch @@ -52,13 +55,61 @@ def test_open_session_menu_consumes_next_row_activation(): def test_session_swipes_archive_right_and_delete_left(): - assert "_swipeActionThreshold=96" in SESSIONS_JS + assert "_gesturePointerType!=='mouse'" in SESSIONS_JS + assert "_swipeTracking=true" in SESSIONS_JS + assert "const _trackHorizontalSwipe=(dx,dy)=>{" in SESSIONS_JS + assert "_swipeActionThreshold=144" in SESSIONS_JS + assert "_committedSwipeDuration=420" in SESSIONS_JS assert "const _handleSessionSwipe=(signedDx,signedDy)=>{" in SESSIONS_JS + assert "if(_isSessionSwipeTarget()&&(_swipeTracking||Math.abs(signedDx)>Math.abs(signedDy))) _paintSessionSwipe(signedDx)" in SESSIONS_JS + assert "if(_isSessionSwipeTarget()&&(_swipeTracking||dx>dy)) _paintSessionSwipe(signedDx)" in SESSIONS_JS assert "if(signedDx>0){" in SESSIONS_JS - assert "_archiveSession(s,true)" in SESSIONS_JS - assert "deleteSession(s.session_id)" in SESSIONS_JS + assert "_archiveSession(s,!s.archived)" in SESSIONS_JS + assert "deleteSession(s.session_id,async()=>{" in SESSIONS_JS + assert "showToast('Imported sessions cannot be deleted here.',3000);" in SESSIONS_JS + assert "_swipeHandled=false;" in SESSIONS_JS assert "if(!_isDragging&&(dx>5||dy>5))" in SESSIONS_JS - assert "_handleSessionSwipe(signedDx,signedDy)" in SESSIONS_JS + assert "const _commitSessionSwipe=()=>{" in SESSIONS_JS + assert "_commitSessionSwipe();" in SESSIONS_JS + + +def test_session_swipes_show_visual_feedback_and_touch_load_clears(): + assert "const _paintSessionSwipe=(signedDx)=>{" in SESSIONS_JS + assert "el.style.setProperty('--session-swipe-offset',offset+'px')" in SESSIONS_JS + assert "const progress=Math.min(1,Math.abs(offset)/72)" in SESSIONS_JS + assert "el.style.setProperty('--session-swipe-progress',Math.pow(progress,1.5))" in SESSIONS_JS + assert "const _clearSessionSwipePaint=()=>{" in SESSIONS_JS + assert "const _settleSessionSwipePaint=()=>{" in SESSIONS_JS + assert "const _completeSessionSwipePaint=(signedDx)=>{" in SESSIONS_JS + assert "el.classList.add('swipe-committed')" in SESSIONS_JS + assert "el.style.setProperty('--session-swipe-progress','0')" in SESSIONS_JS + assert "deleteSession(s.session_id,async()=>{" in SESSIONS_JS + assert "const archived=await _archiveSession(s,!s.archived);" in SESSIONS_JS + assert "if(!archived) _settleSessionSwipePaint();" in SESSIONS_JS + assert "await new Promise(resolve=>setTimeout(resolve,_committedSwipeDuration));" in SESSIONS_JS + assert "async function deleteSession(sid, beforeDelete=null){" in SESSIONS_JS + assert "requestAnimationFrame(()=>requestAnimationFrame(_clearSessionSwipePaint))" in SESSIONS_JS + assert ".session-item.swiping-right" in STYLE_CSS + assert ".session-item.swiping-left" in STYLE_CSS + assert "opacity:var(--session-swipe-progress,0)" in STYLE_CSS + assert "transform:translateX(calc(-1 * var(--session-swipe-offset,0px)))" in STYLE_CSS + assert STYLE_CSS.count("background:transparent;") >= 3 + assert "transform .5s cubic-bezier(.2,.8,.2,1)" in STYLE_CSS + assert ".session-item.dragging.swiping-right" in STYLE_CSS + assert ".session-item.dragging.swiping-left" in STYLE_CSS + assert ".session-item.active.swiping-right" in STYLE_CSS + assert ".session-item.active.swiping-left" in STYLE_CSS + assert 'content:"Archive"' in STYLE_CSS + assert 'content:"Delete"' in STYLE_CSS + assert ".session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;}" in STYLE_CSS + assert ".session-item.swipe-committed" in STYLE_CSS + assert "transform .42s cubic-bezier(.2,.8,.2,1)" in STYLE_CSS + assert ".session-item.swipe-committed::after{transition:opacity .18s ease;}" in STYLE_CSS + assert ".session-item.long-pressing" in STYLE_CSS + assert "@keyframes session-long-press" in STYLE_CSS + assert "transform:translateX(var(--session-swipe-offset,0))" in STYLE_CSS + assert "finally{" in SESSIONS_JS + assert "el.classList.remove('loading');" in SESSIONS_JS def test_ios_touch_events_drive_session_swipes():