fix(ui): hold info toast ~1s before it rises and fades
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s

The info toast began drifting up and fading the instant it finished
appearing (the CSS keyframes went straight from the 12% appeared-stop to
the 100% risen-and-faded stop), so a glanced message was already leaving.

Insert a ~1s hold at full opacity/rest before the rise-and-fade: extend the
animation 2s → 3s with keyframe stops at 8% (appeared, ~240ms) and 41% (end
of the ~1s hold), keeping the original rise-and-fade pace for the tail. The
reduced-motion variant gets the same appear/hold/fade timing (fade only, no
travel). The showToast dismissal timer is bumped 2000 → 3000ms to stay in
lockstep with the animation (the error toast's 4s dwell is unchanged).
This commit is contained in:
Ilia Denisov
2026-07-05 14:10:34 +02:00
parent fb7490f1df
commit 400b6ac2a5
2 changed files with 16 additions and 13 deletions
+13 -10
View File
@@ -3,8 +3,9 @@
import { app, dismissToast } from '../lib/app.svelte'; import { app, dismissToast } from '../lib/app.svelte';
const dur = $derived(app.reduceMotion ? 0 : 260); const dur = $derived(app.reduceMotion ? 0 : 260);
// An info bubble owns its whole 2s life through a CSS rise-and-fade animation, so it takes // An info bubble owns its whole 3s life through a CSS animation — appear, a ~1s hold at rest,
// no Svelte enter/leave transition; an error keeps the fly-in / fade-out dwell behaviour. // then rise-and-fade — so it takes no Svelte enter/leave transition; an error keeps the fly-in /
// fade-out dwell behaviour.
const isInfo = $derived(app.toast?.kind !== 'error'); const isInfo = $derived(app.toast?.kind !== 'error');
</script> </script>
@@ -50,17 +51,19 @@
border-color: var(--danger); border-color: var(--danger);
color: var(--danger); color: var(--danger);
} }
/* An info bubble fades in, then drifts up by roughly the tab-bar height while fading out, /* An info bubble fades in, holds for ~1s at rest, then drifts up by roughly the tab-bar height
all within 2s; the X centring is preserved across the rise. */ while fading out, all within 3s; the X centring is preserved across the rise. The 8%/41%
stops are the appear (~240ms) and the end of the ~1s hold. */
.toast.rise { .toast.rise {
animation: toast-rise 2s ease-out forwards; animation: toast-rise 3s ease-out forwards;
} }
@keyframes toast-rise { @keyframes toast-rise {
0% { 0% {
opacity: 0; opacity: 0;
transform: translateX(-50%) translateY(8px); transform: translateX(-50%) translateY(8px);
} }
12% { 8%,
41% {
opacity: 1; opacity: 1;
transform: translateX(-50%) translateY(0); transform: translateX(-50%) translateY(0);
} }
@@ -69,16 +72,16 @@
transform: translateX(-50%) translateY(-56px); transform: translateX(-50%) translateY(-56px);
} }
} }
/* Reduced motion: the same 2s life, fade only — no upward travel. */ /* Reduced motion: the same 3s life (appear, ~1s hold, fade), fade only — no upward travel. */
.toast.rise-reduced { .toast.rise-reduced {
animation: toast-rise-reduced 2s ease-out forwards; animation: toast-rise-reduced 3s ease-out forwards;
} }
@keyframes toast-rise-reduced { @keyframes toast-rise-reduced {
0% { 0% {
opacity: 0; opacity: 0;
} }
12%, 8%,
70% { 41% {
opacity: 1; opacity: 1;
} }
100% { 100% {
+3 -3
View File
@@ -214,9 +214,9 @@ function goForeground(): void {
export function showToast(text: string, kind: Toast['kind'] = 'info'): void { export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
app.toast = { kind, text, seq: ++toastSeq }; app.toast = { kind, text, seq: ++toastSeq };
if (toastTimer) clearTimeout(toastTimer); if (toastTimer) clearTimeout(toastTimer);
// An info bubble lives exactly as long as its rise-and-fade animation (2s); an error // An info bubble lives exactly as long as its appear + ~1s hold + rise-and-fade animation (3s);
// dwells longer (4s) so it is not missed. // an error dwells longer (4s) so it is not missed.
toastTimer = setTimeout(() => (app.toast = null), kind === 'error' ? 4000 : 2000); toastTimer = setTimeout(() => (app.toast = null), kind === 'error' ? 4000 : 3000);
} }
/** dismissToast hides the current toast at once — a tap on the bubble dismisses it. */ /** dismissToast hides the current toast at once — a tap on the bubble dismisses it. */