diff --git a/frontend/src/lib/webapp/motion/heightStage.js b/frontend/src/lib/webapp/motion/heightStage.js new file mode 100644 index 0000000..121bfd7 --- /dev/null +++ b/frontend/src/lib/webapp/motion/heightStage.js @@ -0,0 +1,74 @@ +import { tick } from "svelte"; + +export function createHeightStageAnimator({ + durationMs = 360, + getElement = () => null, + settleDelayMs = 80, + setState = () => {}, +} = {}) { + let timer = null; + let frame = null; + let animationId = 0; + + function clearPending() { + if (typeof window === "undefined") return; + if (timer) { + window.clearTimeout(timer); + timer = null; + } + if (frame) { + window.cancelAnimationFrame(frame); + frame = null; + } + } + + async function animate(applyChange) { + const element = getElement(); + if (typeof window === "undefined" || !element) { + applyChange(); + return; + } + + const currentAnimationId = ++animationId; + clearPending(); + + const startHeight = Math.ceil(element.getBoundingClientRect().height); + if (!startHeight) { + applyChange(); + return; + } + + setState({ instant: true, locked: true, style: `height:${startHeight}px;` }); + await tick(); + if (currentAnimationId !== animationId) return; + + void element.offsetHeight; + applyChange(); + await tick(); + if (currentAnimationId !== animationId) return; + + const endHeight = Math.max(1, Math.ceil(element.scrollHeight)); + frame = window.requestAnimationFrame(() => { + frame = null; + if (currentAnimationId !== animationId) return; + + setState({ instant: false, locked: true, style: `height:${endHeight}px;` }); + timer = window.setTimeout(() => { + timer = null; + if (currentAnimationId !== animationId) return; + setState({ instant: false, locked: false, style: "" }); + }, durationMs + settleDelayMs); + }); + } + + function destroy() { + animationId += 1; + clearPending(); + setState({ instant: false, locked: false, style: "" }); + } + + return { + animate, + destroy, + }; +} diff --git a/frontend/src/styles/webapp.css b/frontend/src/styles/webapp.css index f917cdc..1fc1fbc 100644 --- a/frontend/src/styles/webapp.css +++ b/frontend/src/styles/webapp.css @@ -2013,6 +2013,43 @@ a { animation: toast-enter 0.22s ease-out both; } +.motion-height-stage { + display: grid; + align-content: start; + min-width: 0; +} + +.motion-height-stage.motion-height-locked { + overflow: hidden; + transition: height var(--motion-stage-duration, 360ms) cubic-bezier(0.22, 0.72, 0.2, 1); + will-change: height; +} + +.motion-height-stage.motion-height-instant { + transition: none; +} + +.motion-enter-card { + animation: motion-card-enter var(--motion-enter-duration, 340ms) cubic-bezier(0.22, 0.72, 0.2, 1) + backwards; + animation-delay: var(--motion-delay, 0ms); + transform-origin: var(--motion-origin, center top); + will-change: opacity, transform; +} + +.motion-fade-up { + animation: motion-fade-up var(--motion-enter-duration, 160ms) ease-out both; +} + +.motion-scale-in { + animation: motion-scale-in var(--motion-enter-duration, 240ms) cubic-bezier(0.22, 0.72, 0.2, 1) + both; +} + +.motion-shimmer { + animation: motion-shimmer var(--motion-shimmer-duration, 1.15s) ease-in-out infinite; +} + @keyframes section-enter { from { opacity: 0; @@ -2037,6 +2074,56 @@ a { } } +@keyframes motion-card-enter { + from { + opacity: 0; + transform: translate3d(0, 12px, 0) scale(0.985); + } + + 70% { + opacity: 1; + } + + to { + opacity: 1; + transform: translate3d(0, 0, 0) scale(1); + } +} + +@keyframes motion-fade-up { + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes motion-scale-in { + from { + opacity: 0; + transform: scale(0.975); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes motion-shimmer { + 0% { + background-position: 120% 0; + } + + 100% { + background-position: -120% 0; + } +} + @keyframes toast-enter { from { opacity: 0; @@ -2234,11 +2321,16 @@ a { .dialog-backdrop, .dialog-card, .auth-mode-panel, + .motion-enter-card, + .motion-fade-up, + .motion-scale-in, + .motion-shimmer, .toast { animation: none !important; } - .auth-card { + .auth-card, + .motion-height-stage.motion-height-locked { transition: none !important; } } diff --git a/frontend/src/webapp/screens/InstallGuideScreen.svelte b/frontend/src/webapp/screens/InstallGuideScreen.svelte index 673c3ef..004b56f 100644 --- a/frontend/src/webapp/screens/InstallGuideScreen.svelte +++ b/frontend/src/webapp/screens/InstallGuideScreen.svelte @@ -1,7 +1,5 @@