From c4fffff2057d9c6cb5d2ef74a4d630412d09dc74 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Thu, 21 May 2026 07:52:42 -0600 Subject: [PATCH] refine archive/restore animations --- static/sessions.js | 21 +++++++++++++++++++-- static/style.css | 10 ++++++---- tests/test_session_touch_actions.py | 23 ++++++++++++++++++----- 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 435e481a..decb50ec 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -107,8 +107,8 @@ let _sessionListLastScrollAt = 0; let _pendingSessionListPayload = null; let _pendingSessionListApplyTimer = 0; const SESSION_LIST_INTERACTION_IDLE_MS = 700; -const SESSION_SWIPE_DURATION_MS = 420; -const SESSION_SWIPE_REFLOW_LEAD_MS = 180; +const SESSION_SWIPE_DURATION_MS = 500; +const SESSION_SWIPE_REFLOW_LEAD_MS = 220; const SESSION_REFLOW_TIMEOUT_MS = 420; const SESSION_LIST_FLIP_TIMEOUT_MS = 460; @@ -1459,6 +1459,7 @@ let _lineageReportCacheGeneration = 0; let _sessionVisibleSidebarIds = []; let _pendingSessionReflowPositions = null; const _optimisticallyRemovedSessionIds = new Set(); +const _sessionSwipeReturnOffsets = new Map(); function _captureSessionReflowPositions(){ const list=$('sessionList'); @@ -1834,6 +1835,7 @@ async function _archiveSession(session, archived=true, beforeListRender=null){ session.archived=archived; if(S.session&&S.session.session_id===session.session_id) S.session.archived=archived; if(renderHold) await renderHold; + if(_showArchived&&!_sessionPrefersReducedMotion()) _sessionSwipeReturnOffsets.set(session.session_id,'0px'); _pendingSessionReflowPositions=reflowPositions; await renderSessionList(); showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored')); @@ -3272,6 +3274,16 @@ function renderSessionListFromCache(){ const hasUnread=_hasUnreadForSession(s)&&!isActive; const readOnly=_isReadOnlySession(s); el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'')+(isStreaming?' streaming':'')+(hasUnread?' unread':''); + const swipeReturnOffset=_sessionSwipeReturnOffsets.get(s.session_id); + if(swipeReturnOffset!==undefined){ + _sessionSwipeReturnOffsets.delete(s.session_id); + el.style.setProperty('--session-swipe-return-offset',swipeReturnOffset); + el.classList.add('session-swipe-returning'); + el.addEventListener('animationend',()=>{ + el.classList.remove('session-swipe-returning'); + el.style.removeProperty('--session-swipe-return-offset'); + },{once:true}); + } if(animateRefresh&&(enterAllAnimatedRows||!(flipBefore&&flipBefore.has(s.session_id)))){ el.classList.add('session-list-flip-enter'); } @@ -3771,6 +3783,11 @@ function renderSessionListFromCache(){ _archiveSession(s,false,()=>_waitForSessionMotion(committedSwipeDuration)).then((restored)=>{ if(!restored) _settleSessionSwipePaint(); }); + }else if(_showArchived){ + _settleSessionSwipePaint(); + _archiveSession(s,true,()=>_waitForSessionMotion(committedSwipeDuration)).then((archived)=>{ + if(!archived) _settleSessionSwipePaint(); + }); }else{ _completeSessionSwipePaint(signedDx); _archiveSession(s,true,()=>_waitForSessionMotion(committedSwipeReflowDelay)).then((archived)=>{ diff --git a/static/style.css b/static/style.css index efe39142..b3a3fd0c 100644 --- a/static/style.css +++ b/static/style.css @@ -654,7 +654,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)) translateY(var(--session-reflow-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(.22,.61,.36,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:translate3d(var(--session-swipe-offset,0),var(--session-reflow-offset,0),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);} @@ -681,7 +681,7 @@ 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:translate3d(calc(-1 * var(--session-swipe-offset,0px)),0,0) scale(calc(.82 + var(--session-swipe-progress,0) * .18)); pointer-events:none; z-index:0; } @@ -728,10 +728,11 @@ .session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;will-change:transform;} .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, - .session-item.swipe-removing{transition:background .15s,color .15s,transform .42s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;will-change:transform;} + .session-item.swipe-removing{transition:background .15s,color .15s,transform .5s cubic-bezier(.22,.61,.36,1),box-shadow .15s ease;will-change:transform;} .session-item.swipe-removing{ overflow:hidden; } + .session-item.session-swipe-returning{animation:sessionSwipeReturn .5s cubic-bezier(.22,.61,.36,1) both;will-change:transform,opacity;} .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);} @@ -823,13 +824,14 @@ @media (prefers-reduced-motion:reduce){ .session-action-menu{animation:none;will-change:auto;} .session-item,.session-item.dragging,.session-item.session-reflowing,.session-item.swipe-committed,.session-item.swipe-removing{transition:none;} - .session-item.long-pressing{animation:none;} + .session-item.long-pressing,.session-item.session-swipe-returning{animation:none;} .session-item.session-list-flip-enter{animation:none;} } /* 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 sessionSwipeReturn{0%{opacity:.62;transform:translate3d(var(--session-swipe-return-offset,32px),var(--session-reflow-offset,0),0) scale(.985);}100%{opacity:1;transform:translate3d(0,var(--session-reflow-offset,0),0) scale(1);}} @keyframes newflash{0%{background:var(--accent-bg-strong);color:var(--accent);}100%{background:transparent;color:var(--muted);}} @keyframes sessionListFlipIn{0%{opacity:0;transform:perspective(700px) rotateX(-8deg) translateY(10px) scale(.985);}100%{opacity:1;transform:perspective(700px) rotateX(0) translateY(0) scale(1);}} @keyframes spin{to{transform:rotate(360deg);}} diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index bb061463..0e9e36e4 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -38,7 +38,7 @@ def test_session_menu_has_subtle_open_animation(): 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.dragging,.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 + assert ".session-item.long-pressing,.session-item.session-swipe-returning{animation:none;}" in STYLE_CSS def test_mobile_session_menu_opens_from_long_press_and_hides_dots(): @@ -86,8 +86,8 @@ def test_session_swipes_route_archive_restore_and_delete(): assert "_swipeTracking=true" in SESSIONS_JS assert "_archiveSwipeActionThreshold=128" in SESSIONS_JS assert "_deleteSwipeActionThreshold=128" in SESSIONS_JS - assert "const SESSION_SWIPE_DURATION_MS = 420;" in SESSIONS_JS - assert "const SESSION_SWIPE_REFLOW_LEAD_MS = 180;" in SESSIONS_JS + assert "const SESSION_SWIPE_DURATION_MS = 500;" in SESSIONS_JS + assert "const SESSION_SWIPE_REFLOW_LEAD_MS = 220;" in SESSIONS_JS assert "const committedSwipeDuration=_sessionPrefersReducedMotion()?0:SESSION_SWIPE_DURATION_MS;" in SESSIONS_JS assert "const committedSwipeReflowDelay=Math.max(0,committedSwipeDuration-SESSION_SWIPE_REFLOW_LEAD_MS);" in SESSIONS_JS swipe_block = _sessions_block("const _handleSessionSwipe=(signedDx,signedDy)=>{", "const _commitSessionSwipe=()=>{") @@ -106,6 +106,12 @@ def test_session_swipes_route_archive_restore_and_delete(): assert "_settleSessionSwipePaint();" in restore_branch assert "_completeSessionSwipePaint(signedDx);" not in restore_branch assert "_archiveSession(s,false,()=>_waitForSessionMotion(committedSwipeDuration))" in restore_branch + archived_visible_start = swipe_block.find("}else if(_showArchived){") + assert archived_visible_start > restore_start + archived_visible_branch = swipe_block[archived_visible_start:archive_start] + assert "_settleSessionSwipePaint();" in archived_visible_branch + assert "_completeSessionSwipePaint(signedDx);" not in archived_visible_branch + assert "_archiveSession(s,true,()=>_waitForSessionMotion(committedSwipeDuration))" in archived_visible_branch assert archive_branch.find("_completeSessionSwipePaint(signedDx);") < archive_branch.find("_archiveSession(s,true,()=>_waitForSessionMotion(committedSwipeReflowDelay))") assert delete_branch.find("deleteSession(s.session_id,async()=>{") < delete_branch.find("_completeSessionSwipePaint(signedDx);") assert "showToast('Imported sessions cannot be deleted here.',3000);" in SESSIONS_JS @@ -129,7 +135,8 @@ def test_session_swipe_paint_uses_transform_only_exit(): assert "requestAnimationFrame(()=>requestAnimationFrame(_clearSessionSwipePaint))" in SESSIONS_JS assert "el.classList.remove('swiping-right','swiping-left','swipe-committed','swipe-removing')" in clear assert ".session-item.swipe-committed,\n .session-item.swipe-removing{transition:" 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 "transition:background .15s,color .15s,transform .5s cubic-bezier(.22,.61,.36,1),box-shadow .15s ease" in STYLE_CSS + assert "transform:translate3d(calc(-1 * var(--session-swipe-offset,0px)),0,0) scale(calc(.82 + var(--session-swipe-progress,0) * .18))" in STYLE_CSS swipe_start = STYLE_CSS.find(".session-item.swipe-removing{") swipe_end = STYLE_CSS.find("}", swipe_start) assert swipe_start >= 0 and swipe_end > swipe_start @@ -137,12 +144,13 @@ def test_session_swipe_paint_uses_transform_only_exit(): assert "height:0" not in swipe_removing assert "padding-top:0" not in swipe_removing assert "margin-bottom:0" not in swipe_removing - assert "transform:translateX(var(--session-swipe-offset,0))" in STYLE_CSS + assert "transform:translate3d(var(--session-swipe-offset,0),var(--session-reflow-offset,0),0)" in STYLE_CSS def test_session_removal_reflows_surviving_rows_smoothly(): assert "let _pendingSessionReflowPositions = null;" in SESSIONS_JS assert "const _optimisticallyRemovedSessionIds = new Set();" in SESSIONS_JS + assert "const _sessionSwipeReturnOffsets = new Map();" in SESSIONS_JS capture = _sessions_block("function _captureSessionReflowPositions(){", "function _waitForSessionMotion") helper = _sessions_block("function _playSessionRowsReflowFromPositions", "function _sessionPrefersReducedMotion") assert "positions.set(row.dataset.sid,row.getBoundingClientRect().top);" in capture @@ -163,6 +171,11 @@ def test_session_removal_reflows_surviving_rows_smoothly(): assert "const serverSessions=_optimisticallyRemovedSessionIds.size" in SESSIONS_JS assert "? (sessData.sessions||[]).filter(s=>s&&!_optimisticallyRemovedSessionIds.has(s.session_id))" 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 + assert "if(_showArchived&&!_sessionPrefersReducedMotion()) _sessionSwipeReturnOffsets.set(session.session_id,'0px');" in SESSIONS_JS + assert "const swipeReturnOffset=_sessionSwipeReturnOffsets.get(s.session_id);" in SESSIONS_JS + assert "el.classList.add('session-swipe-returning');" in SESSIONS_JS + assert "@keyframes sessionSwipeReturn" in STYLE_CSS + assert ".session-item.session-swipe-returning{animation:sessionSwipeReturn .5s cubic-bezier(.22,.61,.36,1) both;will-change:transform,opacity;}" in STYLE_CSS delete_start = SESSIONS_JS.find("async function deleteSession(sid, beforeDelete=null){") delete_end = SESSIONS_JS.find("// ── Project helpers", delete_start) assert delete_start >= 0 and delete_end > delete_start