diff --git a/static/js/section-management.js b/static/js/section-management.js index 01f059d..3ec17a1 100644 --- a/static/js/section-management.js +++ b/static/js/section-management.js @@ -33,31 +33,53 @@ export function initSectionCollapse(Storage) { Storage.setJSON('section-collapsed', state); // Always clear any in-flight animation classes from a previous toggle - // so back-to-back clicks restart cleanly. + // so back-to-back clicks restart cleanly. Bump a generation token so + // any callback still pending from a superseded toggle becomes a no-op. section.classList.remove('section-just-expanded', 'section-just-collapsing'); + const gen = (section._collapseGen = (section._collapseGen || 0) + 1); if (willCollapse) { - // Domino-out: play the fade/slide-down on .list-item children - // BEFORE actually adding .collapsed (which hides them via - // display:none). After the cascade finishes, lock in collapse. - // Force reflow so the keyframes restart. + // Domino-out: play the fade/slide-down on the row children BEFORE + // actually adding .collapsed (which hides them via display:none), + // then lock in collapse once the cascade finishes. + // + // We wait on the REAL animations (getAnimations) rather than a fixed + // timeout. Different sections animate different rows — .list-item in + // most, .models-row in #models-section — so any hard-coded duration + // either stalls with a dead pause (when the selector matches nothing, + // as it did for #models-section) or guesses the wrong length. Force a + // reflow first so the keyframes restart from the top. // eslint-disable-next-line no-unused-expressions section.offsetHeight; section.classList.add('section-just-collapsing'); - const itemCount = Math.min(12, section.querySelectorAll('.list-item').length); - const total = itemCount * 25 + 230; // matches CSS keyframes + stagger - setTimeout(() => { + + const lockCollapsed = () => { + if (section._collapseGen !== gen) return; // superseded by a newer toggle section.classList.remove('section-just-collapsing'); section.classList.add('collapsed'); - }, total); + }; + // Only the domino-out keyframes gate the collapse — ignore unrelated + // (and possibly infinite, e.g. spinners) animations in the subtree. + const dominoOut = section.getAnimations({ subtree: true }) + .filter(a => a.animationName === 'section-domino-out'); + if (dominoOut.length === 0) { + lockCollapsed(); // nothing to animate — collapse now, no dead pause + } else { + Promise.allSettled(dominoOut.map(a => a.finished)).then(lockCollapsed); + // Safety net: if an animation never settles (e.g. element removed), + // still lock in the collapse so the section can't get stuck open. + setTimeout(lockCollapsed, 600); + } } else { - // Expand path — already had this: remove .collapsed and replay - // the inbound domino. + // Expand path — remove .collapsed and replay the inbound domino. section.classList.remove('collapsed'); // eslint-disable-next-line no-unused-expressions section.offsetHeight; section.classList.add('section-just-expanded'); - setTimeout(() => section.classList.remove('section-just-expanded'), 700); + setTimeout(() => { + if (section._collapseGen !== gen) return; // superseded by a newer toggle + section.classList.remove('section-just-expanded'); + }, 700); } } diff --git a/static/style.css b/static/style.css index 52c7c70..d4569ae 100644 --- a/static/style.css +++ b/static/style.css @@ -1251,21 +1251,21 @@ body.bg-pattern-sparkles { for ~700ms), the .list-item children cascade in one after another, same feel as the chat input's tools menu. Each row springs in from a tiny offset below + scaled-down, staggered by nth-child. */ - .section.section-just-expanded .list-item { + .section.section-just-expanded :is(.list-item, .models-row) { animation: section-domino-in 0.36s cubic-bezier(0.22, 1.61, 0.36, 1) backwards; } - .section.section-just-expanded .list-item:nth-child(1) { animation-delay: 0.04s; } - .section.section-just-expanded .list-item:nth-child(2) { animation-delay: 0.08s; } - .section.section-just-expanded .list-item:nth-child(3) { animation-delay: 0.12s; } - .section.section-just-expanded .list-item:nth-child(4) { animation-delay: 0.16s; } - .section.section-just-expanded .list-item:nth-child(5) { animation-delay: 0.20s; } - .section.section-just-expanded .list-item:nth-child(6) { animation-delay: 0.24s; } - .section.section-just-expanded .list-item:nth-child(7) { animation-delay: 0.28s; } - .section.section-just-expanded .list-item:nth-child(8) { animation-delay: 0.32s; } - .section.section-just-expanded .list-item:nth-child(9) { animation-delay: 0.36s; } - .section.section-just-expanded .list-item:nth-child(10) { animation-delay: 0.40s; } - .section.section-just-expanded .list-item:nth-child(11) { animation-delay: 0.44s; } - .section.section-just-expanded .list-item:nth-child(12) { animation-delay: 0.48s; } + .section.section-just-expanded :is(.list-item, .models-row):nth-child(1) { animation-delay: 0.04s; } + .section.section-just-expanded :is(.list-item, .models-row):nth-child(2) { animation-delay: 0.08s; } + .section.section-just-expanded :is(.list-item, .models-row):nth-child(3) { animation-delay: 0.12s; } + .section.section-just-expanded :is(.list-item, .models-row):nth-child(4) { animation-delay: 0.16s; } + .section.section-just-expanded :is(.list-item, .models-row):nth-child(5) { animation-delay: 0.20s; } + .section.section-just-expanded :is(.list-item, .models-row):nth-child(6) { animation-delay: 0.24s; } + .section.section-just-expanded :is(.list-item, .models-row):nth-child(7) { animation-delay: 0.28s; } + .section.section-just-expanded :is(.list-item, .models-row):nth-child(8) { animation-delay: 0.32s; } + .section.section-just-expanded :is(.list-item, .models-row):nth-child(9) { animation-delay: 0.36s; } + .section.section-just-expanded :is(.list-item, .models-row):nth-child(10) { animation-delay: 0.40s; } + .section.section-just-expanded :is(.list-item, .models-row):nth-child(11) { animation-delay: 0.44s; } + .section.section-just-expanded :is(.list-item, .models-row):nth-child(12) { animation-delay: 0.48s; } @keyframes section-domino-in { 0% { opacity: 0; transform: translateY(8px) translateX(-4px) scale(0.92); } 60% { opacity: 1; } @@ -1279,21 +1279,21 @@ body.bg-pattern-sparkles { nth-last-child so the BOTTOM item leaves first and the cascade rolls upward — mirrors the "stacked deck" feeling of the open animation reversed. */ - .section.section-just-collapsing .list-item { + .section.section-just-collapsing :is(.list-item, .models-row) { animation: section-domino-out 0.22s ease-in forwards; } - .section.section-just-collapsing .list-item:nth-last-child(1) { animation-delay: 0.00s; } - .section.section-just-collapsing .list-item:nth-last-child(2) { animation-delay: 0.025s; } - .section.section-just-collapsing .list-item:nth-last-child(3) { animation-delay: 0.05s; } - .section.section-just-collapsing .list-item:nth-last-child(4) { animation-delay: 0.075s; } - .section.section-just-collapsing .list-item:nth-last-child(5) { animation-delay: 0.10s; } - .section.section-just-collapsing .list-item:nth-last-child(6) { animation-delay: 0.125s; } - .section.section-just-collapsing .list-item:nth-last-child(7) { animation-delay: 0.15s; } - .section.section-just-collapsing .list-item:nth-last-child(8) { animation-delay: 0.175s; } - .section.section-just-collapsing .list-item:nth-last-child(9) { animation-delay: 0.20s; } - .section.section-just-collapsing .list-item:nth-last-child(10) { animation-delay: 0.225s; } - .section.section-just-collapsing .list-item:nth-last-child(11) { animation-delay: 0.25s; } - .section.section-just-collapsing .list-item:nth-last-child(12) { animation-delay: 0.275s; } + .section.section-just-collapsing :is(.list-item, .models-row):nth-last-child(1) { animation-delay: 0.00s; } + .section.section-just-collapsing :is(.list-item, .models-row):nth-last-child(2) { animation-delay: 0.025s; } + .section.section-just-collapsing :is(.list-item, .models-row):nth-last-child(3) { animation-delay: 0.05s; } + .section.section-just-collapsing :is(.list-item, .models-row):nth-last-child(4) { animation-delay: 0.075s; } + .section.section-just-collapsing :is(.list-item, .models-row):nth-last-child(5) { animation-delay: 0.10s; } + .section.section-just-collapsing :is(.list-item, .models-row):nth-last-child(6) { animation-delay: 0.125s; } + .section.section-just-collapsing :is(.list-item, .models-row):nth-last-child(7) { animation-delay: 0.15s; } + .section.section-just-collapsing :is(.list-item, .models-row):nth-last-child(8) { animation-delay: 0.175s; } + .section.section-just-collapsing :is(.list-item, .models-row):nth-last-child(9) { animation-delay: 0.20s; } + .section.section-just-collapsing :is(.list-item, .models-row):nth-last-child(10) { animation-delay: 0.225s; } + .section.section-just-collapsing :is(.list-item, .models-row):nth-last-child(11) { animation-delay: 0.25s; } + .section.section-just-collapsing :is(.list-item, .models-row):nth-last-child(12) { animation-delay: 0.275s; } @keyframes section-domino-out { 0% { opacity: 1; transform: translateY(0) translateX(0) scale(1); } 100% { opacity: 0; transform: translateY(6px) translateX(-3px) scale(0.94); }