refine archive/restore animations

This commit is contained in:
dobby-d-elf 2026-05-21 07:52:42 -06:00
parent a277bbd42c
commit c4fffff205
3 changed files with 43 additions and 11 deletions

View file

@ -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)=>{

View file

@ -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);}}

View file

@ -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