managarten/packages/shared-auth-ui/src/pages/LoginPage.svelte
Till JS 2d11ba6248 refactor(auth): remove all Google/Apple social login code
No external auth providers to keep authentication fully self-sovereign
and avoid dependency on third-party services. Removes Google Sign-In,
Apple Sign-In components, utilities, endpoints, translations, and
mobile dependencies across all apps and shared packages.

Google/Apple integrations for data sync (Contacts import, Calendar sync)
are intentionally preserved as they serve a different purpose.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:12:30 +01:00

1131 lines
24 KiB
Svelte

<script lang="ts">
import type { Component, Snippet } from 'svelte';
import type { AuthResult } from '../types';
import { Check, Warning, Eye, EyeSlash, SignIn, Sun, Moon } from '@manacore/shared-icons';
/** Translation strings for the login page */
export interface LoginTranslations {
title: string;
subtitle: string;
emailPlaceholder: string;
passwordPlaceholder: string;
rememberMe: string;
forgotPassword: string;
signInButton: string;
signingIn: string;
success: string;
orDivider: string;
noAccount: string;
createAccount: string;
skipToForm: string;
showPassword: string;
hidePassword: string;
emailRequired: string;
emailInvalid: string;
passwordRequired: string;
signInFailed: string;
signInSuccess: string;
emailVerified?: string;
emailNotVerified?: string;
resendVerification?: string;
resendingVerification?: string;
verificationEmailSent?: string;
}
const defaultTranslations: LoginTranslations = {
title: 'Sign In',
subtitle: 'Sign in with your Mana account',
emailPlaceholder: 'Email',
passwordPlaceholder: 'Password',
rememberMe: 'Remember me',
forgotPassword: 'Forgot password?',
signInButton: 'Sign In',
signingIn: 'Signing in...',
success: 'Success!',
orDivider: 'or',
noAccount: "Don't have an account?",
createAccount: 'Create one',
skipToForm: 'Skip to login form',
showPassword: 'Show password',
hidePassword: 'Hide password',
emailRequired: 'Email is required',
emailInvalid: 'Please enter a valid email address',
passwordRequired: 'Password is required',
signInFailed: 'Sign in failed',
signInSuccess: 'Successfully signed in. Redirecting...',
emailVerified: 'Email successfully verified! Please sign in.',
emailNotVerified: 'Email not verified.',
resendVerification: 'Resend verification email',
resendingVerification: 'Sending...',
verificationEmailSent: 'Verification email sent! Please check your inbox.',
};
interface Props {
appName: string;
logo: Component<{ size?: number; color?: string }>;
primaryColor: string;
onSignIn: (email: string, password: string) => Promise<AuthResult>;
onResendVerification?: (email: string) => Promise<AuthResult>;
goto: (path: string) => void;
successRedirect?: string;
registerPath?: string;
forgotPasswordPath?: string;
lightBackground?: string;
darkBackground?: string;
appSlider?: Snippet;
headerControls?: Snippet;
translations?: Partial<LoginTranslations>;
/** Show email verified success banner */
verified?: boolean;
/** Pre-fill email field (e.g., after email verification) */
initialEmail?: string;
/** Pre-fill password field (for dev mode) */
initialPassword?: string;
/** App version string to display */
version?: string;
/** Build timestamp (ISO string) to display next to version */
buildTime?: string;
}
let {
appName,
logo: Logo,
primaryColor,
onSignIn,
onResendVerification,
goto,
successRedirect = '/dashboard',
registerPath = '/register',
forgotPasswordPath = '/forgot-password',
lightBackground = '#f5f5f5',
darkBackground = '#121212',
appSlider,
headerControls,
translations = {},
verified = false,
initialEmail = '',
initialPassword = '',
version = '',
buildTime = '',
}: Props = $props();
const t = $derived({ ...defaultTranslations, ...translations });
// Check if we're in development mode (early for state init)
const isDevMode = typeof import.meta !== 'undefined' && import.meta.env?.DEV;
// Local dev credentials (run `pnpm db:seed:dev` in mana-core-auth to create this user)
const DEV_EMAIL = 'dev@manacore.local';
const DEV_PASSWORD = 'devpassword123';
let loading = $state(false);
let error = $state<string | null>(null);
let errorField = $state<'email' | 'password' | 'general' | null>(null);
// In dev mode, pre-fill with test credentials
let email = $state(initialEmail || (isDevMode ? DEV_EMAIL : ''));
let password = $state(initialPassword || (isDevMode ? DEV_PASSWORD : ''));
let showPassword = $state(false);
let rememberMe = $state(false);
let showSuccess = $state(false);
let shakeError = $state(false);
let emailInput: HTMLInputElement;
let passwordInput: HTMLInputElement;
let successAnnouncement = $state('');
let showVerifiedBanner = $state(verified);
let showEmailNotVerified = $state(false);
let resendingVerification = $state(false);
let verificationEmailSent = $state(false);
// Theme state - can be toggled manually, defaults to system preference
let userThemePreference = $state<'light' | 'dark' | null>(null);
let systemIsDark = $state(
typeof window !== 'undefined'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false
);
// Effective dark mode based on user preference or system
let isDark = $derived(
userThemePreference !== null ? userThemePreference === 'dark' : systemIsDark
);
$effect(() => {
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
systemIsDark = mediaQuery.matches;
const listener = (e: MediaQueryListEvent) => (systemIsDark = e.matches);
mediaQuery.addEventListener('change', listener);
return () => mediaQuery.removeEventListener('change', listener);
}
});
function toggleTheme() {
if (userThemePreference === null) {
// First toggle: switch to opposite of system
userThemePreference = systemIsDark ? 'light' : 'dark';
} else {
// Subsequent toggles: just flip
userThemePreference = userThemePreference === 'dark' ? 'light' : 'dark';
}
}
$effect(() => {
// Focus password field if email is pre-filled, otherwise focus email
if (initialEmail && passwordInput) {
passwordInput.focus();
} else if (emailInput) {
emailInput.focus();
}
});
function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function triggerErrorShake() {
shakeError = true;
setTimeout(() => (shakeError = false), 500);
}
function setError(message: string, field: 'email' | 'password' | 'general' = 'general') {
error = message;
errorField = field;
triggerErrorShake();
setTimeout(() => {
if (field === 'email' && emailInput) emailInput.focus();
else if (field === 'password' && passwordInput) passwordInput.focus();
}, 100);
}
function clearError() {
error = null;
errorField = null;
}
async function handleLogin() {
loading = true;
clearError();
showEmailNotVerified = false;
verificationEmailSent = false;
if (!email) {
setError(t.emailRequired, 'email');
loading = false;
return;
}
if (!isValidEmail(email)) {
setError(t.emailInvalid, 'email');
loading = false;
return;
}
if (!password) {
setError(t.passwordRequired, 'password');
loading = false;
return;
}
const result = await onSignIn(email, password);
loading = false;
if (result.success) {
showSuccess = true;
successAnnouncement = t.signInSuccess;
setTimeout(() => goto(successRedirect), 600);
} else if (result.error === 'EMAIL_NOT_VERIFIED') {
showEmailNotVerified = true;
setError(t.emailNotVerified || 'Email not verified.', 'general');
} else {
setError(result.error || t.signInFailed, 'general');
}
}
async function handleResendVerification() {
if (!onResendVerification || !email || resendingVerification) return;
resendingVerification = true;
clearError();
const result = await onResendVerification(email);
resendingVerification = false;
if (result.success) {
verificationEmailSent = true;
showEmailNotVerified = false;
} else {
setError(result.error || t.signInFailed, 'general');
}
}
function skipToForm() {
if (emailInput) emailInput.focus();
}
function fillDevCredentials() {
email = DEV_EMAIL;
password = DEV_PASSWORD;
}
</script>
<svelte:head>
<title>Login - {appName}</title>
<meta name="theme-color" content={darkBackground} media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content={lightBackground} media="(prefers-color-scheme: light)" />
</svelte:head>
<button class="skip-link" onclick={skipToForm} type="button">
{t.skipToForm}
</button>
<div class="sr-only" aria-live="polite" aria-atomic="true">
{successAnnouncement}
</div>
<div
class="page-container"
class:dark={isDark}
class:light={!isDark}
style:--light-bg={lightBackground}
style:--dark-bg={darkBackground}
style:--primary-color={primaryColor}
>
<!-- Theme Toggle - Top Left -->
<button
type="button"
onclick={toggleTheme}
class="theme-toggle"
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{#if isDark}
<Sun size={20} weight="bold" />
{:else}
<Moon size={20} weight="bold" />
{/if}
</button>
{#if headerControls}
<div class="header-controls">
{@render headerControls()}
</div>
{/if}
<main class="main-content">
<!-- Logo Section -->
<div class="logo-section">
<button
type="button"
onclick={fillDevCredentials}
class="logo-button"
class:success-pulse={showSuccess}
style:border-color={showSuccess ? '#22c55e' : primaryColor}
aria-label="{appName} logo"
>
{#if showSuccess}
<Check size={55} class="text-green-500" />
{:else}
<Logo size={55} color={primaryColor} />
{/if}
</button>
<h1 class="app-name">{appName}</h1>
</div>
<!-- Form Section -->
<div class="form-section">
<div class="form-card" class:shake={shakeError}>
{#if showVerifiedBanner}
<div class="verified-banner" role="status" aria-live="polite">
<Check size={18} class="text-green-500 shrink-0" />
<p>{t.emailVerified}</p>
<button
type="button"
class="verified-banner-close"
onclick={() => (showVerifiedBanner = false)}
aria-label="Close"
>
&times;
</button>
</div>
{/if}
<div class="form-header">
<h2 class="form-title">{t.title}</h2>
<p class="form-subtitle">{t.subtitle}</p>
</div>
{#if verificationEmailSent}
<div class="verified-banner" role="status" aria-live="polite">
<Check size={18} class="text-green-500 shrink-0" />
<p>{t.verificationEmailSent}</p>
<button
type="button"
class="verified-banner-close"
onclick={() => (verificationEmailSent = false)}
aria-label="Close"
>
&times;
</button>
</div>
{/if}
{#if error}
<div class="error-message" id="form-error" role="alert" aria-live="assertive">
<Warning size={18} class="text-red-500 shrink-0" />
<div class="error-content">
<p>{error}</p>
{#if showEmailNotVerified && onResendVerification}
<button
type="button"
class="resend-link"
onclick={handleResendVerification}
disabled={resendingVerification}
style:color={primaryColor}
>
{resendingVerification ? t.resendingVerification : t.resendVerification}
</button>
{/if}
</div>
</div>
{/if}
<form
onsubmit={(e) => {
e.preventDefault();
handleLogin();
}}
aria-busy={loading}
>
<!-- Email -->
<div class="input-group">
<label for="email" class="sr-only">{t.emailPlaceholder}</label>
<input
id="email"
type="email"
bind:this={emailInput}
bind:value={email}
placeholder={t.emailPlaceholder}
required
autocomplete="email"
aria-invalid={errorField === 'email'}
class="input-field"
class:input-error={errorField === 'email'}
style:--ring-color={errorField === 'email' ? '#ef4444' : primaryColor}
/>
</div>
<!-- Password -->
<div class="input-group">
<label for="password" class="sr-only">{t.passwordPlaceholder}</label>
<div class="input-wrapper">
<input
id="password"
type={showPassword ? 'text' : 'password'}
bind:this={passwordInput}
bind:value={password}
placeholder={t.passwordPlaceholder}
required
autocomplete="current-password"
aria-invalid={errorField === 'password'}
class="input-field has-icon"
class:input-error={errorField === 'password'}
style:--ring-color={errorField === 'password' ? '#ef4444' : primaryColor}
/>
<button
type="button"
onclick={() => (showPassword = !showPassword)}
class="password-toggle"
aria-label={showPassword ? t.hidePassword : t.showPassword}
>
{#if showPassword}
<EyeSlash size={20} />
{:else}
<Eye size={20} />
{/if}
</button>
</div>
</div>
<!-- Remember & Forgot -->
<div class="options-row">
<label class="remember-label">
<input type="checkbox" bind:checked={rememberMe} style:accent-color={primaryColor} />
<span>{t.rememberMe}</span>
</label>
<button
type="button"
onclick={() => goto(forgotPasswordPath)}
class="forgot-link"
style:color={primaryColor}
>
{t.forgotPassword}
</button>
</div>
<!-- Submit -->
<button
type="submit"
disabled={loading || showSuccess}
class="submit-button"
style:background-color={showSuccess ? '#22c55e' : primaryColor + '60'}
style:border-color={showSuccess ? '#22c55e' : primaryColor}
>
{#if loading}
<svg
class="spinner"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" stroke-opacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round" />
</svg>
<span>{t.signingIn}</span>
{:else if showSuccess}
<Check size={20} />
<span>{t.success}</span>
{:else}
<SignIn size={20} />
<span>{t.signInButton}</span>
{/if}
</button>
</form>
<p class="register-link">
{t.noAccount}
<button type="button" onclick={() => goto(registerPath)} style:color={primaryColor}>
{t.createAccount}
</button>
</p>
</div>
</div>
</main>
{#if appSlider}
<footer class="app-slider-section">
{@render appSlider()}
</footer>
{/if}
{#if version}
<p class="version-label">
v{version}{#if buildTime}
· {new Date(buildTime).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})}
{new Date(buildTime).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})}{/if}
</p>
{/if}
</div>
<style>
:global(html, body) {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
.page-container {
display: flex;
flex-direction: column;
min-height: 100vh;
min-height: 100dvh;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
margin: 0;
padding: 0;
/* Dark mode default */
background-color: var(--dark-bg, #121212);
}
.page-container.light {
background-color: var(--light-bg, #f5f5f5);
}
.theme-toggle {
position: absolute;
top: 1rem;
left: 1rem;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
transition: all 0.2s ease;
}
.light .theme-toggle {
border-color: rgba(0, 0, 0, 0.2);
background: rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.7);
}
.theme-toggle:hover {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
.light .theme-toggle:hover {
background: rgba(0, 0, 0, 0.1);
color: #000;
}
.header-controls {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 50;
opacity: 0.6;
display: flex;
gap: 0.75rem;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
}
.logo-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem 1rem 1.5rem;
}
.logo-button {
width: 100px;
height: 100px;
border-radius: 50%;
border: 3px solid;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.75rem;
cursor: pointer;
transition: transform 0.2s;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
/* Dark mode default */
background-color: #000;
}
.light .logo-button {
background-color: #fff;
}
.logo-button:hover {
transform: scale(1.05);
}
.logo-button:active {
transform: scale(0.95);
}
.app-name {
font-size: 1.5rem;
font-weight: 600;
color: #fff;
}
.light .app-name {
color: #000;
}
.form-section {
flex: 1;
display: flex;
justify-content: center;
padding: 1rem 1rem 2rem;
}
.form-card {
width: 100%;
max-width: 400px;
border-radius: 1rem;
padding: 1.5rem;
border: 1px solid;
backdrop-filter: blur(10px);
/* Dark mode default */
background-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.1);
}
.light .form-card {
background-color: rgba(255, 255, 255, 0.7);
border-color: rgba(0, 0, 0, 0.1);
}
.form-header {
text-align: center;
margin-bottom: 1.5rem;
}
.form-title {
font-size: 1.25rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.form-subtitle {
font-size: 0.875rem;
margin-top: 0.5rem;
color: rgba(255, 255, 255, 0.6);
}
.light .form-title {
color: rgba(0, 0, 0, 0.9);
}
.light .form-subtitle {
color: rgba(0, 0, 0, 0.6);
}
.verified-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
margin-bottom: 1rem;
border-radius: 0.75rem;
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.3);
color: #22c55e;
font-size: 0.875rem;
position: relative;
}
.verified-banner-close {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #22c55e;
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem;
line-height: 1;
opacity: 0.7;
}
.verified-banner-close:hover {
opacity: 1;
}
.error-message {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
margin-bottom: 1rem;
border-radius: 0.75rem;
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
font-size: 0.875rem;
}
.error-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.resend-link {
background: none;
border: none;
cursor: pointer;
font-weight: 500;
font-size: 0.875rem;
padding: 0;
text-align: left;
text-decoration: underline;
}
.resend-link:hover {
opacity: 0.8;
}
.resend-link:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.input-group {
margin-bottom: 0.75rem;
}
.input-wrapper {
position: relative;
}
.input-field {
width: 100%;
height: 3.5rem;
padding: 0 1rem;
border: 1px solid;
border-radius: 0.75rem;
font-size: 1rem;
transition:
border-color 0.2s,
box-shadow 0.2s;
/* Dark mode default */
background-color: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.2);
color: #fff;
}
.input-field.input-error {
border-color: #ef4444;
}
.light .input-field {
background-color: rgba(255, 255, 255, 0.8);
border-color: rgba(0, 0, 0, 0.1);
color: #000;
}
.light .input-field.input-error {
border-color: #ef4444;
}
.input-field:focus {
outline: none;
box-shadow: 0 0 0 2px var(--ring-color, currentColor);
}
.input-field.has-icon {
padding-right: 3rem;
}
.password-toggle {
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 3rem;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
transition: opacity 0.2s;
color: rgba(255, 255, 255, 0.5);
}
.light .password-toggle {
color: rgba(0, 0, 0, 0.4);
}
.password-toggle:hover {
opacity: 0.8;
}
.options-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.remember-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
color: rgba(255, 255, 255, 0.7);
}
.light .remember-label {
color: rgba(0, 0, 0, 0.7);
}
.remember-label input[type='checkbox'] {
width: 1.125rem;
height: 1.125rem;
cursor: pointer;
background-color: transparent;
border: 1.5px solid rgba(255, 255, 255, 0.4);
border-radius: 0.25rem;
appearance: none;
-webkit-appearance: none;
display: grid;
place-content: center;
}
.remember-label input[type='checkbox']::before {
content: '';
width: 0.65rem;
height: 0.65rem;
transform: scale(0);
transition: transform 0.1s ease-in-out;
box-shadow: inset 1rem 1rem var(--primary-color, #fff);
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
.remember-label input[type='checkbox']:checked {
border-color: var(--primary-color, #fff);
}
.remember-label input[type='checkbox']:checked::before {
transform: scale(1);
}
.light .remember-label input[type='checkbox'] {
border-color: rgba(0, 0, 0, 0.3);
}
.forgot-link {
background: none;
border: none;
cursor: pointer;
font-weight: 500;
padding: 0.25rem;
}
.forgot-link:hover {
opacity: 0.7;
}
.submit-button {
width: 100%;
height: 3.5rem;
border: 2px solid;
border-radius: 0.75rem;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
cursor: pointer;
transition: opacity 0.2s;
color: #fff;
}
.light .submit-button {
color: #000;
}
.submit-button:hover:not(:disabled) {
opacity: 0.85;
}
.submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.register-link {
text-align: center;
font-size: 0.875rem;
margin-top: 1rem;
color: rgba(255, 255, 255, 0.6);
}
.light .register-link {
color: rgba(0, 0, 0, 0.6);
}
.register-link button {
background: none;
border: none;
cursor: pointer;
font-weight: 500;
padding: 0.25rem;
}
.register-link button:hover {
opacity: 0.7;
}
.app-slider-section {
width: 100%;
padding: 0 0 1rem;
}
.version-label {
position: fixed;
bottom: 0.5rem;
right: 0.75rem;
font-size: 10px;
color: rgba(156, 163, 175, 0.6);
user-select: none;
pointer-events: none;
margin: 0;
}
.light .version-label {
color: rgba(156, 163, 175, 0.6);
}
/* Entrance Animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.logo-section {
animation: fadeInScale 0.5s ease-out both;
}
.form-card {
animation: fadeInUp 0.5s ease-out 0.15s both;
}
.app-slider-section {
animation: fadeIn 0.5s ease-out 0.3s both;
}
/* Interactive Animations */
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
10%,
30%,
50%,
70%,
90% {
transform: translateX(-4px);
}
20%,
40%,
60%,
80% {
transform: translateX(4px);
}
}
.shake {
animation: shake 0.5s ease-in-out;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spinner {
width: 1.25rem;
height: 1.25rem;
animation: spin 1s linear infinite;
}
@keyframes success-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.success-pulse {
animation: success-pulse 0.6s ease-in-out;
}
/* Accessibility */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 0.5rem 1rem;
z-index: 100;
font-weight: 500;
}
.skip-link:focus {
top: 0;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.logo-section,
.form-card,
.app-slider-section,
.shake,
.spinner,
.success-pulse {
animation: none;
}
* {
transition-duration: 0.01ms !important;
}
}
/* Mobile adjustments */
@media (max-width: 480px) {
.logo-section {
padding-top: 2rem;
}
.logo-button {
width: 80px;
height: 80px;
}
.logo-button :global(svg) {
width: 40px;
height: 40px;
}
.form-card {
padding: 1.25rem;
}
}
</style>