mlstocks / frontend /src /components /CreateModelModal.vue
github-actions[bot]
Deploy to Hugging Face Space
abf702c
<script setup>
import {
X,
ChevronRight,
ChevronLeft,
Check,
Database,
Cpu,
Settings,
Eye,
BarChart,
Brain,
History,
TrendingUp,
Activity
} from 'lucide-vue-next';
import { ref, computed, watch } from 'vue';
const props = defineProps({
isOpen: Boolean,
universe: Array, // Symbols from AIBuilder
builderType: String, // 'classic' or 'neural'
initialType: String // 'Technical' or 'Fundamental'
});
const emit = defineEmits(['close', 'startTraining']);
const currentStep = ref(1);
const totalSteps = 6;
const steps = [
{ id: 1, name: 'Type', icon: Brain },
{ id: 2, name: 'Assets', icon: Database },
{ id: 3, name: 'Features', icon: BarChart },
{ id: 4, name: 'Algorithm', icon: Cpu },
{ id: 5, name: 'Config', icon: Settings },
{ id: 6, name: 'Review', icon: Eye },
];
// Form State
const form = ref({
type: 'Technical',
assetClass: 'Stock',
selectedSymbol: null, // Changed from array to single value
selectedFeatures: [],
selectedModels: [],
config: {
period: '2y',
testSize: 0.2
}
});
// Watch for initialType changes and update form
watch(() => props.initialType, (newType) => {
if (newType && props.isOpen) {
form.value.type = newType;
}
}, { immediate: true });
// Reset form type when modal opens
watch(() => props.isOpen, (isOpen) => {
if (isOpen && props.initialType) {
form.value.type = props.initialType;
}
});
const assetClasses = ['Stock', 'Index', 'Future', 'Crypto', 'ETF'];
// Technical Indicators
const technicalFeatures = [
// Momentum
{ id: 'rsi', name: 'RSI (14)', category: 'Momentum' },
{ id: 'stoch_k', name: 'Stoch %K', category: 'Momentum' },
{ id: 'stoch_d', name: 'Stoch %D', category: 'Momentum' },
{ id: 'williams', name: 'Williams %R', category: 'Momentum' },
{ id: 'cci', name: 'CCI', category: 'Momentum' },
// Trend
{ id: 'sma_20', name: 'SMA 20', category: 'Trend' },
{ id: 'sma_50', name: 'SMA 50', category: 'Trend' },
{ id: 'sma_200', name: 'SMA 200', category: 'Trend' },
{ id: 'ema_9', name: 'EMA 9', category: 'Trend' },
{ id: 'ema_21', name: 'EMA 21', category: 'Trend' },
{ id: 'macd', name: 'MACD Base', category: 'Trend' },
{ id: 'macd_signal', name: 'MACD Signal', category: 'Trend' },
{ id: 'macd_hist', name: 'MACD Hist', category: 'Trend' },
{ id: 'ichimoku_a', name: 'Ichimoku A', category: 'Trend' },
{ id: 'ichimoku_b', name: 'Ichimoku B', category: 'Trend' },
// Volatility
{ id: 'bb_high', name: 'BB High', category: 'Volatility' },
{ id: 'bb_low', name: 'BB Low', category: 'Volatility' },
{ id: 'atr', name: 'Average True Range', category: 'Volatility' },
{ id: 'keltner_h', name: 'Keltner High', category: 'Volatility' },
{ id: 'donchian_h', name: 'Donchian High', category: 'Volatility' },
// Volume
{ id: 'volume', name: 'Raw Volume', category: 'Volume' },
{ id: 'obv', name: 'On-Balance Vol', category: 'Volume' },
{ id: 'vwap', name: 'VWAP', category: 'Volume' },
{ id: 'cmf', name: 'Chaikin MF', category: 'Volume' },
];
// Fundamental Indicators
const fundamentalFeatures = [
{ id: 'pe', name: 'P/E Ratio', category: 'Valuation' },
{ id: 'pb', name: 'P/B Ratio', category: 'Valuation' },
{ id: 'rev_growth', name: 'Revenue Growth', category: 'Performance' },
{ id: 'eps', name: 'EPS', category: 'Performance' },
{ id: 'debt_equity', name: 'Debt/Equity', category: 'Solvency' },
{ id: 'roe', name: 'ROE', category: 'Profitability' },
{ id: 'curr_ratio', name: 'Current Ratio', category: 'Liquidity' },
{ id: 'div_yield', name: 'Dividend Yield', category: 'Valuation' },
];
const filteredFeatures = computed(() => {
return form.value.type === 'Technical' ? technicalFeatures : fundamentalFeatures;
});
// Classic ML Models
const classicModels = [
{ id: 'rf', name: 'Random Forest', description: 'Ensemble of decision trees' },
{ id: 'xgb', name: 'XGBoost', description: 'Gradient boosting framework' },
{ id: 'lr', name: 'Linear Regression', description: 'Baseline linear model' },
{ id: 'svm', name: 'SVM', description: 'Support Vector Machines' },
];
// Neural DL Models
const neuralModels = [
{ id: 'transformer', name: 'Transformer', description: 'Attention-based sequence model' },
{ id: 'lstm', name: 'LSTM RNN', description: 'Long Short-Term Memory' },
{ id: 'cnn', name: 'CNN 1D', description: 'Convolutional neural network' },
{ id: 'mlp', name: 'Neural Network (MLP)', description: 'Multi-layer Perceptron' },
];
const filteredModels = computed(() => {
return props.builderType === 'classic' ? classicModels : neuralModels;
});
const nextStep = () => {
if (currentStep.value < totalSteps) currentStep.value++;
};
const prevStep = () => {
if (currentStep.value > 1) currentStep.value--;
};
const toggleSelection = (list, item) => {
const idx = list.indexOf(item);
if (idx > -1) list.splice(idx, 1);
else list.push(item);
};
const handleStart = () => {
emit('startTraining', { ...form.value, builderType: props.builderType });
emit('close');
reset();
};
const reset = () => {
currentStep.value = 1;
form.value.selectedSymbol = null; // Reset to null
form.value.selectedFeatures = [];
form.value.selectedModels = [];
};
</script>
<template>
<div v-if="isOpen" class="fixed inset-0 z-[110] flex items-center justify-center md:p-4">
<div class="absolute inset-0 bg-slate-950/95 backdrop-blur-md" @click="$emit('close')"></div>
<div class="relative w-full h-full md:h-auto md:max-w-4xl bg-[#0b1120] border-0 md:border md:border-slate-800 md:rounded-3xl shadow-2xl overflow-hidden flex flex-col md:max-h-[90vh]">
<!-- MODAL HEADER -->
<div class="flex items-center justify-between px-4 md:px-8 py-4 md:py-6 border-b border-slate-800 bg-slate-900/40">
<div class="flex items-center gap-3 md:gap-4">
<div class="hidden sm:flex w-10 h-10 bg-blue-600/10 rounded-xl items-center justify-center border border-blue-500/20 text-blue-400">
<component :is="builderType === 'classic' ? Cpu : Brain" class="w-5 h-5" />
</div>
<div>
<h2 class="text-base md:text-xl font-bold text-white tracking-tight">Create {{ builderType === 'classic' ? 'ML' : 'DL' }} Model</h2>
<div class="flex items-center gap-2 mt-0.5">
<span class="px-2 py-0.5 bg-indigo-500/10 text-indigo-400 text-[9px] md:text-[10px] font-black rounded-lg border border-indigo-500/20 uppercase tracking-widest">
{{ form.type }} Engine
</span>
</div>
</div>
</div>
<button @click="$emit('close')" class="p-2 hover:bg-slate-800 rounded-xl text-slate-500 hover:text-white transition-all">
<X class="w-5 h-5 md:w-6 md:h-6" />
</button>
</div>
<!-- STEPPER: Optimized for Mobile -->
<div class="bg-slate-950 px-4 md:px-8 py-4 border-b border-slate-800 flex justify-between gap-1 overflow-x-auto custom-scrollbar">
<div v-for="s in steps" :key="s.id" class="flex items-center gap-2 md:gap-3 shrink-0 group">
<div
class="flex items-center justify-center w-8 h-8 md:w-10 md:h-10 rounded-xl transition-all duration-300 border shadow-inner"
:class="[
currentStep === s.id ? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-600/20 scale-105' :
currentStep > s.id ? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400' : 'bg-slate-900 border-slate-800 text-slate-600'
]"
>
<Check v-if="currentStep > s.id" class="w-4 h-4 md:w-5 md:h-5" />
<component v-else :is="s.icon" class="w-4 h-4 md:w-5 md:h-5" />
</div>
<span v-if="currentStep === s.id" class="text-[10px] md:text-xs font-black uppercase tracking-widest text-white hidden xs:block">
{{ s.name }}
</span>
<div v-if="s.id < totalSteps" class="w-4 md:w-8 h-px bg-slate-800 mx-1 md:mx-2"></div>
</div>
</div>
<!-- CONTENT: Mobile Scroll and Spacing -->
<div class="flex-1 overflow-y-auto p-4 md:p-10 custom-scrollbar">
<!-- Step 1: Type -->
<div v-if="currentStep === 1" class="space-y-6 md:space-y-8 animate-in slide-in-from-bottom duration-300">
<div class="space-y-1 md:space-y-2">
<h3 class="text-xl md:text-3xl font-black text-white tracking-tight">Select Logic Engine</h3>
<p class="text-xs md:text-base text-slate-400 max-w-lg">Choose between historical price patterns or fundamental health metrics.</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 md:gap-6">
<div
@click="form.type = 'Technical'"
class="p-6 md:p-8 rounded-2xl md:rounded-3xl border-2 transition-all cursor-pointer group"
:class="form.type === 'Technical' ? 'bg-blue-600/10 border-blue-500' : 'bg-slate-900 border-slate-800 hover:border-slate-700'"
>
<div class="w-12 h-12 md:w-16 md:h-16 bg-blue-500/10 rounded-2xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
<TrendingUp class="w-6 h-6 md:w-8 md:h-8" :class="form.type === 'Technical' ? 'text-blue-400' : 'text-slate-600'" />
</div>
<h4 class="font-bold text-lg md:text-xl text-white mb-2">Technical Analysis</h4>
<p class="text-xs md:text-sm text-slate-500 leading-relaxed">Predict next-day price movements using RSI, MACD, and Volume signals.</p>
</div>
<div
@click="form.type = 'Fundamental'"
class="p-6 md:p-8 rounded-2xl md:rounded-3xl border-2 transition-all cursor-pointer group"
:class="form.type === 'Fundamental' ? 'bg-emerald-600/10 border-emerald-500' : 'bg-slate-900 border-slate-800 hover:border-slate-700'"
>
<div class="w-12 h-12 md:w-16 md:h-16 bg-emerald-500/10 rounded-2xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
<Activity class="w-6 h-6 md:w-8 md:h-8" :class="form.type === 'Fundamental' ? 'text-emerald-400' : 'text-slate-600'" />
</div>
<h4 class="font-bold text-lg md:text-xl text-white mb-2">Fundamental Health</h4>
<p class="text-xs md:text-sm text-slate-500 leading-relaxed">Assess long-term growth by training on P/E, EPS, and Revenue trends.</p>
</div>
</div>
</div>
<!-- Step 2: Assets -->
<div v-if="currentStep === 2" class="space-y-6 md:space-y-10 animate-in slide-in-from-bottom duration-300">
<div class="space-y-2">
<h3 class="text-xl md:text-3xl font-black text-white tracking-tight">Target Selection</h3>
<p class="text-xs md:text-base text-slate-400">Choose the specific instrument for the training pipeline.</p>
</div>
<div class="space-y-4">
<label class="text-[10px] md:text-xs font-black text-slate-600 uppercase tracking-widest block">Sector / Asset Class</label>
<div class="flex flex-wrap gap-2 md:gap-3">
<button
v-for="ac in assetClasses" :key="ac"
@click="form.assetClass = ac"
class="px-4 py-2 md:px-6 md:py-2.5 rounded-xl text-[10px] md:text-xs font-bold transition-all border shadow-sm"
:class="form.assetClass === ac ? 'bg-white text-slate-950 border-white font-black' : 'bg-slate-900/50 text-slate-400 border-slate-800 hover:border-slate-700'"
>
{{ ac }}
</button>
</div>
</div>
<div class="space-y-4">
<label class="text-[10px] md:text-xs font-black text-slate-600 uppercase tracking-widest block">Available Universe</label>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
<div
v-for="s in universe" :key="s"
@click="form.selectedSymbol = s"
class="p-4 rounded-2xl border-2 cursor-pointer transition-all flex flex-col gap-2 group relative overflow-hidden"
:class="form.selectedSymbol === s ? 'bg-blue-600 border-blue-500 shadow-xl shadow-blue-600/20' : 'bg-slate-900/50 border-slate-800 hover:border-slate-700'"
>
<span class="text-sm md:text-lg font-black tracking-tighter" :class="form.selectedSymbol === s ? 'text-white' : 'text-slate-300'">{{ s }}</span>
<span class="text-[8px] md:text-[10px] font-bold uppercase" :class="form.selectedSymbol === s ? 'text-blue-200' : 'text-slate-600'">Equity</span>
<div v-if="form.selectedSymbol === s" class="absolute top-2 right-2 w-4 h-4 bg-white rounded-full flex items-center justify-center">
<Check class="w-3 h-3 text-blue-600" />
</div>
</div>
</div>
<div v-if="universe.length === 0" class="p-6 bg-amber-500/5 border border-amber-500/20 rounded-2xl text-center">
<Info class="w-6 h-6 text-amber-500 mx-auto mb-2" />
<p class="text-xs text-amber-500 italic max-w-xs mx-auto">No symbols found in the primary vault. Please add assets via the Data Hub first.</p>
</div>
</div>
</div>
<!-- Step 3: Features -->
<div v-if="currentStep === 3" class="space-y-6 md:space-y-8 animate-in slide-in-from-bottom duration-300">
<div class="space-y-2">
<h3 class="text-xl md:text-3xl font-black text-white tracking-tight">Input Parameters</h3>
<p class="text-xs md:text-base text-slate-400">Select the features to feed into the neural architecture.</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 md:gap-4">
<div
v-for="f in filteredFeatures" :key="f.id"
@click="toggleSelection(form.selectedFeatures, f.id)"
class="flex items-center justify-between p-4 md:p-6 bg-slate-900 border border-slate-800 rounded-2xl md:rounded-3xl hover:bg-slate-800/50 transition-all cursor-pointer group"
:class="form.selectedFeatures.includes(f.id) ? 'border-blue-500/50 bg-blue-500/5' : ''"
>
<div class="flex flex-col">
<span class="text-sm md:text-base font-bold text-white group-hover:text-blue-400 transition-colors">{{ f.name }}</span>
<span class="text-[9px] md:text-[10px] text-slate-500 uppercase font-black tracking-widest mt-0.5">{{ f.category }}</span>
</div>
<div class="w-6 h-6 md:w-7 md:h-7 rounded-lg flex items-center justify-center border-2 transition-all" :class="form.selectedFeatures.includes(f.id) ? 'bg-blue-600 border-blue-500 shadow-lg shadow-blue-500/20' : 'bg-slate-950 border-slate-800 group-hover:border-slate-700'">
<Check v-if="form.selectedFeatures.includes(f.id)" class="w-3.5 h-3.5 md:w-4 md:h-4 text-white" />
</div>
</div>
</div>
</div>
<!-- Step 4: Algorithm -->
<div v-if="currentStep === 4" class="space-y-6 md:space-y-8 animate-in slide-in-from-bottom duration-300">
<div class="space-y-2">
<h3 class="text-xl md:text-3xl font-black text-white">Algorithms</h3>
<p class="text-sm text-slate-500">Choose the optimal processing model for your data type.</p>
</div>
<div class="space-y-3 md:space-y-4">
<div
v-for="m in filteredModels" :key="m.id"
@click="toggleSelection(form.selectedModels, m.id)"
class="p-4 md:p-6 rounded-2xl md:rounded-3xl border-2 transition-all cursor-pointer flex items-center gap-4 md:gap-6"
:class="form.selectedModels.includes(m.id) ? 'bg-indigo-600/10 border-indigo-500 shadow-xl shadow-indigo-600/10' : 'bg-slate-900 border-slate-800 hover:border-slate-700'"
>
<div class="w-12 h-12 md:w-16 md:h-16 bg-slate-800 rounded-2xl flex items-center justify-center shrink-0 border border-slate-700">
<Cpu class="w-6 h-6 md:w-8 md:h-8" :class="form.selectedModels.includes(m.id) ? 'text-indigo-400' : 'text-slate-600'" />
</div>
<div class="flex-1">
<h4 class="font-bold text-base md:text-lg text-white mb-0.5">{{ m.name }}</h4>
<p class="text-[10px] md:text-sm text-slate-500 leading-snug">{{ m.description }}</p>
</div>
<div class="w-6 h-6 md:w-7 md:h-7 rounded-full border-2 flex items-center justify-center transition-all" :class="form.selectedModels.includes(m.id) ? 'bg-indigo-500 border-indigo-500 shadow-lg' : 'bg-slate-950 border-slate-800'">
<Check v-if="form.selectedModels.includes(m.id)" class="w-3.5 h-3.5 text-white" />
</div>
</div>
</div>
</div>
<!-- Step 5: Config -->
<div v-if="currentStep === 5" class="space-y-8 md:space-y-12 animate-in slide-in-from-bottom duration-300">
<div class="space-y-2">
<h3 class="text-xl md:text-3xl font-black text-white">Hyperparameters</h3>
<p class="text-sm text-slate-500">Fine-tune the training pipeline and temporal constraints.</p>
</div>
<div class="grid grid-cols-1 gap-8 md:gap-12">
<div class="space-y-4">
<label class="text-[10px] md:text-xs font-black text-slate-500 uppercase tracking-widest flex items-center gap-2">
<History class="w-4 h-4 text-blue-400" /> Historical Period
</label>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<button v-for="p in ['6m', '1y', '2y', '5y']" :key="p" @click="form.config.period = p" :class="form.config.period === p ? 'bg-blue-600 border-blue-500 text-white shadow-lg font-black' : 'bg-slate-900 border-slate-800 text-slate-500 hover:text-white'" class="py-3 md:py-4 rounded-xl border-2 text-xs md:text-sm transition-all">{{ p.toUpperCase() }}</button>
</div>
</div>
<div class="space-y-6">
<div class="flex justify-between items-center">
<label class="text-[10px] md:text-xs font-black text-slate-500 uppercase tracking-widest flex items-center gap-2">
<TrendingUp class="w-4 h-4 text-emerald-400" /> Train/Test Partitioning
</label>
<span class="text-xs font-bold text-blue-400 bg-blue-500/10 px-3 py-1 rounded-lg border border-blue-500/20">{{ Math.round((1 - form.config.testSize) * 100) }}% Train / {{ Math.round(form.config.testSize * 100) }}% Test</span>
</div>
<input type="range" v-model.number="form.config.testSize" min="0.1" max="0.5" step="0.05" class="w-full h-2 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-blue-500" />
<div class="flex justify-between text-[10px] font-bold text-slate-600">
<span>90/10 Split</span>
<span>50/50 Split</span>
</div>
</div>
</div>
</div>
<!-- Step 6: Review -->
<div v-if="currentStep === 6" class="space-y-6 md:space-y-10 animate-in slide-in-from-bottom duration-300">
<div class="text-center space-y-3">
<div class="w-20 h-20 bg-blue-600/10 rounded-[2.5rem] flex items-center justify-center mx-auto border border-blue-500/20 shadow-2xl relative">
<Eye class="w-10 h-10 text-blue-400" />
<div class="absolute -bottom-1 -right-1 w-8 h-8 bg-emerald-500 rounded-full flex items-center justify-center border-4 border-[#0b1120]">
<Check class="w-4 h-4 text-white" />
</div>
</div>
<div>
<h3 class="text-2xl md:text-3xl font-black text-white">Consolidate Task</h3>
<p class="text-xs md:text-sm text-slate-500">Ready to initiate the training sequence?</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="bg-slate-900/50 p-5 md:p-6 rounded-2xl border border-slate-800 flex items-center gap-4">
<div class="w-10 h-10 bg-blue-500/10 rounded-xl flex items-center justify-center text-blue-400 border border-blue-500/20">
<Target class="w-5 h-5" />
</div>
<div>
<div class="text-[9px] font-black text-slate-500 uppercase tracking-[.2em] mb-1">Target Asset</div>
<div class="text-base font-bold text-white">{{ form.selectedSymbol || 'None' }}</div>
</div>
</div>
<div class="bg-slate-900/50 p-5 md:p-6 rounded-2xl border border-slate-800 flex items-center gap-4">
<div class="w-10 h-10 bg-indigo-500/10 rounded-xl flex items-center justify-center text-indigo-400 border border-indigo-500/20">
<Cpu class="w-5 h-5" />
</div>
<div>
<div class="text-[9px] font-black text-slate-500 uppercase tracking-[.2em] mb-1">Architectures</div>
<div class="text-base font-bold text-white">{{ form.selectedModels.length }} Selected</div>
</div>
</div>
<div class="bg-slate-900/50 p-5 md:p-6 rounded-2xl border border-slate-800 col-span-1 sm:col-span-2 space-y-2">
<div class="text-[9px] font-black text-slate-500 uppercase tracking-[.2em] flex items-center gap-2">
<BarChart class="w-3 h-3 text-emerald-400" /> Logic Vector Scope
</div>
<div class="flex flex-wrap gap-2 pt-1">
<span v-for="featId in form.selectedFeatures" :key="featId" class="px-2 py-1 bg-slate-950 border border-slate-700/50 rounded-lg text-[10px] font-bold text-slate-300">{{ featId.toUpperCase() }}</span>
<span v-if="form.selectedFeatures.length === 0" class="text-xs italic text-slate-600">No features mapped</span>
</div>
</div>
</div>
</div>
</div>
<!-- FOOTER: Mobile Optimized Height and Spacing -->
<div class="px-4 md:px-10 py-4 md:py-8 border-t border-slate-800 bg-slate-900/40 flex flex-col sm:flex-row gap-4 items-center justify-between">
<div class="flex items-center gap-2 md:gap-4 order-2 sm:order-1 w-full sm:w-auto">
<button
@click="prevStep"
:disabled="currentStep === 1"
class="flex-1 sm:flex-none flex items-center justify-center gap-2 px-6 py-3 bg-slate-800 hover:bg-slate-700 disabled:opacity-30 text-white font-bold rounded-2xl transition-all border border-slate-700"
>
<ChevronLeft class="w-4 h-4" /> <span class="hidden md:inline">Back</span>
</button>
<div class="hidden md:flex flex-col">
<span class="text-[10px] font-black text-slate-500 uppercase tracking-widest">Pipeline Phase</span>
<span class="text-sm font-bold text-white">{{ currentStep }} / {{ totalSteps }}</span>
</div>
</div>
<div class="w-full sm:w-auto order-1 sm:order-2">
<button
v-if="currentStep < totalSteps"
@click="nextStep"
class="w-full sm:w-auto flex items-center justify-center gap-2 px-10 py-3 md:py-4 bg-blue-600 hover:bg-blue-500 text-white font-black rounded-2xl transition-all shadow-xl shadow-blue-600/20 active:scale-95"
>
Carry On <ChevronRight class="w-5 h-5" />
</button>
<button
v-else
@click="handleStart"
class="w-full sm:w-auto flex items-center justify-center gap-2 px-12 py-3 md:py-4 bg-indigo-600 hover:bg-indigo-500 text-white font-black rounded-2xl transition-all shadow-xl shadow-indigo-600/20 active:scale-95"
>
Initiate Training <Zap class="w-5 h-5" />
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.xs\:block { display: none; }
@media (min-width: 480px) { .xs\:block { display: block; } }
.custom-scrollbar::-webkit-scrollbar { width: 5px; height: 5px; }
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 10px; }
.animate-in {
animation-duration: 0.4s;
animation-fill-mode: both;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideInBottom {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.slide-in-from-bottom { animation-name: slideInBottom; }
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
cursor: pointer;
border: 4px solid #3b82f6;
box-shadow: 0 0 10px rgba(59, 130, 246, 0.5);
}
</style>