From 9fd349898f6cf6202cf5370520554b435e542703 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 2 Jul 2026 04:01:38 +0000 Subject: [PATCH] fix(chat): defer mobile overflow-anchor suppression (re-arm reachable + independent hard cap) #4856 Clean rebase of allenliang2022's #5392 (rebase-first). Co-authored-by: allenliang2022 --- static/ui.js | 131 ++++++++- ...est_issue4856_android_scroll_regression.py | 267 +++++++++++++++++- 2 files changed, 378 insertions(+), 20 deletions(-) diff --git a/static/ui.js b/static/ui.js index 67fe3af1..b996ce3b 100644 --- a/static/ui.js +++ b/static/ui.js @@ -12249,18 +12249,133 @@ function _restoreMessageScrollSnapshot(snapshot){ * Chromium cannot re-anchor to the topmost row during the innerHTML='' * wipe-and-rebuild gap. The rAF callback restores CSS default afterward. */ +// Mobile scroll jump-back root fix. On touch devices #messages rests at +// overflow-anchor:auto, so the browser's native scroll-anchoring engine +// re-compensates scrollTop in the LAYOUT phase whenever content above the +// viewport changes height — worklog live→settled collapse, tool-card inserts, +// media/katex reflow, virtual-scroll topPad recompute, the STREAM_DONE +// multi-render sequence. That compensation happens in the browser's layout step, +// INDEPENDENT of which frame our JS wrote scrollTop in, so per-write suppression +// (a single-rAF guard) could not reach it: the collapse/reflow lands a frame or +// two later, after the guard already released. Real mobile flight-recorder data +// (captured jumps with dTop -101/+350/+748/-400, call stack = rAF sampler only = +// NO JS frame) confirmed the compensation is the browser engine, not our scroll +// writes. +// +// Fix: DEFER the restore AND track CSS animations. Each call re-arms suppression +// and cancels any pending release, so a burst of renders (STREAM_DONE fires +// several back-to-back) shares ONE suppression window. The base window is two +// animation frames + a settle timeout, which covers churn that is NOT a CSS +// animation (virtual topPad recompute, image-decode, katex measure). But the +// dominant churn is CSS max-height collapse/expand animations on worklog rows — +// .activity-body (.34s), .tool-group-body (.3s), .tool-card-detail (.26s) — which +// run LONGER than a fixed window; a fixed window lifts mid-animation and the rest +// of the animation still jumps. So we also bind transitionrun/transitionend on +// #messages: an animation start holds suppression (cancels the pending release); +// an animation end schedules a short settle after the LAST one. Hard-capped so a +// looping transition can't pin overflow-anchor:none forever. Desktop rests at +// none (predicate false) → the whole guard is a no-op. +const _MOBILE_ANCHOR_BASE_SETTLE_MS=400; +const _MOBILE_ANCHOR_POST_TRANSITION_MS=90; +const _MOBILE_ANCHOR_MAX_HOLD_MS=1200; +let _mobileAnchorSuppressReleaseTimer=null; +let _mobileAnchorSuppressRafId=0; +let _mobileAnchorTransitionListenerBound=false; +let _mobileAnchorSuppressArmedAt=0; +// Independent hard-cap timer. Unlike the settle/rAF release (which the +// transitionrun handler CANCELS to hold across an animation), this one is NEVER +// cancelled by re-arm or by onRun — it is only ever cleared when suppression is +// actually lifted, and re-armed to a fresh deadline on each _fixMobileScrollJank +// call. This guarantees overflow-anchor returns to the mobile resting 'auto' +// even if EVERY transitionend/transitioncancel is missed (animation interrupted, +// element detached mid-transition, etc.) — the #5338 contract that mobile rests +// at 'auto' must hold no matter what. (Gate-cert defect: the previous +// _MOBILE_ANCHOR_MAX_HOLD_MS was only a guard clause inside onRun, so a missed +// transitionend pinned 'none' forever.) +let _mobileAnchorMaxHoldTimer=null; +function _liftMobileAnchorSuppression(el){ + if(_mobileAnchorSuppressReleaseTimer){ clearTimeout(_mobileAnchorSuppressReleaseTimer); _mobileAnchorSuppressReleaseTimer=null; } + if(_mobileAnchorMaxHoldTimer){ clearTimeout(_mobileAnchorMaxHoldTimer); _mobileAnchorMaxHoldTimer=null; } + if(_mobileAnchorSuppressRafId&&typeof cancelAnimationFrame==='function'){ cancelAnimationFrame(_mobileAnchorSuppressRafId); } + _mobileAnchorSuppressRafId=0; + // Only clear the inline value we set; a concurrent path may have legitimately + // re-armed it (checked via the 'none' guard). + if(el&&el.style&&el.style.overflowAnchor==='none') el.style.overflowAnchor=''; +} +function _bindMobileAnchorTransitionExtender(el){ + if(_mobileAnchorTransitionListenerBound||!el||!el.addEventListener) return; + _mobileAnchorTransitionListenerBound=true; + // Only act while suppression is actually armed (inline 'none') and within the + // hard cap, so we never pin overflow-anchor:none indefinitely. + const onRun=(e)=>{ + if(!e||e.propertyName!=='max-height') return; + if(el.style.overflowAnchor!=='none') return; + if(_mobileAnchorSuppressArmedAt && (performance.now()-_mobileAnchorSuppressArmedAt)>_MOBILE_ANCHOR_MAX_HOLD_MS) return; + // An animation is running — cancel the pending SETTLE release so we stay + // suppressed until it ends (transitionend re-schedules the settle). The + // independent max-hold timer is deliberately NOT cancelled here. + if(_mobileAnchorSuppressReleaseTimer){ clearTimeout(_mobileAnchorSuppressReleaseTimer); _mobileAnchorSuppressReleaseTimer=null; } + if(_mobileAnchorSuppressRafId&&typeof cancelAnimationFrame==='function'){ cancelAnimationFrame(_mobileAnchorSuppressRafId); } + _mobileAnchorSuppressRafId=0; + }; + const onEnd=(e)=>{ + if(!e||e.propertyName!=='max-height') return; + if(el.style.overflowAnchor!=='none') return; + // This animation ended; settle shortly after (another may still be running, + // in which case its own transitionrun already cancelled this timer). + if(_mobileAnchorSuppressReleaseTimer){ clearTimeout(_mobileAnchorSuppressReleaseTimer); } + _mobileAnchorSuppressReleaseTimer=setTimeout(()=>{ + _mobileAnchorSuppressReleaseTimer=null; + _liftMobileAnchorSuppression(el); + },_MOBILE_ANCHOR_POST_TRANSITION_MS); + }; + el.addEventListener('transitionrun',onRun,{passive:true}); + el.addEventListener('transitionstart',onRun,{passive:true}); + el.addEventListener('transitionend',onEnd,{passive:true}); + el.addEventListener('transitioncancel',onEnd,{passive:true}); +} window._fixMobileScrollJank=function _fixMobileScrollJank(){ const el=document.getElementById('messages'); if(!el) return; - // Route through the same "is the browser scroll-anchor layer active?" predicate - // as _suppressBrowserOverflowAnchor so the two guards can't drift (#5338 review). - // Desktop rests at overflow-anchor:none (predicate false) → nothing to suppress. - // Mobile touch devices rest at auto (predicate true) → temporarily suppress - // anchor re-selection during the wipe-and-rebuild gap. - if(!_browserOverflowAnchorActive(el)) return; + // Engage when the browser scroll-anchor layer is active (mobile auto), OR when + // WE are already holding an inline suppression from a prior call in the same + // burst. The predicate reads the COMPUTED value, which our own inline + // overflow-anchor:none flips to 'none' — so on the 2nd..Nth call of a + // STREAM_DONE burst the predicate would say false and short-circuit the re-arm + // below, collapsing the whole "consecutive renders extend the window" behavior + // to a single first-call window. Treat an inline 'none' WE set as still-armed + // so re-arm actually runs. Desktop rests at computed 'none' with EMPTY inline, + // so `alreadySuppressed` is false there and this stays a no-op. (Gate-cert + // defect: re-arm was dead code without this.) + const alreadySuppressed=el.style.overflowAnchor==='none'; + if(!alreadySuppressed && !_browserOverflowAnchorActive(el)) return; el.style.overflowAnchor='none'; - requestAnimationFrame(()=>{ - if(el.style.overflowAnchor==='none') el.style.overflowAnchor=''; + _bindMobileAnchorTransitionExtender(el); + _mobileAnchorSuppressArmedAt=performance.now(); + // Re-arm: cancel any pending release so consecutive renders EXTEND, not shorten, + // the suppression window (the STREAM_DONE settle fires renderMessages several + // times back-to-back, plus a deferred postProcess reflow). + if(_mobileAnchorSuppressReleaseTimer){ clearTimeout(_mobileAnchorSuppressReleaseTimer); _mobileAnchorSuppressReleaseTimer=null; } + if(_mobileAnchorSuppressRafId&&typeof cancelAnimationFrame==='function'){ cancelAnimationFrame(_mobileAnchorSuppressRafId); } + _mobileAnchorSuppressRafId=0; + // Independent hard cap: (re)arm a release that NOTHING cancels except an actual + // lift, so a missed transitionend can never pin 'none' past the cap. + if(_mobileAnchorMaxHoldTimer){ clearTimeout(_mobileAnchorMaxHoldTimer); } + _mobileAnchorMaxHoldTimer=setTimeout(()=>{ + _mobileAnchorMaxHoldTimer=null; + _liftMobileAnchorSuppression(el); + },_MOBILE_ANCHOR_MAX_HOLD_MS); + const rafHop=(cb)=>{ if(typeof requestAnimationFrame==='function') return requestAnimationFrame(cb); return setTimeout(cb,16); }; + // Base window: two animation frames (paint + post-render reflow settle) THEN a + // settle timeout. CSS max-height animations are covered by the transitionrun/ + // transitionend extender above; this floor covers non-animated churn. + _mobileAnchorSuppressRafId=rafHop(()=>{ + _mobileAnchorSuppressRafId=rafHop(()=>{ + _mobileAnchorSuppressReleaseTimer=setTimeout(()=>{ + _mobileAnchorSuppressReleaseTimer=null; + _liftMobileAnchorSuppression(el); + },_MOBILE_ANCHOR_BASE_SETTLE_MS); + }); }); }; diff --git a/tests/test_issue4856_android_scroll_regression.py b/tests/test_issue4856_android_scroll_regression.py index 0c9ba7c6..4b1cc246 100644 --- a/tests/test_issue4856_android_scroll_regression.py +++ b/tests/test_issue4856_android_scroll_regression.py @@ -7,7 +7,7 @@ UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8") _FUNC_MARKER = "window._fixMobileScrollJank=function _fixMobileScrollJank(){" -_RAF_MARKER = "requestAnimationFrame(()=>{" +_RAF_MARKER = "setTimeout(()=>{" def _extract_fix_mobile_scroll_jank(src: str) -> str: @@ -25,8 +25,11 @@ def _extract_fix_mobile_scroll_jank(src: str) -> str: def _extract_raf_body(fn_src: str) -> str: + # The deferred-release cleanup now lives inside the final setTimeout callback + # (see the two-rAF-hop + settle-timeout structure), not a bare rAF. Extract + # that callback body so the cleanup-check assertions below still apply. idx = fn_src.find(_RAF_MARKER) - assert idx != -1, "requestAnimationFrame callback not found in function" + assert idx != -1, "deferred-release setTimeout callback not found in function" depth = 0 for i, ch in enumerate(fn_src[idx:], idx): if ch == "{": @@ -35,7 +38,7 @@ def _extract_raf_body(fn_src: str) -> str: depth -= 1 if depth == 0: return fn_src[idx : i + 1] - raise AssertionError("Could not extract rAF body") + raise AssertionError("Could not extract deferred-release cleanup body") def test_fix_sets_none_not_auto(): @@ -54,16 +57,23 @@ def test_fix_sets_none_not_auto(): def test_raf_cleanup_checks_none(): - # The rAF guard must check for 'none' so it only clears the inline style - # it set; checking 'auto' was always false and left the inline style on. - fn = _extract_fix_mobile_scroll_jank(UI_JS) - raf = _extract_raf_body(fn) - assert "overflowAnchor==='none'" in raf, ( - "The rAF cleanup in _fixMobileScrollJank() must check for 'none' so it " - "clears the inline style after the synchronous scrollTop write lands." + # The release cleanup must check for 'none' so it only clears the inline + # style it set; checking 'auto' was always false and left the inline style + # on. The cleanup now lives in the shared _liftMobileAnchorSuppression() + # helper (called by every release path: base settle, transition settle, and + # the independent hard cap), so assert the invariant there. + lift_idx = UI_JS.find("function _liftMobileAnchorSuppression(") + assert lift_idx != -1, ( + "_liftMobileAnchorSuppression() must exist as the single cleanup path " + "shared by the base-settle, transition-settle, and hard-cap releases." ) - assert "overflowAnchor==='auto'" not in raf, ( - "The rAF cleanup must not check for 'auto'; that check was always false." + lift = _balanced_block(UI_JS, UI_JS.index("{", lift_idx)) + assert "overflowAnchor==='none'" in lift, ( + "The cleanup in _liftMobileAnchorSuppression() must check for 'none' so " + "it clears only the inline style it set, not a legitimately re-armed one." + ) + assert "overflowAnchor==='auto'" not in lift, ( + "The cleanup must not check for 'auto'; that check was always false." ) @@ -570,3 +580,236 @@ def test_post_process_runs_under_overflow_anchor_suppression(): "wrapper; found fewer than 3." ) + +def test_fix_mobile_scroll_jank_defers_release_across_height_churn(): + """Root-cause fix: the anchor suppression must span the WHOLE height-churn + window, not just one frame. + + Real mobile flight-recorder data proved the jump-back is the browser's own + overflow-anchor engine re-compensating scrollTop in the LAYOUT phase when + above-viewport content changes height (virtual-scroll topPad recompute, + worklog live->settled collapse, STREAM_DONE multi-render, media/katex + reflow). That compensation is independent of which frame our JS wrote + scrollTop, so the previous single-rAF restore released too early -- the + collapse/reflow lands a frame or two later, after suppression lifted. + + The fix DEFERS release: each call re-arms and cancels any pending release so + a burst of renders shares one suppression window that only lifts after the + layout has been quiet for two animation frames + a settle timeout. These + source invariants encode that behavior (base-fails on the old single-rAF + body / head-passes on the deferred-release body). + """ + fn = _extract_fix_mobile_scroll_jank(UI_JS) + # Re-arm must cancel a pending release so consecutive renders EXTEND, not + # restart-and-shorten, the window. + assert "clearTimeout(" in fn, ( + "_fixMobileScrollJank() must clearTimeout() any pending release on " + "re-arm so a burst of renders shares one suppression window." + ) + assert "cancelAnimationFrame(" in fn, ( + "_fixMobileScrollJank() must cancelAnimationFrame() any pending release " + "hop on re-arm so consecutive renders extend the suppression window." + ) + # Release must be deferred past the render frame: a settle timeout gated + # behind animation-frame hops (not a bare single rAF). + assert "setTimeout(" in fn, ( + "_fixMobileScrollJank() must defer the anchor restore behind a settle " + "timeout so late layout reflow after the render cannot re-anchor." + ) + # The module-level re-arm state the deferred release needs. + assert "_mobileAnchorSuppressReleaseTimer" in UI_JS, ( + "The deferred-release timer handle must be tracked module-level so " + "successive _fixMobileScrollJank() calls can cancel and re-arm it." + ) + # Guard against regressing to the old immediate single-rAF restore: the + # function body must NOT restore overflowAnchor inside a bare + # requestAnimationFrame(()=>{...}) with no intervening defer. + assert "requestAnimationFrame(()=>{\n if(el.style.overflowAnchor==='none')" not in fn, ( + "_fixMobileScrollJank() must not restore overflow-anchor in a single " + "immediate rAF; that released before the height-churn window closed " + "(the residual mobile scroll jump-back this fix addresses)." + ) + + +def test_fix_mobile_scroll_jank_tracks_css_max_height_animations(): + """The deferred window must EXTEND across CSS max-height animations. + + A fixed rAF+timeout window (~150ms) under-covers the real churn: the + dominant above-viewport height change during streaming is CSS max-height + collapse/expand animations on worklog rows -- `.activity-body` + (transition: max-height .34s), `.tool-group-body` (.3s), + `.tool-card-detail` (.26s). Those run 260-340ms, LONGER than a fixed window, + so overflow-anchor:none lifts mid-animation and the remaining frames of the + animation still jump (the residual on-device report captured mid-stream). + + Fix: bind transitionrun/transitionend for `max-height` on #messages so an + animation start HOLDS suppression (cancels the pending release) and an + animation end schedules a short settle after the last one, bounded by a hard + cap. This test locks in that the guard listens for the animation lifecycle. + """ + assert "_bindMobileAnchorTransitionExtender" in UI_JS, ( + "A transition extender must exist so the suppression window tracks CSS " + "max-height animations that outlast the fixed deferred window." + ) + # It must listen for the START of an animation (hold) and the END (settle). + assert "'transitionrun'" in UI_JS or '"transitionrun"' in UI_JS, ( + "The extender must listen for transitionrun so an animation beginning " + "AFTER the base window started counting down still holds suppression." + ) + assert "'transitionend'" in UI_JS or '"transitionend"' in UI_JS, ( + "The extender must listen for transitionend so suppression settles only " + "after the animation actually finishes." + ) + # It must gate on the height property, not every transition (opacity etc.). + assert "propertyName!=='max-height'" in UI_JS or 'propertyName!=="max-height"' in UI_JS, ( + "The extender must act only on max-height transitions -- those are the " + "ones that change above-viewport height and drive the anchor jump." + ) + # A hard cap must exist so a looping/pathological transition can't pin + # overflow-anchor:none forever. + assert "_MOBILE_ANCHOR_MAX_HOLD_MS" in UI_JS, ( + "A max-hold cap must bound the transition-extended suppression so a " + "looping transition cannot pin overflow-anchor:none indefinitely." + ) + + +# ── Behavioral harness: execute the REAL _fixMobileScrollJank against a mock +# #messages element + fake timers, so we test BEHAVIOR (does re-arm push the +# release out? does the hard cap fire when transitionend is missed?) instead of +# source strings. These two behaviors were the gate-cert defects on #5392: +# 1. re-arm was DEAD CODE — the computed-value predicate short-circuited the +# 2nd..Nth call of a burst because our own inline overflow-anchor:none had +# already flipped computed to 'none', so consecutive renders never extended +# the window (collapsed to a single first-call window). +# 2. the "hard cap" was only a guard clause inside onRun, not an independent +# release timer — so a MISSED transitionend (interrupted animation, detached +# element) pinned overflow-anchor:none forever, violating the #5338 contract +# that mobile rests at 'auto'. +# Both assertions are mutation-verified: reverting either fix flips the matching +# test to FAIL (proven at authoring time by reverting each hunk). +_ANCHOR_BLOCK_START = "const _MOBILE_ANCHOR_BASE_SETTLE_MS" + + +def _extract_anchor_block(src: str) -> str: + start = src.find(_ANCHOR_BLOCK_START) + assert start != -1, "anchor suppression block not found in ui.js" + fn_mark = src.find("window._fixMobileScrollJank=function", start) + assert fn_mark != -1, "_fixMobileScrollJank not found" + brace_open = src.index("{", src.index("(){", fn_mark)) + depth = 0 + end = -1 + for i in range(brace_open, len(src)): + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + if depth == 0: + end = i + break + assert end != -1, "could not balance _fixMobileScrollJank braces" + return src[start : src.index(";", end) + 1] + + +_ANCHOR_HARNESS = r""" +const block = %s; +function makeSandbox() { + let now = 0; const timers = []; let nextId = 1; + const schedule = (cb, delay) => { const id = nextId++; timers.push({ id, at: now + (delay||0), cb }); return id; }; + const cancel = (id) => { const i = timers.findIndex(t => t.id === id); if (i >= 0) timers.splice(i, 1); }; + const advance = (ms) => { const target = now + ms; + for (;;) { const due = timers.filter(t => t.at <= target).sort((a,b)=>a.at-b.at); + if (!due.length) break; const t = due[0]; timers.splice(timers.indexOf(t),1); now = t.at; t.cb(); } + now = target; }; + const setTimeout = (cb,d)=>schedule(cb,d); const clearTimeout = (id)=>cancel(id); + const requestAnimationFrame = (cb)=>schedule(cb,16); const cancelAnimationFrame = (id)=>cancel(id); + const performance = { now: () => now }; + const listeners = {}; + const el = { style:{overflowAnchor:'auto'}, + addEventListener:(t,f)=>{(listeners[t]=listeners[t]||[]).push(f);}, + _fire:(t,e)=>{(listeners[t]||[]).forEach(f=>f(e));} }; + const _browserOverflowAnchorActive = (e)=>e.style.overflowAnchor==='auto'; + const document = { getElementById:(id)=>id==='messages'?el:null }; const win = {}; + new Function('setTimeout','clearTimeout','requestAnimationFrame','cancelAnimationFrame', + 'performance','document','window','_browserOverflowAnchorActive', block + )(setTimeout,clearTimeout,requestAnimationFrame,cancelAnimationFrame, + performance,document,win,_browserOverflowAnchorActive); + return { fix: win._fixMobileScrollJank, el, advance }; +} +const out = {}; +// TEST 1: re-arm extends the window across consecutive calls. +{ const s = makeSandbox(); + s.fix(); const armed1 = s.el.style.overflowAnchor; + s.advance(200); const midStill = s.el.style.overflowAnchor; + s.fix(); s.advance(300); const afterFirstWouldRelease = s.el.style.overflowAnchor; + s.advance(400); const afterSecond = s.el.style.overflowAnchor; + out.rearm = { armed1, midStill, afterFirstWouldRelease, afterSecond }; } +// TEST 2: hard cap releases when transitionend is missed. +{ const s = makeSandbox(); + s.fix(); s.el._fire('transitionrun', { propertyName:'max-height' }); + s.advance(500); const heldMid = s.el.style.overflowAnchor; + s.advance(1000); const afterCap = s.el.style.overflowAnchor; + out.hardcap = { heldMid, afterCap }; } +console.log(JSON.stringify(out)); +""" + + +def _run_anchor_harness() -> dict: + block = _extract_anchor_block(UI_JS) + script = _ANCHOR_HARNESS % json.dumps(block) + result = subprocess.run( + [NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30 + ) + return json.loads(result.stdout.strip()) + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_behavioral_rearm_extends_suppression_window(): + """Consecutive _fixMobileScrollJank() calls must EXTEND the window. + + Gate-cert defect: the computed-value predicate short-circuited the 2nd call + (our inline 'none' made computed 'none' -> predicate false -> early return), + so re-arm never ran. Behaviorally: with the bug, the release stays anchored + to call #1 and fires ~432ms in, so at t=500 (after a 2nd call at t=200) the + anchor is already restored. With the fix, the 2nd call re-arms and the anchor + is STILL 'none' at t=500. Mutation-verified: reverting the `alreadySuppressed` + guard flips `afterFirstWouldRelease` from 'none' to ''. + """ + r = _run_anchor_harness()["rearm"] + assert r["armed1"] == "none", "first call must engage suppression" + assert r["midStill"] == "none", "suppression must hold within the first window" + assert r["afterFirstWouldRelease"] == "none", ( + "the 2nd _fixMobileScrollJank() call must RE-ARM and extend the window; " + "if suppression released here, re-arm is dead code (the computed-value " + "predicate short-circuited the 2nd call)." + ) + assert r["afterSecond"] == "", ( + "suppression must eventually release to the mobile resting 'auto' after " + "the (extended) window elapses." + ) + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_behavioral_hard_cap_releases_when_transitionend_missed(): + """A missed transitionend must NOT pin overflow-anchor:none forever. + + Gate-cert defect: the 'hard cap' was only a guard clause inside onRun, not an + independent release timer. If a max-height animation STARTS (transitionrun + holds suppression) but its transitionend never fires (interrupted / element + detached), nothing restored 'auto' -> stuck 'none', violating the #5338 + resting-value contract. With the fix, an independent max-hold timer restores + 'auto' by _MOBILE_ANCHOR_MAX_HOLD_MS. Mutation-verified: removing the + independent timer leaves `afterCap` stuck at 'none'. + """ + r = _run_anchor_harness()["hardcap"] + assert r["heldMid"] == "none", ( + "transitionrun must HOLD suppression across the animation (the settle " + "release is cancelled while animating)." + ) + assert r["afterCap"] == "", ( + "when transitionend is missed, the independent hard-cap timer MUST still " + "restore the mobile resting 'auto'; a guard-clause-only cap pins 'none' " + "forever." + ) + + +