diff --git a/static/sessions.js b/static/sessions.js index 6c5cab5d..710a7e38 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1319,7 +1319,7 @@ function _playQueuedSessionReflowAnimation(){ const before=_pendingSessionReflowPositions; _pendingSessionReflowPositions=null; if(!before||!before.size) return; - const reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const reduce=_sessionPrefersReducedMotion(); if(reduce) return; const list=$('sessionList'); if(!list) return; @@ -1327,14 +1327,30 @@ function _playQueuedSessionReflowAnimation(){ const oldTop=before.get(row.dataset.sid); if(oldTop===undefined) return; const delta=oldTop-row.getBoundingClientRect().top; - if(Math.abs(delta)<1||typeof row.animate!=='function') return; - const anim=row.animate( - [{transform:`translateY(${delta}px)`},{transform:'translateY(0)'}], - {duration:360,easing:'cubic-bezier(.2,.8,.2,1)'} - ); - if(anim&&anim.finished) anim.finished.catch(()=>{}); + if(Math.abs(delta)<1) return; + row.style.setProperty('--session-reflow-offset',delta+'px'); + row.classList.add('session-reflowing'); + row.getBoundingClientRect(); + row.style.setProperty('--session-reflow-offset','0px'); + let reflowCleared=false; + const clearReflow=()=>{ + if(reflowCleared) return; + reflowCleared=true; + row.classList.remove('session-reflowing'); + row.style.removeProperty('--session-reflow-offset'); + row.removeEventListener('transitionend',onReflowEnd); + }; + const onReflowEnd=(event)=>{ + if(event.propertyName==='transform') clearReflow(); + }; + row.addEventListener('transitionend',onReflowEnd); + setTimeout(clearReflow,420); }); } + +function _sessionPrefersReducedMotion(){ + return Boolean(window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches); +} const SESSION_VIRTUAL_ROW_HEIGHT = 52; const SESSION_VIRTUAL_BUFFER_ROWS = 12; const SESSION_VIRTUAL_THRESHOLD_ROWS = 80; @@ -1621,7 +1637,7 @@ function _appendSessionDuplicateAction(menu, session){ function _playSessionActionMenuEntrance(menu){ if(!menu) return; - const reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const reduce=_sessionPrefersReducedMotion(); if(reduce) return; if(typeof menu.animate==='function'){ try{ @@ -3251,7 +3267,7 @@ function renderSessionListFromCache(){ const _longPressDelay=400; const _swipeActionThreshold=144; const _swipeCancelRatio=0.75; - const _committedSwipeDuration=420; + const _committedSwipeDuration=_sessionPrefersReducedMotion()?0:420; const _clearLongPressTimer=()=>{ if(_longPressTimer){clearTimeout(_longPressTimer);_longPressTimer=null;} if(!_longPressMenuOpened) el.classList.remove('long-pressing'); diff --git a/static/style.css b/static/style.css index 73c10f03..bfdc54be 100644 --- a/static/style.css +++ b/static/style.css @@ -429,7 +429,7 @@ 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,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{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)) translateY(var(--session-reflow-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);} @@ -491,6 +491,7 @@ line-height:1; } .session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;} + .session-item.session-reflowing{transition:background .15s,color .15s,transform .36s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;will-change:transform;} .session-item.swipe-committed{transition:background .15s,color .15s,transform .42s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;} .session-item.swipe-removing{ min-height:0!important; @@ -585,6 +586,8 @@ .session-action-opt.danger .ws-opt-icon,.session-action-opt.danger .ws-opt-name{color:var(--error);} @media (prefers-reduced-motion:reduce){ .session-action-menu{animation:none;will-change:auto;} + .session-item,.session-item.session-reflowing,.session-item.swipe-committed,.session-item.swipe-removing{transition:none;} + .session-item.long-pressing{animation:none;} } /* Hide overlay during inline rename */ .session-item:has(.session-title-input) .session-actions{display:none;} diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index 4b88685d..ee436356 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -25,6 +25,8 @@ def test_session_menu_has_subtle_open_animation(): 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 + assert ".session-item,.session-item.session-reflowing,.session-item.swipe-committed,.session-item.swipe-removing{transition:none;}" in STYLE_CSS + assert ".session-item.long-pressing{animation:none;}" in STYLE_CSS def test_mobile_session_menu_opens_from_long_press_and_hides_dots(): @@ -59,7 +61,7 @@ def test_session_swipes_archive_right_and_delete_left(): 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 "_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 "if(_isSessionSwipeTarget()&&(_swipeTracking||dx>dy)) _paintSessionSwipe(signedDx)" in SESSIONS_JS @@ -128,12 +130,15 @@ def test_session_removal_reflows_surviving_rows_smoothly(): assert "function _captureSessionReflowPositions(){" in SESSIONS_JS assert "positions.set(row.dataset.sid,row.getBoundingClientRect().top);" in SESSIONS_JS assert "function _playQueuedSessionReflowAnimation(){" in SESSIONS_JS - assert "window.matchMedia('(prefers-reduced-motion: reduce)').matches" in SESSIONS_JS + assert "function _sessionPrefersReducedMotion(){" in SESSIONS_JS assert "const delta=oldTop-row.getBoundingClientRect().top;" in SESSIONS_JS - assert "{duration:360,easing:'cubic-bezier(.2,.8,.2,1)'}" in SESSIONS_JS + 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 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 def test_ios_touch_events_drive_session_swipes():