feat: 增强登录页交互式 3D Logo (#536)

* feat(login): add interactive optical logo lab

* feat(login): add prismatic logo component

* fix(login): refine optical logo colors and rendering

* feat(login): add matte logo material and sizing

* feat(login): add logo palette toggle

* feat(login): add configurable logo presets

* fix(login): ignore lab route after authentication

* fix(login): keep prism idle motion
This commit is contained in:
InfinityPacer
2026-07-17 12:33:26 +08:00
committed by GitHub
parent 5676e0b9b1
commit 4378c4843b
6 changed files with 4026 additions and 67 deletions

View File

@@ -57,6 +57,7 @@
"express": "^4.21.2",
"express-http-proxy": "^2.1.1",
"gridstack": "^12.6.0",
"gsap": "^3.15.0",
"http-proxy-middleware": "^3.0.0",
"js-cookie": "^3.0.5",
"lodash-es": "^4.17.21",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,247 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import logoUrl from '@images/logo.svg'
const props = withDefaults(
defineProps<{
animate?: boolean
intensity?: number
}>(),
{
animate: true,
intensity: 45,
},
)
const rootRef = ref<HTMLSpanElement | null>(null)
const logoMaskStyle = computed(() => ({
'--logo-mask': `url("${logoUrl}")`,
'--prism-intensity': Math.min(1, Math.max(0, props.intensity / 100)),
}))
let pointerFrame: number | null = null
let pendingPointerX = 0.5
let pendingPointerY = 0.42
/** 将指针位置映射为同一套棱镜反射和轻微空间倾角。 */
function renderPointerResponse() {
pointerFrame = null
const root = rootRef.value
if (!root) return
root.style.setProperty('--logo-light-x', `${(pendingPointerX * 100).toFixed(2)}%`)
root.style.setProperty('--logo-light-y', `${(pendingPointerY * 100).toFixed(2)}%`)
root.style.setProperty('--logo-tilt-x', `${((0.5 - pendingPointerY) * 7).toFixed(2)}deg`)
root.style.setProperty('--logo-tilt-y', `${((pendingPointerX - 0.5) * 9).toFixed(2)}deg`)
}
function queuePointerResponse() {
if (pointerFrame === null) pointerFrame = window.requestAnimationFrame(renderPointerResponse)
}
function handlePointerMove(event: PointerEvent) {
if (event.pointerType === 'touch') return
const bounds = rootRef.value?.getBoundingClientRect()
if (!bounds?.width || !bounds.height) return
pendingPointerX = Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width))
pendingPointerY = Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height))
queuePointerResponse()
}
function resetPointerResponse() {
pendingPointerX = 0.5
pendingPointerY = 0.42
queuePointerResponse()
}
onBeforeUnmount(() => {
if (pointerFrame !== null) window.cancelAnimationFrame(pointerFrame)
})
</script>
<template>
<span
ref="rootRef"
class="prismatic-logo"
:class="{ 'prismatic-logo--animated': props.animate }"
:style="logoMaskStyle"
role="img"
aria-label="MoviePilot"
@pointermove="handlePointerMove"
@pointerleave="resetPointerResponse"
>
<img :src="logoUrl" class="prismatic-logo__base" alt="" draggable="false" aria-hidden="true" />
<span class="prismatic-logo__spectrum" aria-hidden="true" />
<span class="prismatic-logo__specular" aria-hidden="true" />
<span class="prismatic-logo__reveal" aria-hidden="true" />
</span>
</template>
<style scoped lang="scss">
.prismatic-logo {
--logo-light-x: 50%;
--logo-light-y: 42%;
--logo-tilt-x: 0deg;
--logo-tilt-y: 0deg;
position: relative;
display: grid;
isolation: isolate;
block-size: 100%;
inline-size: 100%;
place-items: center;
transform: perspective(520px) rotateX(var(--logo-tilt-x)) rotateY(var(--logo-tilt-y));
transform-style: preserve-3d;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.prismatic-logo--animated {
animation: prismatic-logo-enter 620ms cubic-bezier(0.16, 1, 0.3, 1) 80ms backwards;
}
.prismatic-logo::before {
position: absolute;
z-index: -1;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(var(--v-theme-primary), 0.34),
rgba(var(--v-theme-primary), 0.1) 44%,
transparent 72%
);
content: '';
filter: blur(13px);
inset: 17%;
opacity: calc(0.36 + var(--prism-intensity) * 0.56);
transform: translate3d(0, 8px, -16px) scaleX(1.18);
}
.prismatic-logo__base,
.prismatic-logo__spectrum,
.prismatic-logo__specular,
.prismatic-logo__reveal {
position: absolute;
display: block;
block-size: 100%;
inline-size: 100%;
inset: 0;
pointer-events: none;
user-select: none;
}
.prismatic-logo__base {
filter:
drop-shadow(0 8px 12px rgba(24, 8, 52, 0.3))
drop-shadow(0 0 10px rgba(139, 92, 246, 0.22));
object-fit: contain;
padding: 7px;
transform: translateZ(10px);
}
.prismatic-logo__spectrum,
.prismatic-logo__specular,
.prismatic-logo__reveal {
-webkit-mask: var(--logo-mask) center / calc(100% - 14px) calc(100% - 14px) no-repeat;
mask: var(--logo-mask) center / calc(100% - 14px) calc(100% - 14px) no-repeat;
}
.prismatic-logo__spectrum {
background:
radial-gradient(
circle at var(--logo-light-x) var(--logo-light-y),
rgba(255, 255, 255, 0.95),
rgba(210, 188, 255, 0.66) 13%,
transparent 36%
),
conic-gradient(
from 218deg at var(--logo-light-x) var(--logo-light-y),
rgba(255, 105, 210, 0.72),
rgba(117, 212, 255, 0.72),
rgba(177, 139, 255, 0.82),
rgba(255, 214, 246, 0.7),
rgba(255, 105, 210, 0.72)
);
mix-blend-mode: screen;
opacity: calc(0.22 + var(--prism-intensity) * 0.58);
transform: translateZ(14px);
}
.prismatic-logo__specular {
background: radial-gradient(
ellipse 28% 20% at var(--logo-light-x) var(--logo-light-y),
rgba(255, 255, 255, 0.96),
rgba(232, 219, 255, 0.52) 28%,
transparent 72%
);
mix-blend-mode: screen;
opacity: calc(0.3 + var(--prism-intensity) * 0.66);
transform: translateZ(18px);
}
.prismatic-logo__reveal {
background: linear-gradient(
112deg,
transparent 30%,
rgba(255, 255, 255, 0.18) 39%,
rgba(255, 255, 255, 0.98) 48%,
rgba(125, 211, 252, 0.62) 54%,
transparent 66%
);
background-position: 100% 50%;
background-size: 300% 100%;
mix-blend-mode: screen;
opacity: 0;
transform: translateZ(20px);
}
.prismatic-logo--animated .prismatic-logo__reveal {
animation: prismatic-logo-reveal 840ms cubic-bezier(0.2, 0.76, 0.18, 1) 160ms both;
}
@keyframes prismatic-logo-enter {
0% {
opacity: 0;
transform: perspective(520px) translateY(10px) scale(0.82) rotateX(-8deg) rotateY(10deg);
}
62% {
opacity: 1;
transform: perspective(520px) translateY(-2px) scale(1.025) rotateX(1deg) rotateY(-1deg);
}
100% {
opacity: 1;
transform: perspective(520px) translateY(0) scale(1) rotateX(0) rotateY(0);
}
}
@keyframes prismatic-logo-reveal {
0% {
background-position: 100% 50%;
opacity: 0;
}
24% {
opacity: 1;
}
100% {
background-position: 0% 50%;
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.prismatic-logo,
.prismatic-logo__reveal {
animation: none;
transform: none;
transition: none;
}
.prismatic-logo__reveal {
display: none;
}
}
</style>

View File

@@ -4,7 +4,7 @@ import { useAuthStore, useUserStore } from '@/stores'
import { authState, userState } from '@/stores/types'
import api from '@/api'
import router from '@/router'
import MetalLogo3D from '@/components/misc/MetalLogo3D.vue'
import OpticalLogoLab from '@/components/misc/OpticalLogoLab.vue'
import { bufferToBase64Url, base64UrlToUint8Array, urlBase64ToUint8Array } from '@/@core/utils/navigator'
import { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'
import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
@@ -16,6 +16,42 @@ import { loadRemoteComponentFromModule, type RemoteModule } from '@/utils/federa
const LoginMfaDialog = defineAsyncComponent(() => import('@/components/dialog/LoginMfaDialog.vue'))
const loginRootRef = ref<HTMLElement | null>(null)
let cardLightFrame: number | null = null
let pendingCardLightX = 0.5
let pendingCardLightY = 0
let pendingCardLightEnergy = 0
/** 卡片顶部反射与指针共用光源位置,避免通过响应式状态触发页面重渲染。 */
function renderCardLight() {
cardLightFrame = null
const root = loginRootRef.value
root?.style.setProperty('--login-card-light-x', `${(pendingCardLightX * 100).toFixed(2)}%`)
root?.style.setProperty('--login-card-light-y', `${(pendingCardLightY * 100).toFixed(2)}%`)
root?.style.setProperty('--login-card-highlight-alpha', (0.035 + pendingCardLightEnergy * 0.08).toFixed(3))
root?.style.setProperty('--login-card-primary-alpha', (0.018 + pendingCardLightEnergy * 0.04).toFixed(3))
root?.style.setProperty('--login-card-top-prism-alpha', (0.2 + pendingCardLightEnergy * 0.32).toFixed(3))
}
function handlePointerLight(event: PointerEvent) {
if (event.pointerType === 'touch') return
const bounds = loginRootRef.value?.querySelector<HTMLElement>('.login-card')?.getBoundingClientRect()
if (!bounds?.width || !bounds.height) return
pendingCardLightX = Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width))
pendingCardLightY = Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height))
const centerDistance = Math.hypot(pendingCardLightX - 0.5, pendingCardLightY - 0.5)
pendingCardLightEnergy = Math.max(0.16, 1 - centerDistance * 1.2)
if (cardLightFrame === null) cardLightFrame = window.requestAnimationFrame(renderCardLight)
}
function resetPointerLight() {
pendingCardLightX = 0.5
pendingCardLightY = 0
pendingCardLightEnergy = 0
if (cardLightFrame === null) cardLightFrame = window.requestAnimationFrame(renderCardLight)
}
// 国际化
const { t, te } = useI18n()
@@ -487,16 +523,19 @@ async function subscribeForPushNotifications() {
// 登录后处理
async function afterLogin(superuser: boolean, userPayload: userState, filteredMenus: any[]) {
const originalPath = authStore.originalPath
authStore.setOriginalPath(null)
// 如果需要显示设置向导,跳转到设置向导页面
if (userPayload.wizard) {
router.push('/setup-wizard')
await router.push('/setup-wizard')
} else {
// 如果有原始路径,优先跳转到原始路径
if (authStore.originalPath && authStore.originalPath !== '/') {
router.push(authStore.originalPath)
// 原始目标是一次性状态,持久化的旧登录页目标不得重新进入认证流程。
if (originalPath && originalPath !== '/' && router.resolve(originalPath).path !== '/login') {
await router.push(originalPath)
} else {
// 跳转到第一个有权限的菜单
router.push(filteredMenus[0].to)
await router.push(filteredMenus[0].to)
}
}
@@ -675,6 +714,8 @@ async function initConditionalPasskey() {
// 组件卸载时清理
onUnmounted(() => {
if (cardLightFrame !== null) window.cancelAnimationFrame(cardLightFrame)
if (conditionalAbortController) {
conditionalAbortController.abort()
conditionalAbortController = null
@@ -688,7 +729,12 @@ onUnmounted(() => {
<template>
<!-- 登录页面容器 -->
<div class="login-root">
<div
ref="loginRootRef"
class="login-root"
@pointermove="handlePointerLight"
@pointerleave="resetPointerLight"
>
<!-- 装饰性背景光晕 -->
<div class="login-bg-decor" aria-hidden="true">
<div class="login-orb login-orb--1" />
@@ -726,17 +772,20 @@ onUnmounted(() => {
<!-- 登录表单 -->
<div v-if="!mfaDialog" class="auth-wrapper d-flex align-center justify-center">
<VCard
class="auth-card login-card glass-effect pa-7 pa-sm-9 w-full h-full login-card--enter"
class="auth-card login-card glass-effect no-blur pa-7 pa-sm-9 w-full h-full login-card--enter"
max-width="24rem"
flat
>
<div class="login-card__glass" aria-hidden="true">
<span class="login-card__glass-caustic" />
</div>
<!-- 卡片头部Logo + 标题 + 欢迎语 -->
<div class="login-head">
<div class="login-logo-wrapper">
<MetalLogo3D class="login-logo" />
</div>
<h1 class="login-title">MoviePilot</h1>
<p class="login-subtitle">{{ t('login.welcomeBack') || 'Welcome Back' }}</p>
<OpticalLogoLab class="login-logo" :locale="currentLocale">
<h1 class="login-title">MoviePilot</h1>
<p class="login-subtitle">{{ t('login.welcomeBack') || 'Welcome Back' }}</p>
</OpticalLogoLab>
</div>
<VCardText class="login-body">
@@ -897,15 +946,38 @@ onUnmounted(() => {
/* ===================== 布局根容器 ===================== */
.login-root {
--login-card-light-x: 50%;
--login-card-light-y: 0%;
--login-card-highlight-alpha: 0.035;
--login-card-primary-alpha: 0.018;
--login-card-top-prism-alpha: 0.2;
--optical-glass-x: 50%;
--optical-glass-y: 22%;
--optical-glass-blur: 24px;
--optical-glass-saturate: 146%;
--optical-glass-contrast: 104%;
--optical-glass-glow-opacity: 0.36;
--optical-glass-caustic-opacity: 0.28;
--optical-glass-caustic-scale: 0.9;
--optical-glass-pulse-blur: 14px;
--optical-glass-shift-x: 0px;
--optical-glass-shift-y: 0px;
--optical-glass-rotation: -5deg;
--optical-glass-scale: 1;
--optical-glass-caustic-highlight-alpha: 0.16;
--optical-glass-caustic-primary-alpha: 0.12;
position: relative;
display: flex;
overflow: hidden;
overflow-x: hidden;
overflow-y: auto;
flex-direction: column;
align-items: center;
justify-content: center;
isolation: isolate;
min-block-size: 100vh;
min-block-size: 100dvh;
padding-block: calc(env(safe-area-inset-top, 0px) + 24px) calc(env(safe-area-inset-bottom, 0px) + 24px);
}
/* ===================== 装饰性背景光晕 ===================== */
@@ -1006,7 +1078,7 @@ onUnmounted(() => {
.auth-wrapper {
position: relative;
z-index: 2;
overflow: hidden;
overflow: visible;
block-size: auto;
inline-size: 100%;
padding-inline: 16px;
@@ -1016,43 +1088,115 @@ onUnmounted(() => {
.login-card {
position: relative;
z-index: 1;
overflow: hidden;
border: none !important;
border-radius: var(--app-surface-radius, 20px) !important;
box-shadow:
0 20px 50px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.12),
0 8px 20px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.06),
0 0 0 1px rgba(var(--v-theme-primary), 0.04) !important;
transition: box-shadow 300ms ease;
box-shadow: 0 20px 54px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.12) !important;
> :not(.login-card__glass) {
position: relative;
z-index: 2;
}
/* 顶部高光线,营造立体感 */
&::before {
position: absolute;
z-index: 1;
border-radius: inherit;
background: linear-gradient(90deg, transparent 10%, rgba(255, 255, 255, 35%) 50%, transparent 90%);
z-index: 4;
border-radius: 999px;
background:
radial-gradient(
ellipse at center,
rgba(255, 255, 255, 0.92),
rgba(232, 219, 255, 0.48) 28%,
transparent 72%
),
linear-gradient(
90deg,
transparent,
rgba(117, 212, 255, 0.16) 30%,
rgba(177, 139, 255, 0.22) 50%,
rgba(255, 105, 210, 0.12) 70%,
transparent
);
block-size: 1px;
content: '';
filter: drop-shadow(0 1px 3px rgba(var(--v-theme-primary), 0.16));
inset-block-start: 0;
inset-inline: 0;
inset-inline-start: clamp(44px, var(--login-card-light-x), calc(100% - 44px));
inline-size: 88px;
mix-blend-mode: screen;
opacity: var(--login-card-top-prism-alpha);
pointer-events: none;
transform: translateX(-50%);
}
}
/* 登录卡片自身承载固定磨砂效果,避免跟随透明主题设置变化。 */
/* 登录卡片拥有独立光学表面,不跟随透明主题的全局模糊开关。 */
.glass-effect {
backdrop-filter: blur(28px) saturate(170%) !important;
background: rgba(var(--v-theme-surface), 0.75) !important;
backdrop-filter: none !important;
background: transparent !important;
}
/* 深色主题上叠一条更亮的描边,区分背景 */
:deep(.v-theme--dark) .login-card,
:deep(.v-theme--purple) .login-card,
:deep(.v-theme--transparent) .login-card {
border: 1px solid rgba(255, 255, 255, 8%) !important;
.login-card__glass {
position: absolute;
z-index: 0;
overflow: hidden;
border-radius: inherit;
backdrop-filter: blur(var(--optical-glass-blur)) saturate(var(--optical-glass-saturate))
contrast(var(--optical-glass-contrast));
background:
radial-gradient(
180px 150px at var(--login-card-light-x) var(--login-card-light-y),
rgba(255, 255, 255, var(--login-card-highlight-alpha)),
rgba(var(--v-theme-primary), var(--login-card-primary-alpha)) 44%,
transparent 76%
),
linear-gradient(145deg, rgba(255, 255, 255, 0.075), transparent 34%),
rgba(var(--v-theme-surface), 0.7);
inset: 0;
pointer-events: none;
transform: translateZ(0);
}
:deep(.v-theme--light) .login-card {
border: 1px solid rgba(255, 255, 255, 65%) !important;
.login-card__glass::before {
position: absolute;
border-radius: 44% 56% 61% 39% / 42% 39% 61% 58%;
background:
radial-gradient(circle at 36% 32%, rgba(255, 255, 255, 0.2), transparent 24%),
conic-gradient(
from 218deg,
transparent,
rgba(var(--v-theme-primary), 0.13),
transparent 38%,
rgba(255, 255, 255, 0.09),
transparent 72%
);
content: '';
filter: blur(var(--optical-glass-pulse-blur));
inset: -28%;
opacity: var(--optical-glass-glow-opacity);
transform: translate(var(--optical-glass-shift-x), var(--optical-glass-shift-y))
rotate(var(--optical-glass-rotation)) scale(var(--optical-glass-scale));
transition: opacity 220ms ease;
}
.login-card__glass-caustic {
position: absolute;
display: block;
border-radius: 50%;
background: radial-gradient(
ellipse,
rgba(255, 255, 255, var(--optical-glass-caustic-highlight-alpha)),
rgba(var(--v-theme-primary), var(--optical-glass-caustic-primary-alpha)) 34%,
transparent 72%
);
filter: blur(var(--optical-glass-pulse-blur));
inset-block-start: calc(var(--optical-glass-y) - 88px);
inset-inline-start: calc(var(--optical-glass-x) - 112px);
block-size: 176px;
inline-size: 224px;
opacity: var(--optical-glass-caustic-opacity);
pointer-events: none;
transform: scale(var(--optical-glass-caustic-scale));
}
/* ===================== 卡片头部 ===================== */
@@ -1060,22 +1204,13 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
inline-size: 100%;
margin-block-end: 12px;
text-align: center;
}
.login-logo-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
animation: logo-enter 700ms cubic-bezier(0.16, 1, 0.3, 1) 100ms both;
margin-block-end: 8px;
}
.login-logo {
flex: none;
inline-size: 100%;
}
.login-title {
@@ -1089,6 +1224,8 @@ onUnmounted(() => {
line-height: 1.2;
-webkit-text-fill-color: transparent;
text-transform: uppercase;
user-select: none;
-webkit-user-select: none;
}
.login-subtitle {
@@ -1104,6 +1241,7 @@ onUnmounted(() => {
/* ===================== 卡片主体 ===================== */
.login-body {
animation: text-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) 420ms both;
padding-block: 8px !important;
}
@@ -1111,14 +1249,17 @@ onUnmounted(() => {
.native-login-field {
position: relative;
display: flex;
overflow: hidden;
align-items: center;
border: 1px solid rgba(var(--v-border-color), 0.38);
min-block-size: 56px;
border-radius: 12px;
background: transparent;
background: rgba(var(--v-theme-surface), 0.13);
backdrop-filter: blur(10px) saturate(118%);
transition:
border-color 150ms ease,
box-shadow 150ms ease;
box-shadow 150ms ease,
background 220ms ease;
}
.native-login-field:focus-within {
@@ -1136,6 +1277,8 @@ onUnmounted(() => {
}
.native-login-field__input {
position: relative;
z-index: 1;
display: block;
border: 0;
appearance: none;
@@ -1385,6 +1528,7 @@ onUnmounted(() => {
letter-spacing: 0.03em;
margin-block-start: 14px;
opacity: 0.75;
animation: text-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) 520ms both;
}
.login-version {
@@ -1399,30 +1543,16 @@ onUnmounted(() => {
@keyframes login-enter {
0% {
filter: blur(4px);
opacity: 0;
transform: translateY(20px) scale(0.97);
transform: translateY(12px) scale(0.985);
}
100% {
filter: blur(0);
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes logo-enter {
0% {
opacity: 0;
transform: scale(0.8) translateY(10px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes text-enter {
0% {
opacity: 0;
@@ -1438,10 +1568,11 @@ onUnmounted(() => {
/* ===================== 无障碍:尊重减少动态偏好 ===================== */
@media (prefers-reduced-motion: reduce) {
.login-card--enter,
.login-logo-wrapper,
.login-body,
.login-foot,
.login-title,
.login-subtitle {
animation-duration: 1ms !important;
animation: none !important;
}
.login-submit {
@@ -1455,6 +1586,43 @@ onUnmounted(() => {
.login-orb {
animation: none !important;
}
.login-card__glass::before,
.login-card__glass-caustic {
transform: none !important;
transition: none !important;
}
}
@media (prefers-reduced-transparency: reduce) {
.login-card__glass,
.native-login-field {
backdrop-filter: none !important;
background: rgb(var(--v-theme-surface)) !important;
}
}
@media (prefers-contrast: more) {
.login-card__glass {
background: rgba(var(--v-theme-surface), 0.94);
}
.native-login-field {
border-color: rgba(var(--v-theme-on-surface), 0.68);
}
}
@supports not ((backdrop-filter: blur(1px))) {
.login-card__glass,
.native-login-field {
background: rgba(var(--v-theme-surface), 0.96) !important;
}
}
@media (height <= 760px) {
.login-root {
justify-content: flex-start;
}
}
/* ===================== 小屏适配 ===================== */

View File

@@ -318,8 +318,8 @@ router.beforeEach(async (to: any, from: any, next: any) => {
// 认证 Store
const authStore = useAuthStore()
// 总是记录非login路由
if (to.fullPath != '/login') authStore.originalPath = to.fullPath
// 登录页的实验参数不是登录后可恢复的业务目标。
if (to.path !== '/login') authStore.originalPath = to.fullPath
const isAuthenticated = authStore.token !== null
if (to.meta.requiresAuth && !isAuthenticated) {

View File

@@ -5364,6 +5364,11 @@ gridstack@^12.6.0:
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-12.6.0.tgz#acfd8c036b202304712c0562078c86ed2ab6e83f"
integrity sha512-dUrqsormSybFn/2P4Dz8AgprftKD5e/IiV7UmC0XLQU+G+/WtkAeFiCSNLoAGhPDXoJ/O61Xtj3gljY/Ds83yQ==
gsap@^3.15.0:
version "3.15.0"
resolved "https://registry.yarnpkg.com/gsap/-/gsap-3.15.0.tgz#7851baaffc77642f2db3b1749d3634f9b5a19d14"
integrity sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==
has-bigints@^1.0.2:
version "1.1.0"
resolved "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz"