diff --git a/static/sessions.js b/static/sessions.js index 3b13b195..836f2c8c 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3615,6 +3615,9 @@ function renderSessionListFromCache(){ menuBtn.setAttribute('aria-haspopup','menu'); menuBtn.setAttribute('aria-label','Conversation actions'); menuBtn.innerHTML=ICONS.more; + const stopMenuPointer=(e)=>e.stopPropagation(); + menuBtn.onpointerdown=stopMenuPointer; + menuBtn.onpointerup=stopMenuPointer; menuBtn.onclick=(e)=>{ e.stopPropagation(); e.preventDefault(); @@ -3637,6 +3640,7 @@ function renderSessionListFromCache(){ const _makeSessionSwipeAffordance=(side,icon,label)=>{ const affordance=document.createElement('div'); affordance.className='session-swipe-affordance session-swipe-affordance-'+side; + affordance.setAttribute('aria-hidden','true'); const badge=document.createElement('span'); badge.className='session-swipe-badge'; badge.innerHTML=li(icon,18); @@ -3653,10 +3657,10 @@ function renderSessionListFromCache(){ ); } - // Use pointerup + manual double-tap detection instead of onclick/ondblclick. + // Use release events + manual double-tap detection instead of onclick/ondblclick. // onclick/ondblclick are unreliable on touch devices (iPad Safari especially): // hover-triggered layout shifts, ghost clicks, and 300ms delay all break - // single-tap navigation. pointerup fires immediately on both mouse & touch. + // single-tap navigation. // Mouse clicks are instant; touch presses need a 300ms delay to distinguish // a tap from a scroll-drag gesture on mobile. // Movement promotes pressing into dragging; drag release cancels a pending tap. @@ -3673,15 +3677,16 @@ function renderSessionListFromCache(){ let _pointerY=0; let _gesturePointerType=''; const _longPressDelay=400; - const _swipeActionThreshold=144; + const _archiveSwipeActionThreshold=128; + const _deleteSwipeActionThreshold=128; const _swipeCancelRatio=0.75; const _committedSwipeDuration=_sessionPrefersReducedMotion()?0:420; const _clearLongPressTimer=()=>{ if(_longPressTimer){clearTimeout(_longPressTimer);_longPressTimer=null;} if(!_longPressMenuOpened) el.classList.remove('long-pressing'); }; - const _beginSessionGesture=(clientX,clientY)=>{ - _gesturePointerType=''; + const _beginSessionGesture=(clientX,clientY,pointerType='')=>{ + _gesturePointerType=pointerType; _pointerDownX=clientX; _pointerDownY=clientY; _pointerX=clientX; @@ -3690,7 +3695,9 @@ function renderSessionListFromCache(){ _swipeTracking=false; _longPressMenuOpened=false; if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;} - el.classList.remove('dragging','swipe-committed'); + el.classList.remove('dragging','swipe-committed','swipe-removing'); + el.style.removeProperty('height'); + el.style.removeProperty('min-height'); }; const _scheduleSessionLongPressMenu=()=>{ _clearLongPressTimer(); @@ -3707,8 +3714,11 @@ function renderSessionListFromCache(){ const _isSessionSwipeTarget=()=>{ return _gesturePointerType!=='mouse'&&!readOnly&&!_renamingSid&&!_sessionSelectMode; }; + const _isSessionActionTarget=(target)=>{ + return !!(actions&&target&&actions.contains(target)); + }; const _trackHorizontalSwipe=(dx,dy)=>{ - if(dx>16&&dx>dy*1.2) _swipeTracking=true; + if(dx>8&&dx>dy*1.1) _swipeTracking=true; }; const _promoteSessionDrag=(dx,dy)=>{ if(_gestureState!=='pressing'||(dx<=5&&dy<=5)) return; @@ -3717,6 +3727,19 @@ function renderSessionListFromCache(){ el.classList.add('dragging'); if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;} }; + const _updateSessionGesture=(clientX,clientY)=>{ + if(_gestureState==='idle') return false; + _pointerX=clientX; + _pointerY=clientY; + const signedDx=clientX-_pointerDownX; + const signedDy=clientY-_pointerDownY; + const dx=Math.abs(signedDx); + const dy=Math.abs(signedDy); + _promoteSessionDrag(dx,dy); + _trackHorizontalSwipe(dx,dy); + if(_isSessionSwipeTarget()&&(_swipeTracking||dx>dy)) _paintSessionSwipe(signedDx); + return _swipeTracking; + }; const _canSwipeDeleteSession=()=>{ return _isSessionSwipeTarget()&&!_isMessagingSession(s)&&!_isCliSession(s); }; @@ -3726,15 +3749,23 @@ function renderSessionListFromCache(){ const overshoot=Math.max(0,Math.abs(rawOffset)-72); const offset=Math.sign(rawOffset)*(Math.abs(revealedOffset)+Math.sqrt(overshoot)*5); const progress=Math.min(1,Math.abs(revealedOffset)/72); + const reveal=Math.min(132,Math.max(36,Math.abs(rawOffset)+24)); + const iconScale=1+Math.min(.45,Math.max(0,Math.abs(rawOffset)-52)/130); el.style.setProperty('--session-swipe-offset',offset+'px'); + el.style.setProperty('--session-swipe-reveal',reveal+'px'); + el.style.setProperty('--session-swipe-icon-scale',iconScale); 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-reveal'); + el.style.removeProperty('--session-swipe-icon-scale'); el.style.removeProperty('--session-swipe-progress'); - el.classList.remove('swiping-right','swiping-left','swipe-committed'); + el.style.removeProperty('height'); + el.style.removeProperty('min-height'); + el.classList.remove('swiping-right','swiping-left','swipe-committed','swipe-removing'); }; const _settleSessionSwipePaint=()=>{ el.classList.remove('dragging'); @@ -3752,7 +3783,8 @@ function renderSessionListFromCache(){ }; const _handleSessionSwipe=(signedDx,signedDy)=>{ if(_gestureState==='committed'||!_isSessionSwipeTarget()) return false; - if(Math.abs(signedDx)<_swipeActionThreshold) return false; + const actionThreshold=signedDx>0?_archiveSwipeActionThreshold:_deleteSwipeActionThreshold; + if(Math.abs(signedDx)Math.abs(signedDx)*_swipeCancelRatio) return false; _gestureState='committed'; _clearLongPressTimer(); @@ -3766,10 +3798,11 @@ function renderSessionListFromCache(){ if(!archived) _settleSessionSwipePaint(); },_committedSwipeDuration); }else if(_canSwipeDeleteSession()){ - el.classList.remove('dragging'); + _completeSessionSwipePaint(signedDx); + const completedAt=Date.now(); deleteSession(s.session_id,async()=>{ - _completeSessionSwipePaint(signedDx); - await new Promise(resolve=>setTimeout(resolve,_committedSwipeDuration)); + const remaining=_committedSwipeDuration-(Date.now()-completedAt); + if(remaining>0) await new Promise(resolve=>setTimeout(resolve,remaining)); }).then((deleted)=>{ if(!deleted) _settleSessionSwipePaint(); }); @@ -3784,7 +3817,11 @@ function renderSessionListFromCache(){ return _handleSessionSwipe(_pointerX-_pointerDownX,_pointerY-_pointerDownY); }; const _clearPointerDragState=()=>{ - const wasDragging=_gestureState==='dragging'; + if(_gestureState==='committed'){ + _clearLongPressTimer(); + return; + } + const wasDragging=_gestureState==='dragging'||_swipeTracking; _gestureState='idle'; _clearLongPressTimer(); if(wasDragging){ @@ -3792,75 +3829,42 @@ function renderSessionListFromCache(){ _clearDragTimer=setTimeout(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50); } }; - el.onpointerdown=(e)=>{ - if(e.pointerType==='mouse' && e.button!==0) return; - _beginSessionGesture(e.clientX,e.clientY); - _gesturePointerType=e.pointerType||''; - if(e.pointerType==='touch'||e.pointerType==='pen'){ - _scheduleSessionLongPressMenu(); - } - }; - el.onpointermove=(e)=>{ - // Plain hover also dispatches pointermove. Only mark a row as dragging - // after an actual press starts on this row; otherwise hovered rows stay - // faded until the next sidebar rerender clears their DOM nodes. - if(_gestureState==='idle') return; - _pointerX=e.clientX; - _pointerY=e.clientY; - const dx=Math.abs(e.clientX-_pointerDownX); - const dy=Math.abs(e.clientY-_pointerDownY); - _promoteSessionDrag(dx,dy); - const signedDx=e.clientX-_pointerDownX; - const signedDy=e.clientY-_pointerDownY; - _trackHorizontalSwipe(Math.abs(signedDx),Math.abs(signedDy)); - if(_isSessionSwipeTarget()&&(_swipeTracking||Math.abs(signedDx)>Math.abs(signedDy))) _paintSessionSwipe(signedDx); - }; - el.onpointercancel=_clearPointerDragState; - el.onpointerleave=()=>{ - if(_gesturePointerType==='mouse'&&_gestureState!=='idle') _clearPointerDragState(); - }; - el.onpointerup=(e)=>{ - if(e.pointerType==='mouse' && e.button!==0) return; // ignore right/middle click - const wasDragging=_gestureState==='dragging'; + const _finishSessionGesture=(clientX,clientY,target,pointerType)=>{ + const wasDragging=_gestureState==='dragging'||_swipeTracking; _clearLongPressTimer(); - if(_renamingSid) return; - if(actions&&actions.contains(e.target)) return; - _pointerX=e.clientX; - _pointerY=e.clientY; + if(_renamingSid){_gestureState='idle';return false;} + if(_isSessionActionTarget(target)){_gestureState='idle';return false;} + _pointerX=clientX; + _pointerY=clientY; _commitSessionSwipe(); - if(_longPressMenuOpened||_gestureState==='committed'){e.stopPropagation();return;} - if(_sessionActionMenu&&!_sessionActionMenu.contains(e.target)){ + if(_longPressMenuOpened){_gestureState='idle';return true;} + if(_gestureState==='committed') return true; + if(_sessionActionMenu&&!_sessionActionMenu.contains(target)){ closeSessionActionMenu(); - e.stopPropagation(); - return; + return true; } - 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(target&&target.closest&&target.closest('.session-child-count,.session-child-sessions,.session-child-session,.session-lineage-count,.session-lineage-segments,.session-lineage-segment')) return false; + if(_sessionSelectMode){if(!readOnly)toggleSessionSelect(s.session_id);return true;} if(wasDragging){ clearTimeout(_tapTimer);_tapTimer=null;_lastTapTime=0; _gestureState='idle'; _clearDragTimer=setTimeout(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50); - return; + return false; } _gestureState='idle'; const now=Date.now(); if(now-_lastTapTime<350){ - // Double-tap: rename clearTimeout(_tapTimer); _tapTimer=null; _lastTapTime=0; el.classList.remove('loading'); startRename(); - return; + return false; } _lastTapTime=now; - // Single tap: wait to ensure it's not the first of a double-tap, - // then navigate. Mouse is instant; touch needs delay to suppress - // accidental navigation during scroll-drag lifts. clearTimeout(_tapTimer); - const delay=e.pointerType==='mouse'?0:300; - if(e.pointerType!=='mouse') el.classList.add('loading'); + const delay=pointerType==='mouse'?0:300; + if(pointerType!=='mouse') el.classList.add('loading'); _tapTimer=setTimeout(async()=>{ _tapTimer=null; _lastTapTime=0; @@ -3878,6 +3882,32 @@ function renderSessionListFromCache(){ el.classList.remove('loading'); } }, delay); + return false; + }; + el.onpointerdown=(e)=>{ + if(e.pointerType==='touch') return; + if(e.pointerType==='mouse' && e.button!==0) return; + if(_isSessionActionTarget(e.target)) return; + _beginSessionGesture(e.clientX,e.clientY,e.pointerType||''); + if(e.pointerType==='pen'){ + _scheduleSessionLongPressMenu(); + } + }; + el.onpointermove=(e)=>{ + if(e.pointerType==='touch') return; + // Plain hover also dispatches pointermove. Only mark a row as dragging + // after an actual press starts on this row; otherwise hovered rows stay + // faded until the next sidebar rerender clears their DOM nodes. + _updateSessionGesture(e.clientX,e.clientY); + }; + el.onpointercancel=_clearPointerDragState; + el.onpointerleave=()=>{ + if(_gestureState!=='idle') _clearPointerDragState(); + }; + el.onpointerup=(e)=>{ + if(e.pointerType==='touch') return; + if(e.pointerType==='mouse' && e.button!==0) return; // ignore right/middle click + if(_finishSessionGesture(e.clientX,e.clientY,e.target,e.pointerType)) e.stopPropagation(); }; // Add ondblclick for more reliable double-click detection el.ondblclick=(e)=>{ @@ -3895,40 +3925,22 @@ function renderSessionListFromCache(){ } }; el.addEventListener('touchstart',(e)=>{ + if(_isSessionActionTarget(e.target)) return; const touch=e.changedTouches&&e.changedTouches[0]; if(!touch) return; - _beginSessionGesture(touch.clientX,touch.clientY); - _gesturePointerType='touch'; + _beginSessionGesture(touch.clientX,touch.clientY,'touch'); _scheduleSessionLongPressMenu(); },{passive:true}); el.addEventListener('touchmove',(e)=>{ - if(_gestureState==='idle') 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); - const dy=Math.abs(signedDy); - _promoteSessionDrag(dx,dy); - _trackHorizontalSwipe(dx,dy); - if(_isSessionSwipeTarget()&&(_swipeTracking||dx>dy)) _paintSessionSwipe(signedDx); - if(_swipeTracking) e.preventDefault(); + if(_updateSessionGesture(touch.clientX,touch.clientY)) e.preventDefault(); },{passive:false}); + el.addEventListener('touchcancel',_clearPointerDragState,{passive:true}); el.addEventListener('touchend',(e)=>{ - const wasDragging=_gestureState==='dragging'; - _clearLongPressTimer(); const touch=e.changedTouches&&e.changedTouches[0]; - if(touch){ - _pointerX=touch.clientX; - _pointerY=touch.clientY; - } - _commitSessionSwipe(); - if(wasDragging){ - if(_gestureState!=='committed') _clearDragTimer=setTimeout(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50); - } - if(_gestureState!=='committed') _gestureState='idle'; + if(!touch) return; + if(_finishSessionGesture(touch.clientX,touch.clientY,e.target,'touch')) e.stopPropagation(); },{passive:true}); return el; } diff --git a/static/style.css b/static/style.css index daf86032..8bbd8b6d 100644 --- a/static/style.css +++ b/static/style.css @@ -664,8 +664,8 @@ 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{background:color-mix(in srgb,var(--warning) 16%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--warning) 48%,transparent);} + .session-item.swiping-left{background:color-mix(in srgb,var(--error) 14%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--error) 48%,transparent);} .session-swipe-affordance{ position:absolute; top:0; @@ -675,37 +675,47 @@ align-items:center; justify-content:center; gap:3px; - width:72px; - padding:0 10px; + width:var(--session-swipe-reveal,0px); + min-width:36px; + padding:0 8px; box-sizing:border-box; opacity:0; + overflow:hidden; transform:translateX(calc(-1 * var(--session-swipe-offset,0px))) scale(calc(.82 + var(--session-swipe-progress,0) * .18)); - transform-origin:center; pointer-events:none; + z-index:0; } .session-swipe-affordance-right{ left:0; - color:var(--warning); + color:#fff; + background:var(--warning); + border-radius:8px 6px 6px 8px; + transform-origin:left center; } .session-swipe-affordance-left{ right:0; - color:var(--error); + color:#fff; + background:var(--error); + border-radius:6px 8px 8px 6px; + transform-origin:right center; } .session-item.swiping-right .session-swipe-affordance-right, .session-item.swiping-left .session-swipe-affordance-left{ opacity:var(--session-swipe-progress,0); } + .session-text,.session-state-indicator,.session-actions{z-index:1;} .session-swipe-badge{ - width:30px; - height:30px; - border-radius:999px; + width:28px; + height:28px; + border-radius:0; display:flex; align-items:center; justify-content:center; color:#fff; + transform:scaleX(var(--session-swipe-icon-scale,1)); + transform-origin:center; } - .session-swipe-affordance-right .session-swipe-badge{background:var(--warning);} - .session-swipe-affordance-left .session-swipe-badge{background:var(--error);} + .session-swipe-badge svg{display:block;stroke-width:2.2;} .session-swipe-label{ max-width:58px; overflow:hidden; @@ -730,16 +740,16 @@ .session-item.swipe-committed .session-swipe-affordance{transition:opacity .18s ease,transform .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));} + .session-item.dragging.swiping-right{background:color-mix(in srgb,var(--warning) 16%,var(--surface));} + .session-item.dragging.swiping-left{background:color-mix(in srgb,var(--error) 14%,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.active.swiping-right{background:color-mix(in srgb,var(--warning) 20%,var(--accent-bg));} + .session-item.active.swiping-left{background:color-mix(in srgb,var(--error) 18%,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;} diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index 8e373fd5..72cbd484 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -37,7 +37,7 @@ def test_mobile_session_menu_opens_from_long_press_and_hides_dots(): 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 _beginSessionGesture=(clientX,clientY)=>{" in SESSIONS_JS + assert "const _beginSessionGesture=(clientX,clientY,pointerType='')=>{" 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 @@ -46,35 +46,59 @@ def test_mobile_session_menu_opens_from_long_press_and_hides_dots(): def test_open_session_menu_consumes_next_row_activation(): - assert "if(_sessionActionMenu&&!_sessionActionMenu.contains(e.target)){" in SESSIONS_JS + assert "if(_sessionActionMenu&&!_sessionActionMenu.contains(target)){" in SESSIONS_JS assert "closeSessionActionMenu();" in SESSIONS_JS assert "e.stopPropagation();" in SESSIONS_JS + assert "const stopMenuPointer=(e)=>e.stopPropagation();" in SESSIONS_JS + assert "menuBtn.onpointerdown=stopMenuPointer;" in SESSIONS_JS + assert "menuBtn.onpointerup=stopMenuPointer;" in SESSIONS_JS + menu_btn_idx = SESSIONS_JS.find("menuBtn.onpointerdown=stopMenuPointer;") + menu_click_idx = SESSIONS_JS.find("menuBtn.onclick=(e)=>{", menu_btn_idx) + assert menu_btn_idx > 0 and menu_click_idx > menu_btn_idx + assert "const _isSessionActionTarget=(target)=>{" in SESSIONS_JS + assert "return !!(actions&&target&&actions.contains(target));" in SESSIONS_JS + assert "if(_isSessionActionTarget(e.target)) return;" in SESSIONS_JS + assert "if(_isSessionActionTarget(target)){_gestureState='idle';return false;}" in SESSIONS_JS + assert "if(_longPressMenuOpened){_gestureState='idle';return true;}" in SESSIONS_JS + finish_idx = SESSIONS_JS.find("const _finishSessionGesture=(clientX,clientY,target,pointerType)=>{") + dismiss_idx = SESSIONS_JS.find("if(_sessionActionMenu&&!_sessionActionMenu.contains(target)){", finish_idx) + load_idx = SESSIONS_JS.find("await loadSession(s.session_id)", finish_idx) pointerup_idx = SESSIONS_JS.find("el.onpointerup=(e)=>{") - dismiss_idx = SESSIONS_JS.find("if(_sessionActionMenu&&!_sessionActionMenu.contains(e.target)){", pointerup_idx) - load_idx = SESSIONS_JS.find("await loadSession(s.session_id)", pointerup_idx) - assert pointerup_idx > 0 and load_idx > pointerup_idx - assert dismiss_idx > pointerup_idx and dismiss_idx < load_idx + assert finish_idx > 0 and load_idx > finish_idx + assert dismiss_idx > finish_idx and dismiss_idx < load_idx + assert "if(_finishSessionGesture(e.clientX,e.clientY,e.target,e.pointerType)) e.stopPropagation();" in SESSIONS_JS[pointerup_idx:] def test_session_swipes_archive_right_and_delete_left(): 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 "_archiveSwipeActionThreshold=128" in SESSIONS_JS + assert "_deleteSwipeActionThreshold=128" in SESSIONS_JS assert "_committedSwipeDuration=_sessionPrefersReducedMotion()?0: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 "const actionThreshold=signedDx>0?_archiveSwipeActionThreshold:_deleteSwipeActionThreshold;" in SESSIONS_JS + assert "if(Math.abs(signedDx){" in SESSIONS_JS assert "if(_isSessionSwipeTarget()&&(_swipeTracking||dx>dy)) _paintSessionSwipe(signedDx)" in SESSIONS_JS + assert "_updateSessionGesture(e.clientX,e.clientY);" in SESSIONS_JS + assert "if(_updateSessionGesture(touch.clientX,touch.clientY)) e.preventDefault();" in SESSIONS_JS + assert "_beginSessionGesture(touch.clientX,touch.clientY,'touch');" in SESSIONS_JS assert "if(signedDx>0){" in SESSIONS_JS assert "_archiveSession(s,!s.archived)" in SESSIONS_JS + assert "const completedAt=Date.now();" in SESSIONS_JS + assert "const remaining=_committedSwipeDuration-(Date.now()-completedAt);" 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 "let _gestureState='idle';" in SESSIONS_JS assert "_gestureState='dragging';" in SESSIONS_JS assert "const _promoteSessionDrag=(dx,dy)=>{" in SESSIONS_JS - assert "if(_gesturePointerType==='mouse'&&_gestureState!=='idle') _clearPointerDragState();" in SESSIONS_JS assert "const _commitSessionSwipe=()=>{" in SESSIONS_JS assert "_commitSessionSwipe();" in SESSIONS_JS + assert "const wasDragging=_gestureState==='dragging'||_swipeTracking;" in SESSIONS_JS + assert "if(_gestureState==='committed'){" in SESSIONS_JS + assert SESSIONS_JS.count("if(e.pointerType==='touch') return;") >= 3 + assert "el.onpointercancel=_clearPointerDragState;" in SESSIONS_JS def test_session_swipes_show_visual_feedback_and_touch_load_clears(): @@ -84,11 +108,21 @@ def test_session_swipes_show_visual_feedback_and_touch_load_clears(): assert "const overshoot=Math.max(0,Math.abs(rawOffset)-72)" in SESSIONS_JS assert "Math.sqrt(overshoot)*5" in SESSIONS_JS assert "el.style.setProperty('--session-swipe-offset',offset+'px')" in SESSIONS_JS + assert "const reveal=Math.min(132,Math.max(36,Math.abs(rawOffset)+24));" in SESSIONS_JS + assert "const iconScale=1+Math.min(.45,Math.max(0,Math.abs(rawOffset)-52)/130);" in SESSIONS_JS + assert "el.style.setProperty('--session-swipe-reveal',reveal+'px')" in SESSIONS_JS + assert "el.style.setProperty('--session-swipe-icon-scale',iconScale)" in SESSIONS_JS assert "const progress=Math.min(1,Math.abs(revealedOffset)/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 "el.style.removeProperty('--session-swipe-reveal');" in SESSIONS_JS + assert "el.style.removeProperty('--session-swipe-icon-scale');" in SESSIONS_JS + assert "el.style.removeProperty('height');" in SESSIONS_JS + assert "el.style.removeProperty('min-height');" in SESSIONS_JS + assert "el.classList.remove('swiping-right','swiping-left','swipe-committed','swipe-removing')" in SESSIONS_JS assert "const _settleSessionSwipePaint=()=>{" in SESSIONS_JS assert "const _completeSessionSwipePaint=(signedDx)=>{" in SESSIONS_JS + assert "el.classList.remove('dragging');" in SESSIONS_JS assert "el.classList.add('swipe-committed')" in SESSIONS_JS assert "el.style.height=rect.height+'px'" in SESSIONS_JS assert "requestAnimationFrame(()=>el.classList.add('swipe-removing'))" in SESSIONS_JS @@ -96,26 +130,32 @@ def test_session_swipes_show_visual_feedback_and_touch_load_clears(): 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 "if(remaining>0) await new Promise(resolve=>setTimeout(resolve,remaining));" in SESSIONS_JS assert "async function deleteSession(sid, beforeDelete=null){" in SESSIONS_JS + assert "if(beforeDelete) await beforeDelete();" 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 "const _makeSessionSwipeAffordance=(side,icon,label)=>{" in SESSIONS_JS + assert "affordance.setAttribute('aria-hidden','true');" in SESSIONS_JS assert "_makeSessionSwipeAffordance('right',s.archived?'undo':'archive'" in SESSIONS_JS assert "_makeSessionSwipeAffordance('left','trash-2'" in SESSIONS_JS assert ".session-swipe-affordance{" in STYLE_CSS assert "opacity:var(--session-swipe-progress,0)" in STYLE_CSS + assert "width:var(--session-swipe-reveal,0px)" in STYLE_CSS + assert ".session-item.swiping-right{background:color-mix(in srgb,var(--warning) 16%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--warning) 48%,transparent);}" in STYLE_CSS + assert ".session-item.swiping-left{background:color-mix(in srgb,var(--error) 14%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--error) 48%,transparent);}" in STYLE_CSS + assert "background:var(--warning)" in STYLE_CSS + assert "background:var(--error)" in STYLE_CSS assert ".session-item.swiping-right .session-swipe-affordance-right" in STYLE_CSS assert ".session-item.swiping-left .session-swipe-affordance-left" in STYLE_CSS assert "transform:translateX(calc(-1 * var(--session-swipe-offset,0px))) scale(calc(.82 + var(--session-swipe-progress,0) * .18))" in STYLE_CSS assert ".session-swipe-badge{" in STYLE_CSS + assert "transform:scaleX(var(--session-swipe-icon-scale,1))" in STYLE_CSS assert ".session-swipe-label{" in STYLE_CSS 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 ".session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;}" in STYLE_CSS assert ".session-item.swipe-committed" in STYLE_CSS assert ".session-item.swipe-removing{" in STYLE_CSS @@ -139,7 +179,7 @@ def test_session_removal_reflows_surviving_rows_smoothly(): assert "row.style.setProperty('--session-reflow-offset',delta+'px')" in SESSIONS_JS assert "row.classList.add('session-reflowing')" in SESSIONS_JS assert "row.style.setProperty('--session-reflow-offset','0px')" in SESSIONS_JS - assert SESSIONS_JS.count("const reflowPositions=_captureSessionReflowPositions();") >= 2 + assert "const reflowPositions=_captureSessionReflowPositions();" in SESSIONS_JS assert SESSIONS_JS.count("_pendingSessionReflowPositions=reflowPositions;") >= 2 assert "_playQueuedSessionReflowAnimation();" in SESSIONS_JS assert ".session-item.session-reflowing{transition:background .15s,color .15s,transform .36s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;will-change:transform;}" in STYLE_CSS @@ -148,9 +188,14 @@ def test_session_removal_reflows_surviving_rows_smoothly(): def test_ios_touch_events_drive_session_swipes(): assert "el.addEventListener('touchstart'" in SESSIONS_JS assert "el.addEventListener('touchmove'" in SESSIONS_JS + assert "el.addEventListener('touchcancel',_clearPointerDragState" in SESSIONS_JS assert "el.addEventListener('touchend'" in SESSIONS_JS + assert "const _finishSessionGesture=(clientX,clientY,target,pointerType)=>{" in SESSIONS_JS assert "{passive:false}" in SESSIONS_JS - assert "e.preventDefault()" in SESSIONS_JS + assert "if(_updateSessionGesture(touch.clientX,touch.clientY)) e.preventDefault();" in SESSIONS_JS + assert SESSIONS_JS.count("if(e.pointerType==='touch') return;") >= 3 + assert "if(_finishSessionGesture(touch.clientX,touch.clientY,e.target,'touch')) e.stopPropagation();" in SESSIONS_JS + assert "window.PointerEvent" not in SESSIONS_JS def test_touch_session_rows_preserve_vertical_scroll():