feat: add i18n localization with language switcher to all web apps

- Add svelte-i18n configuration with SSR support to all web apps
- Create LanguageSelector component for each app with brand colors
- Add German and English locale files
- Integrate language switcher into login pages via headerControls snippet
- Fix Tailwind v4 @source directives for shared package scanning
- Update AppSlider styling to match login container design

Apps updated:
- Memoro (gold #f8d62b)
- Märchenzauber (pink #FF6B9D)
- ManaDeck (purple #8b5cf6)
- ManaCore (indigo #6366f1)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-25 01:41:25 +01:00
parent bd869dfe09
commit 926ca231b5
147 changed files with 7090 additions and 2276 deletions

View file

@ -10,7 +10,8 @@
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true
"noEmit": true,
"types": ["svelte"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]

View file

@ -31,7 +31,11 @@ export const iconPaths = {
'music': '<path d="M212.92,17.69a8,8,0,0,0-6.86-1.45l-128,32A8,8,0,0,0,72,56V166.09A36,36,0,1,0,88,196V62.25l112-28v99.84A36,36,0,1,0,216,168V24A8,8,0,0,0,212.92,17.69ZM52,216a20,20,0,1,1,20-20A20,20,0,0,1,52,216Zm128-32a20,20,0,1,1,20-20A20,20,0,0,1,180,184Z"/>',
'refresh': '<path d="M240,56v48a8,8,0,0,1-8,8H184a8,8,0,0,1,0-16H211.4L184.81,71.64A80,80,0,1,0,128,208a8,8,0,0,1,0,16A96,96,0,1,1,195.26,60.49L224,85.34V56a8,8,0,0,1,16,0Z"/>'
'refresh': '<path d="M240,56v48a8,8,0,0,1-8,8H184a8,8,0,0,1,0-16H211.4L184.81,71.64A80,80,0,1,0,128,208a8,8,0,0,1,0,16A96,96,0,1,1,195.26,60.49L224,85.34V56a8,8,0,0,1,16,0Z"/>',
'check': '<path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z"/>',
'warning': '<path d="M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM222.93,203.8a8.5,8.5,0,0,1-7.48,4.2H40.55a8.5,8.5,0,0,1-7.48-4.2,7.59,7.59,0,0,1,0-7.72L120.52,44.21a8.75,8.75,0,0,1,15,0l87.45,151.87A7.59,7.59,0,0,1,222.93,203.8ZM120,144V104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,180Z"/>'
} as const;
export type IconName = keyof typeof iconPaths;

View file

@ -1,6 +1,7 @@
// Pages
export { default as LoginPage } from './pages/LoginPage.svelte';
export { default as RegisterPage } from './pages/RegisterPage.svelte';
export { default as ForgotPasswordPage } from './pages/ForgotPasswordPage.svelte';
// Components
export { default as Icon } from './components/Icon.svelte';

View file

@ -0,0 +1,239 @@
<script lang="ts">
import type { Component, Snippet } from 'svelte';
import type { AuthResult } from '../types';
import Icon from '../components/Icon.svelte';
type PageMode = 'form' | 'success';
interface Props {
/** App name */
appName: string;
/** Logo component */
logo: Component<{ size?: number; color?: string }>;
/** Primary color (hex) */
primaryColor: string;
/** Forgot password function */
onForgotPassword: (email: string) => Promise<AuthResult>;
/** Navigation function */
goto: (path: string) => void;
/** Login page path */
loginPath?: string;
/** Light background color */
lightBackground?: string;
/** Dark background color */
darkBackground?: string;
/** App slider snippet */
appSlider?: Snippet;
}
let {
appName,
logo: Logo,
primaryColor,
onForgotPassword,
goto,
loginPath = '/login',
lightBackground = '#f5f5f5',
darkBackground = '#121212',
appSlider
}: Props = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let email = $state('');
let mode = $state<PageMode>('form');
let resetEmail = $state('');
// Check for dark mode
let isDark = $state(false);
$effect(() => {
if (typeof window !== 'undefined') {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const listener = (e: MediaQueryListEvent) => {
isDark = e.matches;
};
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', listener);
return () => {
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', listener);
};
}
});
function getPageBackground() {
return isDark ? darkBackground : lightBackground;
}
async function handleForgotPassword() {
loading = true;
error = null;
if (!email) {
error = 'Email is required';
loading = false;
return;
}
const result = await onForgotPassword(email);
loading = false;
if (result.success) {
resetEmail = email;
email = '';
mode = 'success';
} else {
error = result.error || 'Failed to send reset email';
}
}
</script>
<svelte:head>
<title>Forgot Password - {appName}</title>
</svelte:head>
<div
class="flex min-h-screen flex-col justify-between"
style="background-color: {getPageBackground()};"
>
<!-- Top Section - Logo -->
<div class="flex flex-col items-center justify-center pt-16 pb-8">
<div
class="flex items-center justify-center rounded-full transition-all mb-4"
style="width: 120px; height: 120px; border: 3px solid {primaryColor}; background-color: {isDark ? '#000' : '#fff'}; box-shadow: {isDark
? '0 6px 12px rgba(0, 0, 0, 0.4)'
: '0 6px 12px rgba(0, 0, 0, 0.15)'};"
>
<Logo size={55} color={primaryColor} />
</div>
<h1 class="text-2xl font-semibold" style="color: {isDark ? '#ffffff' : '#000000'};">
{appName}
</h1>
</div>
<!-- Middle Section - Form -->
<div class="flex-1 flex items-start justify-center px-5 pt-8 pb-8">
<div
class="w-full max-w-md rounded-xl p-6"
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.7)'}; backdrop-filter: blur(10px); border: 1px solid {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"
>
<!-- Title -->
<h2
class="mb-6 text-center text-xl font-semibold"
style="color: {isDark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'};"
>
{mode === 'form' ? 'Reset Password' : 'Email Sent'}
</h2>
<!-- Error Messages -->
{#if error}
<div class="mb-4 rounded-xl bg-red-500/20 border border-red-500/30 p-3">
<p class="text-sm text-red-500">{error}</p>
</div>
{/if}
<!-- Form Mode -->
{#if mode === 'form'}
<form
onsubmit={(e) => {
e.preventDefault();
handleForgotPassword();
}}
class="pb-4"
>
<p
class="mb-4 text-sm"
style="color: {isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'};"
>
Enter your email address and we'll send you a link to reset your password.
</p>
<div class="mb-4">
<input
type="email"
bind:value={email}
placeholder="Email"
required
class="h-14 w-full rounded-xl border px-4 text-lg transition-colors focus:outline-none focus:ring-2"
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
/>
</div>
<button
type="submit"
disabled={loading}
class="flex h-14 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 disabled:opacity-50 border-2"
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="key" size={20} />
{loading ? 'Sending...' : 'Send Reset Link'}
</button>
</form>
<!-- Back Button -->
<div class="mt-4">
<button
onclick={() => goto(loginPath)}
class="flex h-10 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80"
style="color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="arrow-left" size={20} />
Back to Login
</button>
</div>
<!-- Success Mode -->
{:else}
<div class="pb-4">
<div class="flex flex-col items-center mb-6">
<div
class="w-20 h-20 rounded-full flex items-center justify-center mb-6"
style="background-color: {primaryColor}30;"
>
<Icon name="mail-open" size={40} color={primaryColor} />
</div>
<p
class="text-sm text-center px-2"
style="color: {isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'};"
>
We've sent a password reset link to <strong>{resetEmail}</strong>. Please check your
inbox.
</p>
</div>
<div class="flex flex-col gap-3">
<button
onclick={() => goto(loginPath)}
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border-2"
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="sign-in" size={20} />
Back to Login
</button>
<button
onclick={() => {
mode = 'form';
error = null;
}}
class="flex h-10 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border"
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'};"
>
Resend Email
</button>
</div>
</div>
{/if}
</div>
</div>
<!-- App Slider -->
{#if appSlider}
<div class="w-full px-4 pb-8">
{@render appSlider()}
</div>
{:else}
<!-- Bottom padding -->
<div class="pb-8"></div>
{/if}
</div>

View file

@ -5,8 +5,6 @@
import GoogleSignInButton from '../components/GoogleSignInButton.svelte';
import AppleSignInButton from '../components/AppleSignInButton.svelte';
type AuthMode = 'initial' | 'login' | 'forgot-password' | 'password-reset-success';
interface Props {
/** App name */
appName: string;
@ -20,8 +18,6 @@
onSignInWithGoogle?: (idToken: string) => Promise<AuthResult>;
/** Sign in with Apple function */
onSignInWithApple?: (identityToken: string) => Promise<AuthResult>;
/** Forgot password function */
onForgotPassword: (email: string) => Promise<AuthResult>;
/** Navigation function */
goto: (path: string) => void;
/** Enable Google Sign-In */
@ -32,6 +28,8 @@
successRedirect?: string;
/** Register page path */
registerPath?: string;
/** Forgot password page path */
forgotPasswordPath?: string;
/** Light background color */
lightBackground?: string;
/** Dark background color */
@ -49,12 +47,12 @@
onSignIn,
onSignInWithGoogle,
onSignInWithApple,
onForgotPassword,
goto,
enableGoogle = false,
enableApple = false,
successRedirect = '/dashboard',
registerPath = '/register',
forgotPasswordPath = '/forgot-password',
lightBackground = '#f5f5f5',
darkBackground = '#121212',
appSlider,
@ -63,11 +61,16 @@
let loading = $state(false);
let error = $state<string | null>(null);
let errorField = $state<'email' | 'password' | 'general' | null>(null);
let email = $state('');
let password = $state('');
let mode = $state<AuthMode>('initial');
let resetEmail = $state('');
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('');
// Check for dark mode
let isDark = $state(false);
@ -84,22 +87,67 @@
}
});
// Autofocus email field on mount
$effect(() => {
if (emailInput) {
emailInput.focus();
}
});
function getPageBackground() {
return isDark ? darkBackground : lightBackground;
}
function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
function triggerErrorShake() {
shakeError = true;
setTimeout(() => {
shakeError = false;
}, 500);
}
function setError(message: string, field: 'email' | 'password' | 'general' = 'general') {
error = message;
errorField = field;
triggerErrorShake();
// Focus the problematic field for better accessibility
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;
error = null;
clearError();
if (!email) {
error = 'Email is required';
setError('Email is required', 'email');
loading = false;
return;
}
if (!isValidEmail(email)) {
setError('Please enter a valid email address', 'email');
loading = false;
return;
}
if (!password) {
error = 'Password is required';
setError('Password is required', 'password');
loading = false;
return;
}
@ -109,55 +157,41 @@
loading = false;
if (result.success) {
goto(successRedirect);
// Show success feedback before redirect
showSuccess = true;
successAnnouncement = 'Successfully signed in. Redirecting...';
setTimeout(() => {
goto(successRedirect);
}, 600);
} else {
error = result.error || 'Sign in failed';
}
}
async function handleForgotPassword() {
loading = true;
error = null;
if (!email) {
error = 'Email is required';
loading = false;
return;
}
const result = await onForgotPassword(email);
loading = false;
if (result.success) {
resetEmail = email;
resetForm();
switchMode('password-reset-success');
} else {
error = result.error || 'Failed to send reset email';
setError(result.error || 'Sign in failed', 'general');
}
}
async function handleGoogleSuccess(idToken: string) {
if (!onSignInWithGoogle) return;
loading = true;
clearError();
const result = await onSignInWithGoogle(idToken);
loading = false;
if (result.success) {
goto(successRedirect);
showSuccess = true;
successAnnouncement = 'Successfully signed in with Google. Redirecting...';
setTimeout(() => {
goto(successRedirect);
}, 600);
} else {
error = result.error || 'Google sign in failed';
setError(result.error || 'Google sign in failed', 'general');
}
}
function resetForm() {
email = '';
password = '';
error = null;
}
function switchMode(newMode: AuthMode) {
mode = newMode;
error = null;
function skipToForm() {
if (emailInput) {
emailInput.focus();
}
}
</script>
@ -165,6 +199,100 @@
<title>Login - {appName}</title>
</svelte:head>
<style>
@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 {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes success-pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.9; }
100% { transform: scale(1); opacity: 1; }
}
.success-pulse {
animation: success-pulse 0.6s ease-in-out;
}
/* Respect reduced motion preference */
@media (prefers-reduced-motion: reduce) {
.shake,
.spinner,
.success-pulse {
animation: none;
}
* {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Skip link styling */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px 16px;
z-index: 100;
text-decoration: none;
font-weight: 500;
}
.skip-link:focus {
top: 0;
}
/* Ensure minimum touch target size (44x44px) */
.touch-target {
min-width: 44px;
min-height: 44px;
}
</style>
<!-- Skip Link for keyboard users -->
<button
class="skip-link"
onclick={skipToForm}
type="button"
>
Skip to login form
</button>
<!-- Screen reader announcements -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
{successAnnouncement}
</div>
<div
class="flex min-h-screen flex-col justify-between"
style="background-color: {getPageBackground()};"
@ -176,115 +304,116 @@
</div>
{/if}
<!-- Top Section - Logo -->
<div class="flex flex-col items-center justify-center pt-16 pb-8">
<div
class="flex items-center justify-center rounded-full transition-all mb-4"
style="width: 120px; height: 120px; border: 3px solid {primaryColor}; background-color: {isDark ? '#000' : '#fff'}; box-shadow: {isDark
? '0 6px 12px rgba(0, 0, 0, 0.4)'
: '0 6px 12px rgba(0, 0, 0, 0.15)'};"
>
<Logo size={55} color={primaryColor} />
<main>
<!-- Top Section - Logo -->
<div class="flex flex-col items-center justify-center pt-16 pb-8">
<div
class="flex items-center justify-center rounded-full transition-all mb-4"
class:success-pulse={showSuccess}
style="width: 120px; height: 120px; border: 3px solid {showSuccess ? '#22c55e' : primaryColor}; background-color: {isDark ? '#000' : '#fff'}; box-shadow: {isDark
? '0 6px 12px rgba(0, 0, 0, 0.4)'
: '0 6px 12px rgba(0, 0, 0, 0.15)'};"
role="img"
aria-label="{appName} logo"
>
{#if showSuccess}
<Icon name="check" size={55} color="#22c55e" />
{:else}
<Logo size={55} color={primaryColor} />
{/if}
</div>
<h1 class="text-2xl font-semibold" style="color: {isDark ? '#ffffff' : '#000000'};">
{appName}
</h1>
</div>
<h1 class="text-2xl font-semibold" style="color: {isDark ? '#ffffff' : '#000000'};">
{appName}
</h1>
</div>
<!-- Middle Section - Auth Form -->
<div class="flex-1 flex items-start justify-center px-5 pt-8 pb-8">
<div
class="w-full max-w-md rounded-xl p-6"
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.7)'}; backdrop-filter: blur(10px); border: 1px solid {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"
>
<!-- Title -->
<div class="mb-6">
<h2
class="text-center text-xl font-semibold flex items-center justify-center gap-2"
style="color: {isDark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'};"
>
{#if mode === 'initial'}
Mana Login
{:else if mode === 'login'}
<!-- Middle Section - Auth Form -->
<div class="flex-1 flex items-start justify-center px-5 pt-8 pb-8">
<div
class="w-full max-w-md rounded-xl p-6"
class:shake={shakeError}
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.7)'}; backdrop-filter: blur(10px); border: 1px solid {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"
>
<!-- Title -->
<div class="mb-6">
<h2
class="text-center text-xl font-semibold"
style="color: {isDark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'};"
>
Sign In
{:else if mode === 'forgot-password'}
Reset Password
{:else if mode === 'password-reset-success'}
Email Sent
{/if}
</h2>
{#if mode === 'initial'}
</h2>
<p
class="mt-3 text-sm text-center"
class="mt-2 text-sm text-center"
style="color: {isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'};"
>
Sign in with your Mana account
</p>
</div>
<!-- Error Messages -->
{#if error}
<div
id="form-error"
role="alert"
aria-live="assertive"
class="mb-4 rounded-xl bg-red-500/20 border border-red-500/30 p-3 flex items-center gap-2"
>
<Icon name="warning" size={18} color="#ef4444" />
<p class="text-sm text-red-500">{error}</p>
</div>
{/if}
</div>
<!-- Error Messages -->
{#if error}
<div class="mb-4 rounded-xl bg-red-500/20 border border-red-500/30 p-3">
<p class="text-sm text-red-500">{error}</p>
</div>
{/if}
<!-- Initial Mode -->
{#if mode === 'initial'}
<div class="mb-2 flex flex-col gap-3">
<button
onclick={() => goto(registerPath)}
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border-2"
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="user-plus" size={20} />
Create Account
</button>
<button
onclick={() => switchMode('login')}
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border"
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="sign-in" size={20} />
Sign In
</button>
</div>
<!-- Login Mode -->
{:else if mode === 'login'}
<!-- Login Form -->
<form
onsubmit={(e) => {
e.preventDefault();
handleLogin();
}}
class="pb-4"
aria-busy={loading}
aria-describedby={error ? 'form-error' : undefined}
>
<div class="mb-2">
<!-- Email Field -->
<div class="mb-3">
<label for="email" class="sr-only">Email address</label>
<input
id="email"
type="email"
bind:this={emailInput}
bind:value={email}
placeholder="Email"
required
autocomplete="email"
aria-invalid={errorField === 'email'}
aria-describedby={errorField === 'email' ? 'form-error' : undefined}
class="h-14 w-full rounded-xl border px-4 text-lg transition-colors focus:outline-none focus:ring-2"
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {errorField === 'email' ? '#ef4444' : (isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)')}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {errorField === 'email' ? '#ef4444' : primaryColor};"
/>
</div>
<div class="mb-2 relative">
<!-- Password Field -->
<div class="mb-3 relative">
<label for="password" class="sr-only">Password</label>
<input
id="password"
type={showPassword ? 'text' : 'password'}
bind:this={passwordInput}
bind:value={password}
placeholder="Password"
required
autocomplete="current-password"
aria-invalid={errorField === 'password'}
aria-describedby={errorField === 'password' ? 'form-error' : undefined}
class="h-14 w-full rounded-xl border px-4 pr-12 text-lg transition-colors focus:outline-none focus:ring-2"
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {errorField === 'password' ? '#ef4444' : (isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)')}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {errorField === 'password' ? '#ef4444' : primaryColor};"
/>
<button
type="button"
onclick={() => (showPassword = !showPassword)}
class="absolute right-3 top-1/2 -translate-y-1/2 p-2 rounded-lg hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
class="absolute right-3 top-1/2 -translate-y-1/2 p-2 rounded-lg hover:bg-black/10 dark:hover:bg-white/10 transition-colors touch-target flex items-center justify-center"
aria-label={showPassword ? 'Hide password' : 'Show password'}
aria-pressed={showPassword}
title={showPassword ? 'Hide password' : 'Show password'}
>
<Icon
name={showPassword ? 'eye-off' : 'eye'}
@ -294,35 +423,73 @@
</button>
</div>
<button
type="button"
onclick={() => switchMode('forgot-password')}
class="mb-4 flex h-10 w-full items-center justify-center rounded-xl font-medium transition-all hover:opacity-80 border"
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'};"
>
Forgot Password?
</button>
<!-- Remember Me & Forgot Password Row -->
<div class="mb-4 flex items-center justify-between">
<label class="flex items-center gap-2 cursor-pointer touch-target">
<input
type="checkbox"
bind:checked={rememberMe}
class="w-5 h-5 rounded border-2 transition-colors cursor-pointer"
style="accent-color: {primaryColor};"
/>
<span
class="text-sm"
style="color: {isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'};"
>
Remember me
</span>
</label>
<button
type="button"
onclick={() => goto(forgotPasswordPath)}
class="text-sm font-medium transition-opacity hover:opacity-70 touch-target flex items-center justify-center px-2"
style="color: {primaryColor};"
>
Forgot password?
</button>
</div>
<!-- Submit Button -->
<button
type="submit"
disabled={loading}
class="flex h-14 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 disabled:opacity-50 border-2"
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
disabled={loading || showSuccess}
aria-disabled={loading || showSuccess}
class="flex h-14 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 disabled:opacity-50 border-2 touch-target"
style="background-color: {showSuccess ? '#22c55e' : primaryColor + '60'}; border-color: {showSuccess ? '#22c55e' : primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="sign-in" size={20} />
{loading ? 'Signing in...' : 'Sign In'}
{#if loading}
<svg
class="spinner w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<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>Signing in...</span>
{:else if showSuccess}
<Icon name="check" size={20} />
<span>Success!</span>
{:else}
<Icon name="sign-in" size={20} />
<span>Sign In</span>
{/if}
</button>
</form>
<!-- Social Login -->
{#if enableGoogle || enableApple}
<div class="my-4 flex items-center gap-3">
<div class="my-4 flex items-center gap-3" role="separator" aria-orientation="horizontal">
<div class="flex-1 border-t" style="border-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"></div>
<p class="text-xs" style="color: {isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'};">or</p>
<span class="text-xs" style="color: {isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'};">or</span>
<div class="flex-1 border-t" style="border-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"></div>
</div>
<div class="mb-4 flex flex-col gap-2">
<div class="mb-4 flex flex-col gap-2" role="group" aria-label="Social login options">
{#if enableGoogle && onSignInWithGoogle}
<GoogleSignInButton onSuccess={handleGoogleSuccess} />
{/if}
@ -332,122 +499,29 @@
</div>
{/if}
<!-- Back Button -->
<div class="mt-4">
<button
onclick={() => {
resetForm();
switchMode('initial');
}}
class="flex h-10 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80"
style="color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="arrow-left" size={20} />
Back
</button>
</div>
<!-- Forgot Password Mode -->
{:else if mode === 'forgot-password'}
<form
onsubmit={(e) => {
e.preventDefault();
handleForgotPassword();
}}
class="pb-4"
>
<!-- Register Link -->
<div class="mt-4 text-center">
<p
class="mb-4 text-sm"
style="color: {isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'};"
class="text-sm"
style="color: {isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'};"
>
Enter your email address and we'll send you a link to reset your password.
</p>
<div class="mb-4">
<input
type="email"
bind:value={email}
placeholder="Email"
required
class="h-14 w-full rounded-xl border px-4 text-lg transition-colors focus:outline-none focus:ring-2"
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
/>
</div>
<div class="flex flex-col gap-4">
<button
type="submit"
disabled={loading}
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 disabled:opacity-50 border-2"
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="key" size={20} />
{loading ? 'Sending...' : 'Reset Password'}
</button>
Don't have an account?
<button
type="button"
onclick={() => {
resetForm();
switchMode('login');
}}
class="flex h-10 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80"
style="color: {isDark ? '#ffffff' : '#000000'};"
onclick={() => goto(registerPath)}
class="font-medium transition-opacity hover:opacity-70 touch-target inline-flex items-center justify-center px-1"
style="color: {primaryColor};"
>
<Icon name="arrow-left" size={20} />
Back
Create one
</button>
</div>
</form>
<!-- Password Reset Success Mode -->
{:else if mode === 'password-reset-success'}
<div class="pb-4">
<div class="flex flex-col items-center mb-6">
<div
class="w-20 h-20 rounded-full flex items-center justify-center mb-6"
style="background-color: {primaryColor}30;"
>
<Icon name="mail-open" size={40} color={primaryColor} />
</div>
<p
class="text-sm text-center px-2"
style="color: {isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'};"
>
We've sent a password reset link to <strong>{resetEmail}</strong>. Please check your
inbox.
</p>
</div>
<div class="flex flex-col gap-3">
<button
onclick={() => {
resetEmail = '';
switchMode('login');
}}
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border-2"
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="sign-in" size={20} />
Back to Login
</button>
<button
onclick={() => switchMode('forgot-password')}
class="flex h-10 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border"
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'};"
>
Resend Email
</button>
</div>
</p>
</div>
{/if}
</div>
</div>
</div>
</main>
<!-- App Slider (shown on initial mode) -->
{#if appSlider && mode === 'initial'}
<!-- App Slider -->
{#if appSlider}
<div class="w-full pb-8 px-2 pt-4">
{@render appSlider()}
</div>

View file

@ -3,6 +3,8 @@
import type { AuthResult } from '../types';
import Icon from '../components/Icon.svelte';
import type { Snippet } from 'svelte';
interface Props {
/** App name */
appName: string;
@ -22,6 +24,8 @@
lightBackground?: string;
/** Dark background color */
darkBackground?: string;
/** App slider snippet */
appSlider?: Snippet;
}
let {
@ -33,7 +37,8 @@
successRedirect = '/dashboard',
loginPath = '/login',
lightBackground = '#f5f5f5',
darkBackground = '#121212'
darkBackground = '#121212',
appSlider
}: Props = $props();
let loading = $state(false);
@ -305,6 +310,13 @@
</div>
</div>
<!-- Bottom padding -->
<div class="pb-8"></div>
<!-- App Slider -->
{#if appSlider}
<div class="w-full px-4 pb-8">
{@render appSlider()}
</div>
{:else}
<!-- Bottom padding -->
<div class="pb-8"></div>
{/if}
</div>

View file

@ -1,20 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"declaration": true,
"declarationMap": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"verbatimModuleSyntax": true
"types": ["svelte"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]

View file

@ -1,18 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules"]
}

View file

@ -7,7 +7,11 @@
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
".": {
"svelte": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"check": "svelte-check --tsconfig ./tsconfig.json"

View file

@ -45,6 +45,8 @@
<path
d={branding.logoPath}
fill={fillColor}
fill-rule="evenodd"
clip-rule="evenodd"
/>
{/if}
</svg>

View file

@ -11,7 +11,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"verbatimModuleSyntax": true
"verbatimModuleSyntax": true,
"types": ["svelte"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]

View file

@ -18,6 +18,7 @@
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^24.10.1",
"typescript": "^5.7.3"
}
}

View file

@ -3,15 +3,15 @@
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"lib": ["ES2022", "DOM"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],

View file

@ -1,17 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"types": ["svelte"]
},

View file

@ -119,6 +119,10 @@ export const iconPaths = {
gear: '<path d="M128,76a52,52,0,1,0,52,52A52.06,52.06,0,0,0,128,76Zm0,80a28,28,0,1,1,28-28A28,28,0,0,1,128,156Zm92-28a92.11,92.11,0,0,1-2.33,20.84A12,12,0,0,1,206.5,158l-16.29-6.73a68.35,68.35,0,0,1-24.91,14.4L163.37,182a12,12,0,0,1-10.06,9.89,92.75,92.75,0,0,1-25.44,0A12,12,0,0,1,117.81,182l-1.93-16.29a68.35,68.35,0,0,1-24.91-14.4L74.68,158a12,12,0,0,1-11.17-9.16,92.11,92.11,0,0,1,0-41.68A12,12,0,0,1,74.68,98l16.29,6.73a68.35,68.35,0,0,1,24.91-14.4L117.81,74a12,12,0,0,1,10.06-9.89,92.75,92.75,0,0,1,25.44,0A12,12,0,0,1,163.37,74l1.93,16.29a68.35,68.35,0,0,1,24.91,14.4L206.5,98a12,12,0,0,1,11.17,9.16A92.11,92.11,0,0,1,220,128Z"/>',
'warning':
'<path d="M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm0,192a84,84,0,1,1,84-84A84.09,84.09,0,0,1,128,212Zm-12-80V80a12,12,0,0,1,24,0v52a12,12,0,0,1-24,0Zm28,40a16,16,0,1,1-16-16A16,16,0,0,1,144,172Z"/>',
'alert-triangle':
'<path d="M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM222.93,203.8a.6.6,0,0,1-.48.2H40.55a.6.6,0,0,1-.48-.2.51.51,0,0,1,0-.52L127.62,51.37a.72.72,0,0,1,1.24,0l87.55,151.91A.51.51,0,0,1,222.93,203.8ZM116,144V104a12,12,0,0,1,24,0v40a12,12,0,0,1-24,0Zm28,40a16,16,0,1,1-16-16A16,16,0,0,1,144,184Z"/>',
'alert-circle':
'<path d="M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm0,192a84,84,0,1,1,84-84A84.09,84.09,0,0,1,128,212Zm-12-80V80a12,12,0,0,1,24,0v52a12,12,0,0,1-24,0Zm28,40a16,16,0,1,1-16-16A16,16,0,0,1,144,172Z"/>',
'question':
'<path d="M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm0,192a84,84,0,1,1,84-84A84.09,84.09,0,0,1,128,212Zm0-140a32,32,0,0,0-32,32,12,12,0,0,0,24,0,8,8,0,1,1,8,8,12,12,0,0,0-12,12v8a12,12,0,0,0,24,0v-1.26A32,32,0,0,0,128,72Zm12,100a16,16,0,1,1-16-16A16,16,0,0,1,140,172Z"/>',
'house':

View file

@ -1,16 +1,19 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
"verbatimModuleSyntax": true,
"noEmit": true,
"types": ["svelte"]
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -8,10 +8,8 @@
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],

View file

@ -7,14 +7,39 @@
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./SubscriptionCard.svelte": "./src/SubscriptionCard.svelte",
"./PackageCard.svelte": "./src/PackageCard.svelte",
"./BillingToggle.svelte": "./src/BillingToggle.svelte",
"./UsageCard.svelte": "./src/UsageCard.svelte",
"./CostCard.svelte": "./src/CostCard.svelte",
"./SubscriptionButton.svelte": "./src/SubscriptionButton.svelte",
"./ManaIcon.svelte": "./src/ManaIcon.svelte"
".": {
"svelte": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./SubscriptionCard.svelte": {
"svelte": "./src/SubscriptionCard.svelte",
"default": "./src/SubscriptionCard.svelte"
},
"./PackageCard.svelte": {
"svelte": "./src/PackageCard.svelte",
"default": "./src/PackageCard.svelte"
},
"./BillingToggle.svelte": {
"svelte": "./src/BillingToggle.svelte",
"default": "./src/BillingToggle.svelte"
},
"./UsageCard.svelte": {
"svelte": "./src/UsageCard.svelte",
"default": "./src/UsageCard.svelte"
},
"./CostCard.svelte": {
"svelte": "./src/CostCard.svelte",
"default": "./src/CostCard.svelte"
},
"./SubscriptionButton.svelte": {
"svelte": "./src/SubscriptionButton.svelte",
"default": "./src/SubscriptionButton.svelte"
},
"./ManaIcon.svelte": {
"svelte": "./src/ManaIcon.svelte",
"default": "./src/ManaIcon.svelte"
}
},
"scripts": {
"check": "svelte-check --tsconfig ./tsconfig.json"

View file

@ -42,6 +42,7 @@
let isHovered = $state(false);
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative rounded-xl p-4 transition-all duration-200 bg-content border hover:-translate-y-0.5 hover:shadow-lg"
class:border-mana={pkg.popular}

View file

@ -57,6 +57,7 @@
let isHovered = $state(false);
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative rounded-xl p-4 transition-all duration-200 bg-content border hover:-translate-y-0.5 hover:shadow-lg"
class:border-2={isCurrentPlan}

View file

@ -10,8 +10,9 @@
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"verbatimModuleSyntax": true
"types": ["svelte"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]

View file

@ -37,6 +37,24 @@ export function createSupabaseAdminClient(config: SupabaseConfig): SupabaseClien
});
}
/**
* Supabase error type
*/
export interface SupabaseError {
message: string;
code?: string;
details?: string;
hint?: string;
}
/**
* Standardized query result type
*/
export interface QueryResult<T> {
data: T | null;
error: SupabaseError | null;
}
/**
* Common database query helpers
*/
@ -44,16 +62,15 @@ export const dbHelpers = {
/**
* Handle Supabase query result and return standardized response
*/
handleQueryResult<T>(result: { data: T | null; error: any }): {
data: T | null;
error: { message: string; code?: string } | null;
} {
handleQueryResult<T>(result: { data: T | null; error: SupabaseError | null }): QueryResult<T> {
if (result.error) {
return {
data: null,
error: {
message: result.error.message,
code: result.error.code,
details: result.error.details,
hint: result.error.hint,
},
};
}

View file

@ -1,17 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020"],
"lib": ["ES2022", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist"
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules"]
}

View file

@ -10,7 +10,8 @@
"./colors": "./src/colors.js",
"./theme.css": "./src/theme-variables.css",
"./themes.css": "./src/themes.css",
"./components.css": "./src/components.css"
"./components.css": "./src/components.css",
"./v4": "./src/tailwind-v4.css"
},
"peerDependencies": {
"tailwindcss": "^3.0.0 || ^4.0.0"

View file

@ -0,0 +1,117 @@
/**
* Tailwind CSS v4 Configuration
*
* This file provides the CSS-first configuration for Tailwind v4.
* Import this file in your app.css instead of using tailwind.config.js
*
* Usage:
* @import "tailwindcss";
* @import "@manacore/shared-tailwind/v4";
*/
/* ===== Theme Configuration ===== */
@theme {
/* Brand color */
--color-mana: #4287f5;
/* Semantic colors using CSS variables */
--color-background: hsl(var(--color-background));
--color-foreground: hsl(var(--color-foreground));
--color-primary: hsl(var(--color-primary));
--color-primary-foreground: hsl(var(--color-primary-foreground));
--color-secondary: hsl(var(--color-secondary));
--color-secondary-foreground: hsl(var(--color-secondary-foreground));
--color-surface: hsl(var(--color-surface));
--color-surface-hover: hsl(var(--color-surface-hover));
--color-surface-elevated: hsl(var(--color-surface-elevated));
--color-muted: hsl(var(--color-muted));
--color-muted-foreground: hsl(var(--color-muted-foreground));
--color-border: hsl(var(--color-border));
--color-border-strong: hsl(var(--color-border-strong));
--color-error: hsl(var(--color-error));
--color-success: hsl(var(--color-success));
--color-warning: hsl(var(--color-warning));
--color-input: hsl(var(--color-input));
--color-ring: hsl(var(--color-ring));
/* Legacy aliases */
--color-content: hsl(var(--color-surface));
--color-content-hover: hsl(var(--color-surface-hover));
--color-content-page: hsl(var(--color-background));
--color-menu: hsl(var(--color-muted));
--color-menu-hover: hsl(var(--color-surface-hover));
--color-theme: hsl(var(--color-foreground));
--color-theme-secondary: hsl(var(--color-muted-foreground));
--color-primary-btn: hsl(var(--color-primary));
--color-primary-btn-text: hsl(var(--color-primary-foreground));
/* Border radius */
--radius-none: 0;
--radius-sm: var(--radius-sm, 0.25rem);
--radius-DEFAULT: var(--radius, 0.375rem);
--radius-md: var(--radius-md, 0.5rem);
--radius-lg: var(--radius-lg, 0.75rem);
--radius-xl: var(--radius-xl, 1rem);
--radius-2xl: var(--radius-2xl, 1.5rem);
--radius-3xl: var(--radius-3xl, 2rem);
--radius-full: 9999px;
/* Box shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-DEFAULT: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
--shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
--shadow-none: none;
/* Font families */
--font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', Menlo, Monaco, Consolas, monospace;
/* Transition durations */
--duration-250: 250ms;
--duration-350: 350ms;
--duration-400: 400ms;
/* Animations */
--animate-spin-slow: spin 3s linear infinite;
--animate-pulse-slow: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
--animate-bounce-slow: bounce 2s infinite;
--animate-fade-in: fadeIn 0.2s ease-out;
--animate-fade-out: fadeOut 0.2s ease-in;
--animate-slide-in: slideIn 0.2s ease-out;
--animate-slide-out: slideOut 0.2s ease-in;
}
/* ===== Keyframes ===== */
@keyframes fadeIn {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fadeOut {
0% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes slideIn {
0% { transform: translateY(-10px); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
@keyframes slideOut {
0% { transform: translateY(0); opacity: 1; }
100% { transform: translateY(-10px); opacity: 0; }
}

View file

@ -5,12 +5,10 @@
* Variables are set by @manacore/shared-theme's createThemeStore() at runtime,
* but this file provides sensible defaults for static rendering.
*
* Usage in app.css:
* Usage in app.css (Tailwind v4):
* ```css
* @import '@manacore/shared-tailwind/themes.css';
* @tailwind base;
* @tailwind components;
* @tailwind utilities;
* @import "tailwindcss";
* @import "@manacore/shared-tailwind/themes.css";
* ```
*
* Color format: HSL values without hsl() wrapper
@ -18,6 +16,105 @@
* Used as: hsl(var(--color-primary))
*/
/* ===== Tailwind v4 Theme Configuration ===== */
@theme {
/* Brand color */
--color-mana: #4287f5;
/* Semantic colors using CSS variables */
--color-background: hsl(var(--color-background));
--color-foreground: hsl(var(--color-foreground));
--color-primary: hsl(var(--color-primary));
--color-primary-foreground: hsl(var(--color-primary-foreground));
--color-secondary: hsl(var(--color-secondary));
--color-secondary-foreground: hsl(var(--color-secondary-foreground));
--color-surface: hsl(var(--color-surface));
--color-surface-hover: hsl(var(--color-surface-hover));
--color-surface-elevated: hsl(var(--color-surface-elevated));
--color-muted: hsl(var(--color-muted));
--color-muted-foreground: hsl(var(--color-muted-foreground));
--color-border: hsl(var(--color-border));
--color-border-strong: hsl(var(--color-border-strong));
--color-error: hsl(var(--color-error));
--color-success: hsl(var(--color-success));
--color-warning: hsl(var(--color-warning));
--color-input: hsl(var(--color-input));
--color-ring: hsl(var(--color-ring));
/* Legacy aliases */
--color-content: hsl(var(--color-surface));
--color-content-hover: hsl(var(--color-surface-hover));
--color-content-page: hsl(var(--color-background));
--color-menu: hsl(var(--color-muted));
--color-menu-hover: hsl(var(--color-surface-hover));
--color-theme: hsl(var(--color-foreground));
--color-theme-secondary: hsl(var(--color-muted-foreground));
--color-primary-btn: hsl(var(--color-primary));
--color-primary-btn-text: hsl(var(--color-primary-foreground));
/* Border radius */
--radius-none: 0;
--radius-sm: 0.25rem;
--radius-DEFAULT: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-2xl: 1.5rem;
--radius-3xl: 2rem;
--radius-full: 9999px;
/* Box shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-DEFAULT: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
--shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
--shadow-none: none;
/* Font families */
--font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', Menlo, Monaco, Consolas, monospace;
/* Animations */
--animate-spin-slow: spin 3s linear infinite;
--animate-pulse-slow: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
--animate-bounce-slow: bounce 2s infinite;
--animate-fade-in: fadeIn 0.2s ease-out;
--animate-fade-out: fadeOut 0.2s ease-in;
--animate-slide-in: slideIn 0.2s ease-out;
--animate-slide-out: slideOut 0.2s ease-in;
}
/* ===== Keyframes ===== */
@keyframes fadeIn {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fadeOut {
0% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes slideIn {
0% { transform: translateY(-10px); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
@keyframes slideOut {
0% { transform: translateY(0); opacity: 1; }
100% { transform: translateY(-10px); opacity: 0; }
}
/* ===== Default Theme (Lume Light) ===== */
:root {
/* Primary brand color */

View file

@ -3,13 +3,27 @@
"version": "0.1.0",
"private": true,
"type": "module",
"svelte": "./src/index.ts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./ThemeToggle.svelte": "./src/ThemeToggle.svelte",
"./ThemeSelector.svelte": "./src/ThemeSelector.svelte",
"./ThemeModeSelector.svelte": "./src/ThemeModeSelector.svelte"
".": {
"svelte": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./ThemeToggle.svelte": {
"svelte": "./src/ThemeToggle.svelte",
"default": "./src/ThemeToggle.svelte"
},
"./ThemeSelector.svelte": {
"svelte": "./src/ThemeSelector.svelte",
"default": "./src/ThemeSelector.svelte"
},
"./ThemeModeSelector.svelte": {
"svelte": "./src/ThemeModeSelector.svelte",
"default": "./src/ThemeModeSelector.svelte"
}
},
"peerDependencies": {
"svelte": "^5.0.0"

View file

@ -1,15 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"types": ["svelte"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules"]
}

View file

@ -3,14 +3,29 @@
"version": "0.1.0",
"private": true,
"type": "module",
"svelte": "./src/index.ts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./store": "./src/store.svelte.ts",
"./types": "./src/types.ts",
"./constants": "./src/constants.ts",
"./utils": "./src/utils.ts"
".": {
"svelte": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./store": {
"svelte": "./src/store.svelte.ts",
"default": "./src/store.svelte.ts"
},
"./types": {
"types": "./src/types.ts",
"default": "./src/types.ts"
},
"./constants": {
"default": "./src/constants.ts"
},
"./utils": {
"default": "./src/utils.ts"
}
},
"peerDependencies": {
"svelte": "^5.0.0"

View file

@ -1,16 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"types": ["svelte"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules"]
}

View file

@ -1,17 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020"],
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist"
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules"]
}

View file

@ -7,10 +7,26 @@
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./atoms": "./src/atoms/index.ts",
"./molecules": "./src/molecules/index.ts",
"./organisms": "./src/organisms/index.ts"
".": {
"svelte": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./atoms": {
"svelte": "./src/atoms/index.ts",
"types": "./src/atoms/index.ts",
"default": "./src/atoms/index.ts"
},
"./molecules": {
"svelte": "./src/molecules/index.ts",
"types": "./src/molecules/index.ts",
"default": "./src/molecules/index.ts"
},
"./organisms": {
"svelte": "./src/organisms/index.ts",
"types": "./src/organisms/index.ts",
"default": "./src/organisms/index.ts"
}
},
"peerDependencies": {
"svelte": "^5.0.0"

View file

@ -41,6 +41,7 @@
const isInteractive = $derived(interactive || !!onclick);
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="card card--{variant} card--padding-{padding} {isInteractive ? 'card--interactive' : ''} {fullWidth ? 'card--full-width' : ''} {className}"
{onclick}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
type TextVariant = 'body' | 'body-secondary' | 'small' | 'large' | 'muted';
type TextAlign = 'left' | 'center' | 'right';
@ -10,7 +11,7 @@
align?: TextAlign;
weight?: TextWeight;
class?: string;
children?: any;
children?: Snippet;
}
let {

View file

@ -137,6 +137,8 @@
<!-- Actions -->
{#if actions}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="data-card__actions flex-shrink-0 flex items-center gap-1" onclick={(e) => e.stopPropagation()}>
{@render actions()}
</div>

View file

@ -13,6 +13,8 @@
required?: boolean;
autocomplete?: HTMLInputAttributes['autocomplete'];
class?: string;
id?: string;
name?: string;
}
let {
@ -26,7 +28,9 @@
disabled = false,
required = false,
autocomplete,
class: className = ''
class: className = '',
id = `input-${Math.random().toString(36).slice(2, 9)}`,
name
}: Props = $props();
function handleInput(e: Event) {
@ -43,15 +47,17 @@
<div class="flex flex-col gap-1.5 {className}">
{#if label}
<label class="text-sm font-medium text-theme">
<label for={id} class="text-sm font-medium text-theme">
{label}
{#if required}
<span class="text-red-500">*</span>
{/if}
</label>
{/if}
<input
{id}
{name}
{type}
{value}
{placeholder}

View file

@ -1,9 +1,5 @@
<script lang="ts">
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
import type { SelectOption } from './Select.types';
interface Props {
/** Current selected value */
@ -24,6 +20,8 @@
required?: boolean;
/** Additional CSS classes */
class?: string;
/** Unique ID for accessibility */
id?: string;
}
let {
@ -35,7 +33,8 @@
error,
disabled = false,
required = false,
class: className = ''
class: className = '',
id = `select-${Math.random().toString(36).slice(2, 9)}`
}: Props = $props();
function handleChange(e: Event) {
@ -47,7 +46,7 @@
<div class="select-wrapper {className}">
{#if label}
<label class="select-label">
<label for={id} class="select-label">
{label}
{#if required}
<span class="select-required">*</span>
@ -57,6 +56,7 @@
<div class="select-container">
<select
{id}
{value}
{disabled}
{required}

View file

@ -0,0 +1,5 @@
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}

View file

@ -26,6 +26,8 @@
autoResize?: boolean;
/** Additional CSS classes */
class?: string;
/** Unique ID for accessibility */
id?: string;
}
let {
@ -41,7 +43,8 @@
disabled = false,
required = false,
autoResize = false,
class: className = ''
class: className = '',
id = `textarea-${Math.random().toString(36).slice(2, 9)}`
}: Props = $props();
let textareaElement: HTMLTextAreaElement | null = $state(null);
@ -68,7 +71,7 @@
<div class="textarea-wrapper {className}">
{#if label}
<label class="textarea-label">
<label for={id} class="textarea-label">
{label}
{#if required}
<span class="textarea-required">*</span>
@ -77,6 +80,7 @@
{/if}
<textarea
{id}
bind:this={textareaElement}
{value}
{placeholder}

View file

@ -27,6 +27,7 @@
: 'bg-menu'} {disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}"
role="switch"
aria-checked={isOn}
aria-label="Toggle"
{disabled}
>
<span

View file

@ -69,7 +69,7 @@
onclick={handleClick}
onkeydown={handleKeyDown}
role={clickable ? 'button' : undefined}
tabindex={clickable ? 0 : -1}
tabindex={clickable ? 0 : undefined}
>
<!-- Color indicator dot -->
<div class="h-2 w-2 rounded-full" style="background-color: {tagColor}"></div>

View file

@ -1,15 +1,5 @@
<script lang="ts">
import type { Snippet } from 'svelte';
export interface AppItem {
name: string;
description: string;
longDescription: string;
icon?: string;
color: string;
comingSoon?: boolean;
status: 'published' | 'beta' | 'development' | 'planning';
}
import type { AppItem } from './AppSlider.types';
interface Props {
apps: AppItem[];
@ -120,17 +110,10 @@
<div class="flex gap-4 justify-center overflow-x-auto pb-6 scrollbar-hide snap-x snap-mandatory scroll-smooth px-4 py-4" style="perspective: 1000px;">
{#each apps as app, index}
<button
class="group relative flex-shrink-0 rounded-2xl p-5 cursor-pointer snap-center"
style="width: 160px; background-color: {hoveredApp === index
? isDark
? 'rgba(255, 255, 255, 0.08)'
: 'rgba(0, 0, 0, 0.08)'
: isDark
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.05)'}; border: 1px solid {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}; box-shadow: 0 4px 20px rgba(0, 0, 0, {isDark ? '0.3' : '0.15'}); transform: perspective(1000px) rotateX({cardRotations[index]?.rotateX || 0}deg) rotateY({cardRotations[index]?.rotateY || 0}deg); transform-style: preserve-3d; transition: transform 0.1s ease-out, background-color 0.2s ease-out;"
class="group relative flex-shrink-0 rounded-xl p-5 cursor-pointer snap-center transition-transform hover:scale-105"
style="width: 160px; background-color: {isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.7)'}; backdrop-filter: blur(10px); border: 1px solid {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"
onmouseenter={() => hoveredApp = index}
onmousemove={(e) => handleCardMouseMove(e, index, e.currentTarget)}
onmouseleave={() => { handleCardMouseLeave(index); hoveredApp = null; }}
onmouseleave={() => hoveredApp = null}
onclick={() => openModal(index)}
>
<div

View file

@ -0,0 +1,9 @@
export interface AppItem {
name: string;
description: string;
longDescription: string;
icon?: string;
color: string;
comingSoon?: boolean;
status: 'published' | 'beta' | 'development' | 'planning';
}

View file

@ -42,16 +42,21 @@
{#if visible}
<!-- Modal Backdrop -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Enter' && handleBackdropClick(e as unknown as MouseEvent)}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<!-- Modal Content -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative flex max-h-[90vh] w-full {maxWidthClasses[maxWidth]} flex-col rounded-xl border border-theme bg-menu shadow-xl"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
{#if showHeader}
<!-- Header -->

View file

@ -1,17 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"declaration": true,
"declarationMap": true,
"types": ["svelte"]
},
"include": ["src/**/*"],

View file

@ -1,18 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM"],
"lib": ["ES2022", "DOM"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist"
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules"]
}