feat: update authorization animation slightly

This commit is contained in:
Elias Schneider
2026-10-01 21:28:22 +02:00
parent 1a91eaa98c
commit 1f67bb7493
2 changed files with 242 additions and 78 deletions
+102 -39
View File
@@ -1,43 +1,106 @@
<script lang="ts">
import { mode } from 'mode-watcher';
interface Props {
[key: string]: any;
<script module lang="ts">
type Point = [number, number];
// Two pen passes over the same loop, slightly offset so the arrow reads as hand-drawn
// Each pass is a chain of cubic curves: a start point followed by three points per curve
const STROKES: Point[][] = [
[
[-1.1, 0.2],
[27.4, 0.1],
[60.2, 2.2],
[77.8, -4.4],
[93.1, -9.9],
[94.2, -28.5],
[81.1, -31.8],
[65.7, -35.1],
[57, -16.4],
[67.9, -3.3],
[76.7, 7.7],
[98.6, 5.5],
[120.5, -1.1],
[135.8, -5.5],
[149, -3.3],
[159, -1.9]
],
[
[0.5, -0.6],
[28.8, -0.9],
[61.6, 1.1],
[79.1, -5.6],
[94.6, -11.4],
[95.2, -29.3],
[81.9, -32.6],
[66.4, -35.6],
[56.1, -17.6],
[66.6, -4.2],
[75.6, 8.6],
[99.4, 6.6],
[121.2, -0.3],
[136.6, -4.4],
[150.1, -2.6],
[159.9, -2.4]
]
];
// Both arms of the head are drawn twice as well and meet at the end of the second pass
const HEAD =
'M137.15 3.58 C144.96 0.13, 151.62 -1.33, 159.9 -2.4 M137.15 3.58 C143.85 1.87, 149.96 -0.23, 159.9 -2.4 M138.63 -12.45 C146.01 -10.77, 152.19 -7.08, 159.9 -2.4 M138.63 -12.45 C144.94 -9.33, 150.61 -6.62, 159.9 -2.4';
const TIP = STROKES[1][STROKES[1].length - 1];
const lerp = (a: Point, b: Point, t: number): Point => [
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t
];
// Pull a pass towards the straight line between its ends, with both ends squeezed towards the middle
function straighten(points: Point[], taut: number, squeeze: number) {
const first = points[0];
const last = points[points.length - 1];
const middle = lerp(first, last, 0.5);
const start = lerp(first, middle, squeeze);
const end = lerp(last, middle, squeeze);
return points.map((point, i) => lerp(point, lerp(start, end, i / (points.length - 1)), taut));
}
let { ...rest }: Props = $props();
function toPath([start, ...curves]: Point[]) {
let d = `M${start}`;
for (let i = 0; i < curves.length; i += 3) {
d += ` C${curves[i]} ${curves[i + 1]} ${curves[i + 2]}`;
}
return d;
}
</script>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 181.0484047549312 72.61928578581296"
class={rest.class}
>
<g stroke-linecap="round"
><g
transform="translate(11.145277371630073 36.74839942056428) rotate(0 79.30063906887085 0.9091689232315048)"
><path
d="M-1.15 0.32 C11.9 0.63, 64.54 5.67, 78.54 2.21 C92.54 -1.25, 87.24 -16.24, 82.86 -20.46 C78.48 -24.68, 53.29 -30.73, 52.26 -23.11 C51.23 -15.49, 66.97 22.37, 76.69 25.25 C86.4 28.14, 96.82 -0.58, 110.54 -5.8 C124.26 -11.02, 151.22 -5.98, 159.03 -6.04 M0.45 -0.56 C13.84 -0.08, 66.55 6.8, 80.7 3.71 C94.85 0.61, 90.01 -14.87, 85.35 -19.14 C80.69 -23.4, 54.44 -29.38, 52.74 -21.91 C51.04 -14.43, 65.36 23.23, 75.14 25.72 C84.92 28.21, 97.29 -1.48, 111.42 -6.95 C125.55 -12.43, 151.9 -7.26, 159.9 -7.14"
stroke={mode.current == 'dark' ? 'white' : 'black'}
stroke-width="2"
fill="none"
></path></g
><g
transform="translate(11.145277371630073 36.74839942056428) rotate(0 79.30063906887085 0.9091689232315048)"
><path
d="M137.15 -1.16 C144.96 -4.61, 151.62 -6.07, 159.9 -7.14 M137.15 -1.16 C143.85 -2.87, 149.96 -4.97, 159.9 -7.14"
stroke={mode.current == 'dark' ? 'white' : 'black'}
stroke-width="2"
fill="none"
></path></g
><g
transform="translate(11.145277371630073 36.74839942056428) rotate(0 79.30063906887085 0.9091689232315048)"
><path
d="M138.63 -17.19 C146.01 -15.51, 152.19 -11.82, 159.9 -7.14 M138.63 -17.19 C144.94 -14.07, 150.61 -11.36, 159.9 -7.14"
stroke={mode.current == 'dark' ? 'white' : 'black'}
stroke-width="2"
fill="none"
></path></g
></g
><mask></mask></svg
>
<script lang="ts">
import { mode } from 'mode-watcher';
let {
class: className,
taut = 0,
squeeze = 0
}: {
class?: string;
// How far the arrow has been pulled straight, from 0 for the drawn loop to 1 for a straight line
taut?: number;
// How far both ends have moved towards the middle, from 0 at rest to 1 where they meet
squeeze?: number;
} = $props();
const strokes = $derived(STROKES.map((points) => straighten(points, taut, squeeze)));
const tip = $derived(strokes[1][strokes[1].length - 1]);
</script>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 181.05 72.62" class={className}>
<g
transform="translate(11.145 36.748)"
stroke={mode.current == 'dark' ? 'white' : 'black'}
stroke-width="2"
stroke-linecap="round"
fill="none"
>
{#each strokes as points, i (i)}
<path d={toPath(points)}></path>
{/each}
<path d={HEAD} transform="translate({tip[0] - TIP[0]} {tip[1] - TIP[1]})"></path>
</g>
</svg>
@@ -4,9 +4,15 @@
import ConnectArrow from '$lib/icons/connect-arrow.svelte';
import CrossAnimated from '$lib/icons/cross-animated.svelte';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { OidcClientMetaData } from '$lib/types/oidc.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { mode } from 'mode-watcher';
import { untrack } from 'svelte';
import { cubicIn, cubicOut } from 'svelte/easing';
import { prefersReducedMotion, Tween } from 'svelte/motion';
type Outcome = 'success' | 'error';
const {
success,
@@ -18,58 +24,153 @@
client?: OidcClientMetaData;
} = $props();
let animationDone = $state(false);
// Each tile travels this far so they meet in the middle
const TRAVEL = 108;
// The tiles start 152px apart, so they touch once each has covered this share of its travel
const CONTACT = 152 / (2 * TRAVEL);
// Kept below the previous 500ms slide so the animation never delays the redirect
const PULL_MS = 350;
$effect(() => {
if (success || error) {
setTimeout(() => (animationDone = true), client ? 500 : 0);
} else {
animationDone = false;
}
});
const SQUASH = [
{ transform: 'scale(1.18, .84)' },
{ transform: 'scale(.94, 1.06)', offset: 0.45 },
{ transform: 'scale(1)' }
];
const SHAKE = [
{ transform: 'scale(1.18, .84)' },
{ transform: 'scale(.94, 1.06)', offset: 0.2 },
{ transform: 'scale(1)', offset: 0.3 },
{ transform: 'translateX(-7px)', offset: 0.44 },
{ transform: 'translateX(6px)', offset: 0.58 },
{ transform: 'translateX(-4px)', offset: 0.72 },
{ transform: 'translateX(2px)', offset: 0.86 },
{ transform: 'translateX(0)' }
];
const isLightMode = $derived(mode.current === 'light');
// The tiles accelerate into each other while the arrow snaps taut during the first third of the pull
const pull = new Tween(0, { duration: PULL_MS });
const travelled = $derived(cubicIn(pull.current));
const taut = $derived(cubicOut(Math.min(1, pull.current / 0.32)));
let result = $state<Outcome | null>(null);
let animated = $state(false);
let resultTile = $state<HTMLDivElement>();
let hasMounted = false;
$effect(() => {
const outcome = success ? 'success' : error ? 'error' : null;
untrack(() => show(outcome));
});
async function show(outcome: Outcome | null) {
// An outcome that is already set when the page loads is shown without replaying the animation
const animate =
hasMounted && !$appConfigStore.disableAnimations && !prefersReducedMotion.current;
hasMounted = true;
resultTile?.getAnimations().forEach((animation) => animation.cancel());
result = null;
if (!outcome) {
pull.set(0, { duration: 0 });
return;
}
// The pull only starts from rest, so an outcome that arrives mid-pull jumps straight to the merged tile
// Any newer outcome interrupts the pull, and an interrupted tween never resolves, so a stale outcome is never shown
if (animate && pull.current === 0) await pull.set(1);
else pull.set(1, { duration: 0 });
animated = animate;
result = outcome;
if (animate) {
resultTile?.animate(outcome === 'success' ? SQUASH : SHAKE, {
duration: outcome === 'success' ? 380 : 620,
easing: 'ease-out'
});
}
}
</script>
<div class="flex justify-center gap-3">
<div
class=" bg-muted transition-transform rounded-2xl p-3 duration-500 ease-in {success || error
? 'translate-x-[108px]'
: ''}"
class="bg-muted rounded-2xl p-3"
style:transform="translateX({TRAVEL * travelled}px) scale({1 - 0.15 * travelled})"
style:opacity={Math.min(1, (1 - travelled) / 0.15)}
>
<Logo class="size-10" animate={false} />
</div>
<ConnectArrow
class="h-w-32 w-32 transition-opacity duration-500 {success || error
? 'opacity-0'
: 'opacity-100 delay-300'}"
class="w-32 {travelled >= CONTACT ? 'invisible' : ''}"
{taut}
squeeze={Math.min(1, travelled / CONTACT)}
/>
<div
class="rounded-2xl p-3 [transition:translate_500ms_ease-in,background-color_200ms] {success ||
error
? '-translate-x-[108px]'
: ''} {animationDone ? (success ? 'bg-green-200' : 'bg-red-200') : 'bg-muted'}"
>
{#if animationDone && success}
<div class="flex size-10 items-center justify-center">
<CheckmarkAnimated class="size-7" />
<div style:transform="translateX({-TRAVEL * travelled}px)">
<div
bind:this={resultTile}
class="bg-muted relative overflow-hidden rounded-2xl p-3"
class:animated
>
<!-- The outcome colour floods in from the edge where the tiles collided -->
<div
class="fill absolute inset-0 {result === 'error' ? 'bg-red-200' : 'bg-green-200'}"
class:flooded={result}
></div>
<div class="client relative size-10" class:faded={result}>
{#if client?.hasLogo}
<img
class="aspect-square size-10 object-contain"
src={cachedOidcClientLogo.getUrl(client.id, isLightMode)}
draggable={false}
alt={m.client_logo()}
/>
{:else if client?.name}
<div class="flex size-10 items-center justify-center text-3xl font-bold">
{client.name.charAt(0).toUpperCase()}
</div>
{/if}
</div>
{:else if animationDone && error}
<div class="flex size-10 items-center justify-center">
<CrossAnimated class="size-5" />
</div>
{:else if client?.hasLogo}
<img
class="aspect-square size-10 object-contain"
src={cachedOidcClientLogo.getUrl(client.id, isLightMode)}
draggable={false}
alt={m.client_logo()}
/>
{:else if client?.name}
<div class="flex size-10 items-center justify-center text-3xl font-bold">
{client.name.charAt(0).toUpperCase()}
</div>
{/if}
{#if result}
<div class="absolute inset-0 flex items-center justify-center">
{#if result === 'success'}
<CheckmarkAnimated class="size-7" />
{:else}
<CrossAnimated class="size-5" />
{/if}
</div>
{/if}
</div>
</div>
</div>
<style>
.fill {
clip-path: circle(0% at 0% 50%);
}
.fill.flooded {
clip-path: circle(150% at 0% 50%);
}
.client.faded {
opacity: 0;
scale: 0.6;
}
/* Only an outcome reached through the animation transitions in, one present on page load appears at once */
.animated .fill.flooded {
transition: clip-path 340ms cubic-bezier(0.3, 0, 0.2, 1);
}
.animated .client {
transition:
opacity 160ms 40ms,
scale 160ms 40ms;
}
</style>