smooth flip animation on delete/archive for session stack

This commit is contained in:
dobby-d-elf committed 2026-05-17 12:02:56 -06:00
1 parent 587101fb97
commit 5db7aa43aa
3 files changed
+37 -13

No files matched your search

+25 -9
View File
@@ -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');
+4 -1
View File
@@ -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;}
+8 -3
View File
@@ -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():