mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-02 15:59:04 +02:00
feat: update authorization animation slightly
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user