diff --git a/public/index.html b/public/index.html index aa069f2..7aa975c 100644 --- a/public/index.html +++ b/public/index.html @@ -3,41 +3,40 @@ - - - + + + + + + + + + + + + + + + + - - - React App + TCG Vault + +
- diff --git a/public/manifest.json b/public/manifest.json index 080d6c7..7e33646 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,6 +1,7 @@ { - "short_name": "React App", - "name": "Create React App Sample", + "short_name": "TCG Vault", + "name": "TCG Vault - Trading Card Game Collection Manager", + "description": "Mobile-first PWA for managing your trading card game collections, decks, and scanning cards with AI-powered OCR", "icons": [ { "src": "favicon.ico", @@ -15,11 +16,15 @@ { "src": "logo512.png", "type": "image/png", - "sizes": "512x512" + "sizes": "512x512", + "purpose": "any maskable" } ], - "start_url": ".", + "start_url": "/", "display": "standalone", - "theme_color": "#000000", - "background_color": "#ffffff" + "orientation": "portrait-primary", + "theme_color": "#8b5cf6", + "background_color": "#ffffff", + "categories": ["entertainment", "lifestyle", "utilities"], + "prefer_related_applications": false } diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..989d64d --- /dev/null +++ b/public/sw.js @@ -0,0 +1,87 @@ +const CACHE_NAME = 'tcg-vault-v1'; +const urlsToCache = [ + '/', + '/static/js/bundle.js', + '/static/css/main.css', + '/manifest.json', + '/favicon.ico', + '/logo192.png', + '/logo512.png' +]; + +// Install event +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then((cache) => cache.addAll(urlsToCache)) + .catch((error) => { + console.error('Failed to cache resources:', error); + }) + ); +}); + +// Fetch event +self.addEventListener('fetch', (event) => { + event.respondWith( + caches.match(event.request) + .then((response) => { + // Return cached version or fetch from network + return response || fetch(event.request); + }) + .catch((error) => { + console.error('Fetch failed:', error); + // Return offline page if available + return caches.match('/'); + }) + ); +}); + +// Activate event +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if (cacheName !== CACHE_NAME) { + return caches.delete(cacheName); + } + }) + ); + }) + ); +}); + +// Background sync for offline actions +self.addEventListener('sync', (event) => { + if (event.tag === 'background-sync') { + event.waitUntil( + // Handle offline actions when back online + console.log('Background sync triggered') + ); + } +}); + +// Push notifications (for future use) +self.addEventListener('push', (event) => { + if (event.data) { + const data = event.data.json(); + const options = { + body: data.body, + icon: '/logo192.png', + badge: '/favicon.ico', + vibrate: [200, 100, 200] + }; + + event.waitUntil( + self.registration.showNotification(data.title, options) + ); + } +}); + +// Notification click +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + event.waitUntil( + clients.openWindow('/') + ); +}); \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index b2e6e78..9618257 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Analytics } from '@vercel/analytics/react'; import { SpeedInsights } from '@vercel/speed-insights/react'; import { AuthProvider, useAuth } from './contexts/AuthContext'; -import Navbar from './components/Navbar'; +import { ThemeProvider } from './contexts/ThemeContext'; +import MobileNavbar from './components/MobileNavbar'; import Dashboard from './pages/Dashboard'; import Collections from './pages/Collections'; import Decks from './pages/Decks'; @@ -30,8 +31,15 @@ const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) = if (isLoading) { return ( -
-
+
+
+
+ + + +
+

Loading TCG Vault...

+
); } @@ -49,8 +57,15 @@ const PublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { if (isLoading) { return ( -
-
+
+
+
+ + + +
+

Loading TCG Vault...

+
); } @@ -62,112 +77,141 @@ const PublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { return <>{children}; }; -// Layout Component -const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => { +// Mobile Layout Component +const MobileLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { return ( -
- -
- {children} +
+ +
+
+ {children} +
); }; +// Auth Layout Component for login/register +const AuthLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { + return ( +
+
+ {children} +
+
+ ); +}; + function App() { return ( - - - - {/* Public Routes */} - - - - } /> - - - - } /> + + + + + {/* Public Routes */} + + + + + + } /> + + + + + + } /> - {/* Protected Routes */} - - - - - - } /> - - - - - - - } /> - - - - - - - } /> - - - - - - - } /> - - - - - - - } /> + {/* Protected Routes */} + + + + + + } /> + + + + + + + } /> + + + + + + + } /> + + + + + + + } /> + + + + + + + } /> - - - - - - } /> + + + + + + } /> - {/* Admin Routes */} - - - - } /> + {/* Admin Routes */} + + + + + + } /> - {/* Default redirect */} - } /> - - {/* 404 fallback */} - -
-

404 - Page Not Found

-

The page you're looking for doesn't exist.

- - Go to Dashboard - -
- - } /> -
- - -
-
+ {/* Default redirect */} + } /> + + {/* 404 fallback */} + +
+
+ + + +
+

Page Not Found

+

The page you're looking for doesn't exist.

+ +
+ + } /> +
+ + +
+
+
); } diff --git a/src/components/MobileNavbar.tsx b/src/components/MobileNavbar.tsx new file mode 100644 index 0000000..722ecdf --- /dev/null +++ b/src/components/MobileNavbar.tsx @@ -0,0 +1,258 @@ +import React, { useState } from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; +import { useTheme } from '../contexts/ThemeContext'; + +interface NavigationItem { + name: string; + href: string; + icon: React.ReactNode; + activeIcon?: React.ReactNode; +} + +const MobileNavbar: React.FC = () => { + const { user, logout, isAdmin } = useAuth(); + const { toggleTheme, effectiveTheme } = useTheme(); + const location = useLocation(); + const navigate = useNavigate(); + const [showUserMenu, setShowUserMenu] = useState(false); + + const navigationItems: NavigationItem[] = [ + { + name: 'Dashboard', + href: '/dashboard', + icon: ( + + + + ), + activeIcon: ( + + + + ), + }, + { + name: 'Cards', + href: '/cards', + icon: ( + + + + ), + activeIcon: ( + + + + ), + }, + { + name: 'Scanner', + href: '/scanner', + icon: ( + + + + + ), + activeIcon: ( + + + + ), + }, + { + name: 'Collections', + href: '/collections', + icon: ( + + + + ), + }, + { + name: 'More', + href: '/menu', + icon: ( + + + + ), + }, + ]; + + const handleLogout = () => { + logout(); + setShowUserMenu(false); + navigate('/login'); + }; + + const isActive = (path: string) => location.pathname === path; + + return ( + <> + {/* Top Header */} +
+
+
+
+ + + +
+
+

TCG Vault

+

+ Hi, {user?.firstName || user?.username} +

+
+
+ +
+ {/* Theme Toggle */} + + + {/* User Avatar */} + +
+
+
+ + {/* Bottom Navigation */} + + + {/* User Menu Modal */} + {showUserMenu && ( +
setShowUserMenu(false)}> +
+
+ +
+
+ {user?.firstName?.[0] || user?.username?.[0]?.toUpperCase() || 'U'} +
+
+

+ {user?.firstName && user?.lastName + ? `${user.firstName} ${user.lastName}` + : user?.username} +

+

{user?.email}

+
+ {user?.roles.map((role) => ( + + {role} + + ))} +
+
+
+ +
+ setShowUserMenu(false)} + className="flex items-center space-x-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" + > + + + + Decks + + + setShowUserMenu(false)} + className="flex items-center space-x-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" + > + + + + + Settings + + + {isAdmin() && ( + setShowUserMenu(false)} + className="flex items-center space-x-3 p-3 rounded-xl hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors" + > + + + + Admin Panel + + )} +
+ + +
+
+ )} + + ); +}; + +export default MobileNavbar; \ No newline at end of file diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index 87f1ce0..7ac03d0 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -1,143 +1,152 @@ import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { useNavigate, Link } from 'react-router-dom'; const LoginForm: React.FC = () => { + const { login } = useAuth(); const [formData, setFormData] = useState({ - username: '', + email: '', password: '', }); - const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); - - const { login } = useAuth(); - const navigate = useNavigate(); - - const handleChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setFormData(prev => ({ - ...prev, - [name]: value - })); - // Clear error when user starts typing - if (error) setError(''); - }; + const [error, setError] = useState(null); + const [showPassword, setShowPassword] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); - setError(''); + setError(null); - if (!formData.username.trim() || !formData.password) { - setError('Please fill in all fields'); + try { + await login(formData.email, formData.password); + } catch (err) { + setError(err instanceof Error ? err.message : 'Login failed'); + } finally { setIsLoading(false); - return; } + }; - const result = await login(formData.username, formData.password); - - if (result.success) { - navigate('/dashboard'); - } else { - setError(result.error || 'Login failed'); - } - - setIsLoading(false); + const handleChange = (e: React.ChangeEvent) => { + setFormData(prev => ({ + ...prev, + [e.target.name]: e.target.value + })); }; return ( -
-
+
+ {/* Logo Section */} +
+
+ + + +
+

+ Welcome Back +

+

+ Sign in to your TCG Vault +

+
+ + {error && ( +
+
+ + + + {error} +
+
+ )} + +
-
- - + +
+ + +
-

- Welcome to TCG Vault -

-

- Sign in to your account -

- - -
-
- - -
- -
- - -
-
- {error && ( -
- {error} -
- )} - -
+
+ +
+
+
-
-

- Don't have an account?{' '} - - Sign up here - -

-
- -
+ + +
+

+ Don't have an account?{' '} + + Sign up + +

+
+
); }; diff --git a/src/components/auth/RegisterForm.tsx b/src/components/auth/RegisterForm.tsx index e5eca8c..1d0bce7 100644 --- a/src/components/auth/RegisterForm.tsx +++ b/src/components/auth/RegisterForm.tsx @@ -1,262 +1,247 @@ import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { useNavigate, Link } from 'react-router-dom'; const RegisterForm: React.FC = () => { + const { register } = useAuth(); const [formData, setFormData] = useState({ + firstName: '', + lastName: '', username: '', email: '', password: '', confirmPassword: '', - firstName: '', - lastName: '', }); - const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); - - const { register } = useAuth(); - const navigate = useNavigate(); - - const handleChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setFormData(prev => ({ - ...prev, - [name]: value - })); - // Clear error when user starts typing - if (error) setError(''); - }; - - const validateForm = () => { - if (!formData.username.trim()) { - setError('Username is required'); - return false; - } - - if (formData.username.length < 3) { - setError('Username must be at least 3 characters long'); - return false; - } - - if (!formData.email.trim()) { - setError('Email is required'); - return false; - } - - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(formData.email)) { - setError('Please enter a valid email address'); - return false; - } - - if (!formData.password) { - setError('Password is required'); - return false; - } - - if (formData.password.length < 6) { - setError('Password must be at least 6 characters long'); - return false; - } - - if (formData.password !== formData.confirmPassword) { - setError('Passwords do not match'); - return false; - } - - return true; - }; + const [error, setError] = useState(null); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); - setError(''); + setError(null); - if (!validateForm()) { + // Validate passwords match + if (formData.password !== formData.confirmPassword) { + setError('Passwords do not match'); setIsLoading(false); return; } - const registerData = { - username: formData.username.trim(), - email: formData.email.trim(), - password: formData.password, - firstName: formData.firstName.trim() || undefined, - lastName: formData.lastName.trim() || undefined, - }; - - const result = await register(registerData); - - if (result.success) { - navigate('/dashboard'); - } else { - setError(result.error || 'Registration failed'); + try { + await register(formData); + } catch (err) { + setError(err instanceof Error ? err.message : 'Registration failed'); + } finally { + setIsLoading(false); } - - setIsLoading(false); + }; + + const handleChange = (e: React.ChangeEvent) => { + setFormData(prev => ({ + ...prev, + [e.target.name]: e.target.value + })); }; return ( -
-
+
+ {/* Logo Section */} +
+
+ + + +
+

+ Join TCG Vault +

+

+ Create your account to get started +

+
+ + {error && ( +
+
+ + + + {error} +
+
+ )} + +
+
+
+ + +
+
+ + +
+
+
-
- - + + +
+ +
+ +
+ + +
-

- Join TCG Vault -

-

- Create your account to get started -

- - -
-
-
- - -
- -
- - -
-
-
- - -
- -
- - -
- -
- - -
- -
- - -
-
- - {error && ( -
- {error} -
- )} - -
+
+ +
+
+
-
-

- Already have an account?{' '} - - Sign in here - -

+
+ +
+ +
- -
+
+ + + +
+

+ Already have an account?{' '} + + Sign in + +

+
+
); }; diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..0163630 --- /dev/null +++ b/src/contexts/ThemeContext.tsx @@ -0,0 +1,105 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; + +type Theme = 'light' | 'dark' | 'system'; + +interface ThemeContextType { + theme: Theme; + effectiveTheme: 'light' | 'dark'; + setTheme: (theme: Theme) => void; + toggleTheme: () => void; +} + +const ThemeContext = createContext(undefined); + +export const useTheme = () => { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}; + +interface ThemeProviderProps { + children: React.ReactNode; +} + +export const ThemeProvider: React.FC = ({ children }) => { + const [theme, setTheme] = useState(() => { + const savedTheme = localStorage.getItem('tcg-vault-theme') as Theme; + return savedTheme || 'system'; + }); + + const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light'); + + useEffect(() => { + const updateEffectiveTheme = () => { + if (theme === 'system') { + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + setEffectiveTheme(prefersDark ? 'dark' : 'light'); + } else { + setEffectiveTheme(theme); + } + }; + + updateEffectiveTheme(); + + // Listen for system theme changes + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = () => { + if (theme === 'system') { + updateEffectiveTheme(); + } + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, [theme]); + + useEffect(() => { + // Apply theme class to document + const root = document.documentElement; + if (effectiveTheme === 'dark') { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } + + // Update meta theme-color for mobile browsers + const metaThemeColor = document.querySelector('meta[name="theme-color"]'); + if (metaThemeColor) { + metaThemeColor.setAttribute('content', effectiveTheme === 'dark' ? '#0f172a' : '#8b5cf6'); + } + + // Store theme preference + localStorage.setItem('tcg-vault-theme', theme); + }, [theme, effectiveTheme]); + + const handleSetTheme = (newTheme: Theme) => { + setTheme(newTheme); + }; + + const toggleTheme = () => { + if (theme === 'light') { + setTheme('dark'); + } else if (theme === 'dark') { + setTheme('system'); + } else { + setTheme('light'); + } + }; + + const value: ThemeContextType = { + theme, + effectiveTheme, + setTheme: handleSetTheme, + toggleTheme, + }; + + return ( + + {children} + + ); +}; + +export default ThemeProvider; \ No newline at end of file diff --git a/src/index.css b/src/index.css index 28e1248..9b2315e 100644 --- a/src/index.css +++ b/src/index.css @@ -2,6 +2,23 @@ @tailwind components; @tailwind utilities; +/* Safe area classes for mobile PWA */ +.safe-area-inset-top { + padding-top: env(safe-area-inset-top); +} + +.safe-area-inset-bottom { + padding-bottom: env(safe-area-inset-bottom); +} + +.safe-area-inset-left { + padding-left: env(safe-area-inset-left); +} + +.safe-area-inset-right { + padding-right: env(safe-area-inset-right); +} + /* Custom animations */ @keyframes slide-down { from { @@ -18,6 +35,23 @@ animation: slide-down 0.3s ease-out; } +/* PWA specific styles */ +@supports (padding: env(safe-area-inset-top)) { + .min-h-screen-safe { + min-height: calc(100vh + env(safe-area-inset-top) + env(safe-area-inset-bottom)); + } +} + +/* Fallback for browsers that don't support env() */ +.min-h-screen-safe { + min-height: 100vh; +} + +/* Touch optimization */ +.active\:scale-98:active { + transform: scale(0.98); +} + /* Import custom card effects */ @import './styles/cardEffects.css'; @@ -28,6 +62,13 @@ body { sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + -webkit-tap-highlight-color: transparent; /* Remove tap highlight on iOS */ + overscroll-behavior: none; /* Prevent pull to refresh */ +} + +/* Prevent zoom on iOS */ +input, select, textarea { + font-size: 16px !important; } code { diff --git a/src/index.tsx b/src/index.tsx index 032464f..fc72e52 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -13,6 +13,19 @@ root.render( ); +// Register service worker for PWA +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js') + .then((registration) => { + console.log('SW registered: ', registration); + }) + .catch((registrationError) => { + console.log('SW registration failed: ', registrationError); + }); + }); +} + // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 23af717..ab56bb9 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -5,125 +5,174 @@ import { Link } from 'react-router-dom'; const Dashboard: React.FC = () => { const { user } = useAuth(); + const statsCards = [ + { + title: 'Collections', + value: '0', + icon: ( + + + + ), + color: 'primary' + }, + { + title: 'Decks', + value: '0', + icon: ( + + + + ), + color: 'accent' + }, + { + title: 'Total Cards', + value: '0', + icon: ( + + + + ), + color: 'primary' + }, + { + title: 'Total Value', + value: '$0', + icon: ( + + + + ), + color: 'accent' + }, + ]; + + const quickActions = [ + { + title: 'Scan Cards', + description: 'Use OCR to quickly add cards', + href: '/scanner', + icon: ( + + + + + ), + gradient: 'from-primary-500 to-accent-500', + }, + { + title: 'Browse Cards', + description: 'Explore your collection', + href: '/cards', + icon: ( + + + + ), + gradient: 'from-accent-500 to-primary-500', + }, + { + title: 'Manage Collections', + description: 'Organize your cards', + href: '/collections', + icon: ( + + + + ), + gradient: 'from-primary-600 to-accent-400', + }, + ]; + return ( -
-
-

- Welcome back, {user?.username}! +
+ {/* Welcome Section */} +
+

+ Welcome back, {user?.firstName || user?.username}! 👋

-

- Here's an overview of your trading card collection. +

+ Ready to manage your card collection?

{/* Quick Stats */} -
-
-
-
- 📚 -
-
-

Collections

-

0

+
+ {statsCards.map((stat, index) => ( +
+
+ {stat.icon}
+

+ {stat.value} +

+

+ {stat.title} +

-
- -
-
-
- 🎴 -
-
-

Decks

-

0

-
-
-
- -
-
-
- 🃏 -
-
-

Total Cards

-

0

-
-
-
- -
-
-
- 💰 -
-
-

Total Value

-

$0

-
-
-
+ ))}
{/* Quick Actions */} -
- -
- 📸 -

- Scan Cards -

-

- Use OCR to quickly add cards to your collection -

-
- - - -
- 📚 -

- Manage Collections -

-

- Organize and track your card collections -

-
- - - -
- 🎴 -

- Build Decks -

-

- Create and optimize decks with AI assistance -

-
- +
+

+ Quick Actions +

+ + {quickActions.map((action, index) => ( + +
+
+ {action.icon} +
+
+

+ {action.title} +

+

+ {action.description} +

+
+ + + +
+ + ))}
- {/* Recent Activity (placeholder) */} -
-

+ {/* Recent Activity */} +
+

Recent Activity

-
-

- No recent activity. Start by scanning some cards or creating a collection! -

+ +
+
+
+ + + +
+

+ No recent activity yet +

+

+ Start by scanning some cards or creating a collection! +

+
diff --git a/tailwind.config.js b/tailwind.config.js index 856641a..88427b8 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -3,24 +3,85 @@ module.exports = { content: [ "./src/**/*.{js,jsx,ts,tsx}", ], + darkMode: 'class', theme: { extend: { colors: { + // Purple primary palette primary: { - 50: '#eff6ff', - 100: '#dbeafe', - 500: '#3b82f6', - 600: '#2563eb', - 700: '#1d4ed8', + 50: '#faf5ff', + 100: '#f3e8ff', + 200: '#e9d5ff', + 300: '#d8b4fe', + 400: '#c084fc', + 500: '#a855f7', + 600: '#9333ea', + 700: '#7c3aed', + 800: '#6b21a8', + 900: '#581c87', + 950: '#3b0764', }, - secondary: { - 50: '#fdf7ef', - 100: '#fcefc3', - 500: '#f59e0b', - 600: '#d97706', - 700: '#b45309', - } - } + // Purple accent variations + accent: { + 50: '#f5f3ff', + 100: '#ede9fe', + 200: '#ddd6fe', + 300: '#c4b5fd', + 400: '#a78bfa', + 500: '#8b5cf6', + 600: '#7c3aed', + 700: '#6d28d9', + 800: '#5b21b6', + 900: '#4c1d95', + }, + // Custom surface colors for dark/light modes + surface: { + 50: '#f8fafc', + 100: '#f1f5f9', + 200: '#e2e8f0', + 300: '#cbd5e1', + 400: '#94a3b8', + 500: '#64748b', + 600: '#475569', + 700: '#334155', + 800: '#1e293b', + 900: '#0f172a', + }, + }, + screens: { + 'xs': '475px', + }, + spacing: { + '18': '4.5rem', + '88': '22rem', + '92': '23rem', + '128': '32rem', + }, + minHeight: { + 'screen-safe': '100dvh', + }, + fontSize: { + 'xxs': '0.625rem', + }, + animation: { + 'fade-in': 'fadeIn 0.5s ease-in-out', + 'slide-up': 'slideUp 0.3s ease-out', + 'bounce-subtle': 'bounceSubtle 0.6s ease-in-out', + }, + keyframes: { + fadeIn: { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, + slideUp: { + '0%': { transform: 'translateY(20px)', opacity: '0' }, + '100%': { transform: 'translateY(0)', opacity: '1' }, + }, + bounceSubtle: { + '0%, 100%': { transform: 'translateY(0)' }, + '50%': { transform: 'translateY(-5px)' }, + }, + }, }, }, plugins: [],