mobile: chat open = instant + quick opacity fade (no slide) (batch128)

User chose the fade over chasing the WebView slide-teardown reflow. Open now: the cache-
rendered conversation is placed instantly and fades opacity 0->1 over 140ms. No transform,
no off-screen pane, no list-column display toggle -> nothing to reflow at the end, so the
~40ms end-of-slide settle cannot occur. Same afterOpenSlide queue flushes network reconcile/
pins/appends at fade-end. Desktop unaffected (fade is mobile-only via __freshOpen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 06:51:38 +05:30
parent 9eb4a99679
commit 9c703d6e64
+21 -15
View File
@@ -1110,10 +1110,11 @@
its own promoted GPU layer while the message DOM renders + scrolls to bottom. Phase 2 (.chat-anim, its own promoted GPU layer while the message DOM renders + scrolls to bottom. Phase 2 (.chat-anim,
added by JS on a double-rAF AFTER the render is laid out & painted): the compositor transitions added by JS on a double-rAF AFTER the render is laid out & painted): the compositor transitions
transform 100%→0 with the main thread idle → a true 60fps slide, not a jump. Dropped at transitionend. */ transform 100%→0 with the main thread idle → a true 60fps slide, not a jump. Dropped at transitionend. */
body.chat-opening .content{display:block!important;transform:translate3d(100%,0,0);} /* ONLY the start transform — display/z-index/box-shadow/will-change all come from the resting body.chat-open rule above, so removing chat-opening/chat-anim at transitionend changes NOTHING (transform 0→none is a no-op, the layer stays promoted). Zero teardown = zero end-of-slide hitch. */ /* OPEN = instant position + a quick opacity fade. No transform, no parallax, no list-column display
body.chat-opening.chat-anim .content{transform:translate3d(0,0,0);transition:transform .3s cubic-bezier(.32,.72,0,1);} toggle → there is literally nothing to reflow when it finishes, so the old ~40ms end-of-slide settle
body.chat-opening .chatcol{display:flex!important;position:absolute;inset:0;z-index:0;transform:translate3d(0,0,0);will-change:transform;backface-visibility:hidden;} is gone by construction. The list (chatcol) stays display:none throughout; content fades in over the bg. */
body.chat-opening.chat-anim .chatcol{transform:translate3d(-22%,0,0);transition:transform .3s cubic-bezier(.32,.72,0,1);} /* translate-only parallax — animating filter:brightness re-rasters the list every frame AND again on teardown; the dim isn't worth the jank. */ body.chat-opening .content{display:block!important;opacity:0;}
body.chat-opening .content.chat-fadein{opacity:1;transition:opacity .14s ease-out;}
/* CLOSE / drag-back: reveal the list underneath (parallax in) while the conversation slides off right /* CLOSE / drag-back: reveal the list underneath (parallax in) while the conversation slides off right
(.content moved by an inline transform in showWelcome()/the edge-drag gesture). */ (.content moved by an inline transform in showWelcome()/the edge-drag gesture). */
body.chat-closing .content, body.chat-dragging .content{display:block!important;z-index:2;box-shadow:-12px 0 30px rgba(15,23,42,.18);will-change:transform;} body.chat-closing .content, body.chat-dragging .content{display:block!important;z-index:2;box-shadow:-12px 0 30px rgba(15,23,42,.18);will-change:transform;}
@@ -1145,7 +1146,7 @@
</head> </head>
<body> <body>
<script src="/icons.js?v=6"></script> <script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-19-batch127';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-19-batch128';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Emoji are rendered with the OS's own (colour) emoji font — instant, zero network. // Emoji are rendered with the OS's own (colour) emoji font — instant, zero network.
// //
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from // We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
@@ -2817,26 +2818,31 @@ let _slideQueue=[], _slideEndHandler=null;
function afterOpenSlide(fn){ if(!document.body.classList.contains('chat-opening')){ fn(); return; } _slideQueue.push(fn); } function afterOpenSlide(fn){ if(!document.body.classList.contains('chat-opening')){ fn(); return; } _slideQueue.push(fn); }
function flushSlideQueue(){ const q=_slideQueue; _slideQueue=[]; for(const fn of q){ try{ fn(); }catch(_){} } } function flushSlideQueue(){ const q=_slideQueue; _slideQueue=[]; for(const fn of q){ try{ fn(); }catch(_){} } }
function armOpenSlide(){ function armOpenSlide(){
// OPEN = instant + a quick opacity fade (user-chosen). The cache-rendered conversation is already in place;
// we just fade it from 0→1. No transform, no off-screen pane, no list-column display toggle → nothing to
// reflow when it ends, so the old end-of-slide settle cannot occur. Queued network reconcile / pins / live
// appends still flush at fade-end via the same afterOpenSlide mechanism. Token guards rapid chat re-taps.
const b=document.body, c=document.querySelector('.shell > .content'); const b=document.body, c=document.querySelector('.shell > .content');
const token=(window.__slideTok=(window.__slideTok||0)+1); const token=(window.__slideTok=(window.__slideTok||0)+1);
if(c && _slideEndHandler){ c.removeEventListener('transitionend', _slideEndHandler); _slideEndHandler=null; } if(c && _slideEndHandler){ c.removeEventListener('transitionend', _slideEndHandler); _slideEndHandler=null; }
clearTimeout(window.__chatOpenT); clearTimeout(window.__chatOpenT);
b.classList.remove('chat-anim'); if(c) c.classList.remove('chat-fadein');
b.classList.add('chat-opening'); // pane -> translate3d(100%), off-screen, own GPU layer b.classList.add('chat-opening'); // content -> opacity:0 (still laid out, just invisible)
const done=()=>{ if(window.__slideTok!==token) return; const done=()=>{ if(window.__slideTok!==token) return;
if(c && _slideEndHandler){ c.removeEventListener('transitionend', _slideEndHandler); _slideEndHandler=null; } if(c && _slideEndHandler){ c.removeEventListener('transitionend', _slideEndHandler); _slideEndHandler=null; }
clearTimeout(window.__chatOpenT); clearTimeout(window.__chatOpenT);
b.classList.remove('chat-opening','chat-anim'); // teardown is now a no-op: the layer stays promoted via body.chat-open, so there's no demote raster to move off the motion path b.classList.remove('chat-opening'); // opacity returns to the resting 1 — no transform/list toggle to reflow
flushSlideQueue(); // chat-opening now OFF → queued appendBubble()s run inline instead of re-deferring into a dead queue if(c) c.classList.remove('chat-fadein');
flushSlideQueue(); // network reconcile / pins / queued appends run now
}; };
_slideEndHandler=function(e){ if(e.target===c && e.propertyName==='transform') done(); }; _slideEndHandler=function(e){ if(e.target===c && e.propertyName==='opacity') done(); };
if(c) c.addEventListener('transitionend', _slideEndHandler); if(c) c.addEventListener('transitionend', _slideEndHandler);
requestAnimationFrame(()=>requestAnimationFrame(()=>{ requestAnimationFrame(()=>{
if(window.__slideTok!==token) return; if(window.__slideTok!==token) return;
if(window.__perfProbe) window.__perfProbe('slide'); // TEMP: measure frame timing over the slide if(window.__perfProbe) window.__perfProbe('slide'); // TEMP: measure frame timing over the open
b.classList.add('chat-anim'); // compositor now runs transform 100%->0 if(c) c.classList.add('chat-fadein'); // opacity 0 -> 1 over 140ms (compositor-only)
window.__chatOpenT=setTimeout(done, 360); // fallback if transitionend is missed (300ms + margin) window.__chatOpenT=setTimeout(done, 240); // fallback if transitionend is missed (140ms + margin)
})); });
} }
async function openConvo(kind,id){ async function openConvo(kind,id){
const it=rowFor(kind,id)||{kind,id,name:'Conversation'}; const it=rowFor(kind,id)||{kind,id,name:'Conversation'};