HomePilot / frontend /src /ui /avatar /wizard /CharacterWizard.jsx
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
90.6 kB
/**
* CharacterWizard β€” 7-step professional character creation wizard.
*
* Steps: Identity, Body, Face, Hair, Profession, Outfit, Generate
*
* Features:
* - Quick Create mode (name + gender + profession β†’ generate)
* - Full Studio mode (all 7 steps with presets + advanced)
* - NSFW gated by global setting (Romance & Roleplay 18+)
* - Keeps 3 generation modes: Design Character, From Reference, Face + Style
* - Live character card preview (right panel)
* - Prompt builder assembles all choices into diffusion-friendly prompt
*/
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { X, ChevronRight, ChevronLeft, Sparkles, Loader2, User, Palette, Camera, CheckCircle2, EyeOff, Shuffle, Zap, Layers, Check, ChevronDown, Star, Flame, RotateCcw, Search, } from 'lucide-react';
import { DEFAULT_DRAFT, WIZARD_STEPS, SKIN_TONES, EYE_COLORS, EYE_SHAPES, FACE_PRESETS, HAIR_STYLES, HAIR_COLORS, OUTFIT_STYLES_SFW, OUTFIT_STYLES_NSFW, COLOR_PALETTE, ACCESSORIES, POSES, BACKGROUNDS, LIGHTING_OPTIONS, EXPRESSIONS, NSFW_POSES, NSFW_BACKGROUNDS, LINGERIE_GARMENTS, LINGERIE_STYLES, LINGERIE_FABRICS, LINGERIE_BRANDS, LINGERIE_EXTRAS, findPreset, } from './wizardTypes';
import { PROFESSIONS, getProfession } from './professionRegistry';
import { loadVibeTab, saveVibeTab } from '../vibeTabPersistence';
import { useGenerateAvatars } from '../useGenerateAvatars';
import { useAvatarGallery } from '../useAvatarGallery';
import { loadAvatarSettings, resolveCheckpoint } from '../AvatarSettingsPanel';
import { resolveFileUrl } from '../../resolveFileUrl';
import { AVATAR_VIBE_PRESETS, CHARACTER_STYLE_PRESETS, GENDER_OPTIONS, } from '../galleryTypes';
// ---------------------------------------------------------------------------
// Shared UI helpers
// ---------------------------------------------------------------------------
function PillButton({ label, selected, onClick, icon, accent = 'purple' }) {
const colors = {
purple: selected ? 'bg-purple-500/15 text-purple-300 border-purple-500/30' : '',
rose: selected ? 'bg-rose-500/15 text-rose-300 border-rose-500/30' : '',
};
return (<button onClick={onClick} role="option" aria-selected={selected} aria-label={label} className={[
'px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all border',
selected ? colors[accent]
: 'bg-white/[0.03] text-white/45 border-white/[0.06] hover:bg-white/[0.06] hover:text-white/65',
].join(' ')}>
{icon && <span className="mr-1.5">{icon}</span>}{label}
</button>);
}
function ColorSwatch({ color, selected, onClick, label }) {
return (<button onClick={onClick} title={label} role="option" aria-selected={selected} aria-label={label || color} className={[
'w-7 h-7 rounded-full border-2 transition-all flex-shrink-0',
selected
? 'border-purple-400 scale-110 shadow-[0_0_8px_rgba(168,85,247,0.4)]'
: 'border-white/10 hover:border-white/30 hover:scale-105',
].join(' ')} style={{ backgroundColor: color }}/>);
}
function SliderField({ label, value, min, max, step, onChange, unit }) {
return (<div className="space-y-1.5">
<div className="flex justify-between">
<span className="text-[10px] text-white/40 font-medium">{label}</span>
<span className="text-[10px] text-white/25 tabular-nums">{value}{unit}</span>
</div>
<input type="range" min={min} max={max} step={step ?? 1} value={value} onChange={(e) => onChange(Number(e.target.value))} className="w-full accent-purple-500 h-1.5 rounded-full bg-white/[0.06]"/>
</div>);
}
function SectionLabel({ children }) {
return <div className="text-[10px] text-white/35 font-semibold uppercase tracking-wider mb-3">{children}</div>;
}
function AdvancedToggle({ open, onToggle }) {
return (<button onClick={onToggle} className="flex items-center gap-1.5 text-[10px] text-white/25 hover:text-white/45 font-medium uppercase tracking-wider transition-colors mt-4 mb-2">
<ChevronRight size={10} className={`transition-transform ${open ? 'rotate-90' : ''}`}/>
Advanced
</button>);
}
// ---------------------------------------------------------------------------
// Step customization check β€” returns true if the user changed anything from defaults
// ---------------------------------------------------------------------------
function isStepCustomized(step, draft) {
const d = DEFAULT_DRAFT;
switch (step) {
case 0: return draft.gender !== d.gender || draft.ageRange !== d.ageRange || draft.name !== d.name;
case 1: return draft.bodyType !== d.bodyType || draft.skinTone !== d.skinTone || draft.posture !== d.posture;
case 2: return draft.facePreset !== d.facePreset || draft.eyeColor !== d.eyeColor || draft.expression !== d.expression;
case 3: return draft.hairStyle !== d.hairStyle || draft.hairColor !== d.hairColor;
case 4: return draft.professionId !== d.professionId;
case 5: return draft.outfitStyle !== d.outfitStyle || draft.accessories.length > 0;
case 6: return draft.portraitType !== d.portraitType || draft.background !== d.background;
default: return false;
}
}
// ---------------------------------------------------------------------------
// Prompt builder
// ---------------------------------------------------------------------------
function buildPrompt(d, nsfwEnabled) {
const parts = [];
// Portrait framing
const framing = {
headshot: 'headshot portrait, face closeup',
half_body: 'upper body portrait, from waist up',
mid_body: 'mid-body portrait, from head to hips, upper body and hip area visible, frame ends at upper thighs',
full_body: 'full body portrait',
};
parts.push(framing[d.portraitType] || framing.headshot);
// Single subject + camera
parts.push('single person, front-facing, looking at camera');
// Gender + age
const genderWord = d.gender === 'neutral' ? 'androgynous person' : d.gender === 'female' ? 'woman' : 'man';
const ageWord = d.ageRange === 'young_adult' ? 'young adult' : d.ageRange === 'mature' ? 'mature' : 'adult';
parts.push(`${ageWord} ${genderWord}`);
// Body
parts.push(`${d.bodyType} build`);
parts.push(`${d.posture} posture`);
// Skin
const skin = findPreset(SKIN_TONES, d.skinTone);
if (skin)
parts.push(skin.prompt);
// Face
const face = findPreset(FACE_PRESETS, d.facePreset);
if (face)
parts.push(face.prompt);
// Facial detail sliders (only add when notably different from default 50)
if (d.jawline >= 70)
parts.push('strong defined jawline');
else if (d.jawline <= 30)
parts.push('soft rounded jawline');
if (d.lips >= 70)
parts.push('full prominent lips');
else if (d.lips <= 30)
parts.push('thin delicate lips');
if (d.browDefinition >= 70)
parts.push('strong defined eyebrows');
else if (d.browDefinition <= 30)
parts.push('soft subtle eyebrows');
// Eyes
const eyeC = findPreset(EYE_COLORS, d.eyeColor);
if (eyeC)
parts.push(eyeC.prompt);
const eyeS = findPreset(EYE_SHAPES, d.eyeShape);
if (eyeS)
parts.push(eyeS.prompt);
// Expression
const expr = findPreset(EXPRESSIONS, d.expression);
if (expr)
parts.push(expr.prompt);
// Hair
const hairS = findPreset(HAIR_STYLES, d.hairStyle);
if (hairS)
parts.push(hairS.prompt);
const hairC = findPreset(HAIR_COLORS, d.hairColor);
if (hairC)
parts.push(hairC.prompt);
// Outfit
const outfit = findPreset([...OUTFIT_STYLES_SFW, ...OUTFIT_STYLES_NSFW], d.outfitStyle);
if (outfit)
parts.push(outfit.prompt);
// Lingerie builder (additive β€” iterates over all active garments for multi-piece outfits)
if (d.outfitStyle === 'lingerie' || d.outfitStyle === 'boudoir_wear') {
const activeGarments = d.lingerieActiveGarments ?? (d.lingerieType ? [d.lingerieType] : []);
const selections = d.lingerieSelections ?? {};
for (const gId of activeGarments) {
const garment = LINGERIE_GARMENTS.find((g) => g.id === gId);
if (garment)
parts.push(garment.prompt);
const sel = selections[gId];
if (sel?.style) {
const styles = LINGERIE_STYLES[gId];
const style = styles?.find((s) => s.id === sel.style);
if (style)
parts.push(style.prompt);
}
if (sel?.fabric) {
const fabric = LINGERIE_FABRICS.find((f) => f.id === sel.fabric);
if (fabric)
parts.push(fabric.prompt);
}
if (sel?.brand) {
const brand = LINGERIE_BRANDS.find((b) => b.id === sel.brand);
if (brand)
parts.push(brand.prompt);
}
}
for (const extId of d.lingerieExtras ?? []) {
const ext = LINGERIE_EXTRAS.find((e) => e.id === extId);
if (ext)
parts.push(ext.prompt);
}
}
// Profession context (influences scene/outfit/pose)
const prof = getProfession(d.professionId);
if (prof && prof.id !== 'custom')
parts.push(prof.label);
// Colors β€” bind to specific garment pieces when lingerie multi-piece is active
const primC = findPreset(COLOR_PALETTE, d.outfitPrimaryColor);
const secC = findPreset(COLOR_PALETTE, d.outfitSecondaryColor);
const isLingerie = d.outfitStyle === 'lingerie' || d.outfitStyle === 'boudoir_wear';
const activeGarmentsForColor = d.lingerieActiveGarments ?? (d.lingerieType ? [d.lingerieType] : []);
const hasTopPiece = isLingerie && activeGarmentsForColor.includes('top');
const hasBottomPiece = isLingerie && activeGarmentsForColor.includes('bottom');
if (hasTopPiece && hasBottomPiece && primC && secC) {
// Primary β†’ top piece, Secondary β†’ bottom piece
parts.push(`${primC.prompt} colored top`);
parts.push(`${secC.prompt} colored bottom`);
}
else if (hasTopPiece && hasBottomPiece && primC) {
parts.push(`${primC.prompt} colored top`);
}
else if (hasTopPiece && hasBottomPiece && secC) {
parts.push(`${secC.prompt} colored bottom`);
}
else {
// Generic outfit coloring (non-lingerie, or single-piece/set)
if (primC && secC)
parts.push(`${primC.prompt} and ${secC.prompt} color scheme`);
else if (primC)
parts.push(`${primC.prompt} colored outfit`);
else if (secC)
parts.push(`${secC.prompt} accents`);
}
// Accessories
for (const accId of d.accessories) {
const acc = findPreset(ACCESSORIES, accId);
if (acc)
parts.push(acc.prompt);
}
// Polish / makeup
const polishMap = {
natural: 'natural look',
light_makeup: 'light professional makeup',
formal: 'formal polished appearance, refined makeup',
};
parts.push(polishMap[d.polish] || '');
// Pose + background (check NSFW-specific poses/backgrounds first, then standard)
const pose = findPreset(NSFW_POSES, d.pose) || findPreset(POSES, d.pose);
if (pose)
parts.push(pose.prompt);
const bg = findPreset(NSFW_BACKGROUNDS, d.background) || findPreset(BACKGROUNDS, d.background);
if (bg)
parts.push(bg.prompt);
// Lighting
const light = findPreset(LIGHTING_OPTIONS, d.lighting);
if (light)
parts.push(light.prompt);
// NSFW modifiers (gated)
if (nsfwEnabled && d.nsfwExposure) {
const nsfwMap = {
suggestive: 'suggestive pose, revealing outfit, tastefully showing skin, sensual',
clothed_revealing: 'clothed but revealing, deep neckline, exposed skin, alluring',
partial_nudity: 'partial nudity, exposed skin, provocative sensual pose, alluring',
topless: 'topless, implied nude, exposed upper body, sensual artistic pose',
full_nude: 'fully nude, naked body, sensual erotic pose, artistic nude',
explicit: 'fully nude, explicit adult content, naked body, sensual erotic pose, anatomically detailed',
};
parts.push(nsfwMap[d.nsfwExposure] || '');
// Intensity amplifier
const intensity = d.nsfwIntensity ?? 5;
if (intensity >= 8)
parts.push('extremely sensual, raw, uninhibited');
else if (intensity >= 5)
parts.push('sensual, inviting');
// Pose β€” unified: quick-pick categories or specific NSFW pose presets
if (d.nsfwPose === 'subtle')
parts.push('subtle teasing pose, coy expression');
else if (d.nsfwPose === 'confident')
parts.push('confident provocative display, bold body language');
else if (d.nsfwPose === 'intimate')
parts.push('intimate close pose, bedroom eyes, inviting');
else if (d.nsfwPose) {
const posePreset = findPreset(NSFW_POSES, d.nsfwPose);
if (posePreset)
parts.push(posePreset.prompt);
}
// Fantasy tone
if (d.nsfwFantasyTone === 'romantic')
parts.push('romantic tender mood, soft warm tones');
if (d.nsfwFantasyTone === 'seductive')
parts.push('seductive alluring expression, smoldering gaze');
if (d.nsfwFantasyTone === 'dramatic')
parts.push('dramatic intense mood, powerful commanding presence');
// Dominance
if (d.nsfwDominanceStyle === 'soft')
parts.push('gentle submissive energy, yielding');
if (d.nsfwDominanceStyle === 'balanced')
parts.push('balanced confident energy, natural relaxed poise');
if (d.nsfwDominanceStyle === 'strong')
parts.push('dominant commanding presence, powerful stance, in control');
}
// Quality + detail level
const realismWord = d.realism > 70 ? 'photorealistic' : d.realism > 40 ? 'semi-realistic' : 'stylized';
const detailWord = d.detailLevel > 75 ? 'extremely detailed, intricate textures' : d.detailLevel > 50 ? 'highly detailed' : d.detailLevel > 25 ? 'moderate detail' : 'simplified, clean lines';
parts.push(`${realismWord}, ${detailWord}, 8k resolution`);
return parts.filter(Boolean).join(', ');
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function CharacterWizard({ backendUrl, apiKey, globalModelImages, onClose, onSaveGeneration }) {
const DRAFT_STORAGE_KEY = 'homepilot_wizard_draft';
const STEP_STORAGE_KEY = 'homepilot_wizard_step';
// Restore draft + step from sessionStorage (survives refresh, cleared on tab close)
const loadSavedDraft = useCallback(() => {
try {
const raw = sessionStorage.getItem(DRAFT_STORAGE_KEY);
if (raw)
return { ...DEFAULT_DRAFT, ...JSON.parse(raw) };
}
catch { /* ignore corrupt data */ }
return { ...DEFAULT_DRAFT };
}, []);
const loadSavedStep = useCallback(() => {
try {
const raw = sessionStorage.getItem(STEP_STORAGE_KEY);
if (raw) {
const n = Number(raw);
if (n >= 0 && n <= 6)
return n;
}
}
catch { /* ignore */ }
return 0;
}, []);
const [wizardMode, setWizardMode] = useState('studio');
const [step, setStep] = useState(loadSavedStep);
const [draft, setDraft] = useState(loadSavedDraft);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [selectedResultIndex, setSelectedResultIndex] = useState(null);
const [showNsfw, setShowNsfw] = useState(false);
const [showCountMenu, setShowCountMenu] = useState(false);
const [count, setCount] = useState(4);
const [professionSearch, setProfessionSearch] = useState('');
// Persist draft + step to sessionStorage on every change
useEffect(() => {
try {
sessionStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft));
}
catch { /* quota */ }
}, [draft]);
useEffect(() => {
try {
sessionStorage.setItem(STEP_STORAGE_KEY, String(step));
}
catch { /* quota */ }
}, [step]);
// Toast
const [toast, setToast] = useState(null);
const toastTimer = useRef();
const showToast = useCallback((message, type = 'info') => {
clearTimeout(toastTimer.current);
setToast({ message, type });
toastTimer.current = setTimeout(() => setToast(null), 4000);
}, []);
// File input for reference upload
const fileInputRef = useRef(null);
// NSFW mode β€” read directly from localStorage so it stays reactive.
// The prop from the parent is stale (captured at mount time), so we ignore it
// and poll localStorage to pick up changes made in the Settings panel.
const readNsfw = useCallback(() => {
try {
return localStorage.getItem('homepilot_nsfw_mode') === 'true';
}
catch {
return false;
}
}, []);
const [nsfwEnabled, setNsfwEnabled] = useState(readNsfw);
useEffect(() => {
const sync = () => setNsfwEnabled(readNsfw());
// Re-read on window focus (e.g. switching tabs/windows)
window.addEventListener('focus', sync);
// Listen for cross-tab localStorage changes
window.addEventListener('storage', sync);
// Poll every 1s to catch same-tab settings changes (localStorage
// writes within the same tab do NOT fire the 'storage' event)
const interval = setInterval(sync, 1000);
return () => {
window.removeEventListener('focus', sync);
window.removeEventListener('storage', sync);
clearInterval(interval);
};
}, [readNsfw]);
// Standard / Spicy tab (for Step 6 outfit + Step 7 vibe selection) β€” persisted
const [vibeTab, _setVibeTab] = useState(loadVibeTab);
const setVibeTab = useCallback((tab) => { _setVibeTab(tab); saveVibeTab(tab); }, []);
// Reset to standard tab if NSFW gets disabled mid-session
useEffect(() => {
if (!nsfwEnabled && vibeTab === 'spicy')
setVibeTab('standard');
}, [nsfwEnabled, vibeTab, setVibeTab]);
// Hooks
const gen = useGenerateAvatars(backendUrl, apiKey);
const gallery = useAvatarGallery();
// Update draft
const update = useCallback((changes) => {
setDraft((prev) => ({ ...prev, ...changes }));
}, []);
// Randomize appearance settings (body, face, hair, outfit)
// When spicy mode is active, also randomize NSFW-specific parameters
const randomizeAppearance = useCallback(() => {
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
const isSpicyMode = nsfwEnabled && vibeTab === 'spicy';
// Outfit presets for Step 5 (Outfit)
const outfitPool = isSpicyMode ? [...OUTFIT_STYLES_SFW, ...OUTFIT_STYLES_NSFW] : OUTFIT_STYLES_SFW;
// Character style presets for Step 6 (Generate) and Quick Create style vibes
const stylePool = CHARACTER_STYLE_PRESETS.filter((s) => isSpicyMode ? s.category === 'spicy' : s.category === 'standard');
const posePool = isSpicyMode ? NSFW_POSES : POSES;
const bgPool = isSpicyMode ? NSFW_BACKGROUNDS : BACKGROUNDS;
// Pick from both pools so outfitStyle works on both Step 5 and Step 6
const combinedOutfitPool = [...outfitPool.map((o) => o.id), ...stylePool.map((s) => s.id)];
setDraft((prev) => ({
...prev,
gender: pick(['female', 'male', 'neutral']),
ageRange: pick(['young_adult', 'adult', 'mature']),
bodyType: pick(['slim', 'average', 'athletic', 'curvy']),
heightCm: 155 + Math.floor(Math.random() * 40),
posture: pick(['upright', 'relaxed', 'confident']),
skinTone: pick(SKIN_TONES).id,
polish: pick(['natural', 'light_makeup', 'formal']),
facePreset: pick(FACE_PRESETS).id,
eyeColor: pick(EYE_COLORS).id,
eyeShape: pick(EYE_SHAPES).id,
expression: pick(EXPRESSIONS).id,
jawline: 20 + Math.floor(Math.random() * 60),
lips: 20 + Math.floor(Math.random() * 60),
browDefinition: 20 + Math.floor(Math.random() * 60),
hairStyle: pick(HAIR_STYLES).id,
hairColor: pick(HAIR_COLORS).id,
hairShine: 10 + Math.floor(Math.random() * 70),
outfitStyle: pick(combinedOutfitPool),
outfitPrimaryColor: pick(COLOR_PALETTE).id,
outfitSecondaryColor: pick(COLOR_PALETTE).id,
accessories: ACCESSORIES.filter(() => Math.random() > 0.7).map((a) => a.id),
portraitType: pick(['headshot', 'half_body', 'full_body']),
pose: pick(posePool).id,
background: pick(bgPool).id,
lighting: pick(LIGHTING_OPTIONS).id,
realism: 40 + Math.floor(Math.random() * 50),
// NSFW-specific parameters (only meaningful when spicy)
...(isSpicyMode ? {
nsfwExposure: pick(['suggestive', 'clothed_revealing', 'partial_nudity', 'topless', 'full_nude', 'explicit']),
nsfwIntensity: 2 + Math.floor(Math.random() * 8),
nsfwPose: pick(['subtle', 'confident', 'intimate', 'seductive_lean', 'lying_down', 'back_arch', 'kneeling', 'over_shoulder', 'hands_above_head']),
nsfwDominanceStyle: pick(['soft', 'balanced', 'strong']),
nsfwFantasyTone: pick(['romantic', 'seductive', 'dramatic']),
} : {
nsfwExposure: undefined,
nsfwIntensity: undefined,
nsfwPose: undefined,
nsfwDominanceStyle: undefined,
nsfwFantasyTone: undefined,
}),
}));
}, [nsfwEnabled, vibeTab]);
// Reset all appearance settings back to defaults
const resetToDefaults = useCallback(() => {
setDraft({ ...DEFAULT_DRAFT });
setVibeTab('standard');
}, []);
// Build prompt from all wizard choices
const prompt = useMemo(() => buildPrompt(draft, nsfwEnabled), [draft, nsfwEnabled]);
// Is spicy content
const isSpicy = vibeTab === 'spicy' || !!draft.nsfwExposure;
// Filtered vibes for Step 7 reference/faceswap modes
const vibes = AVATAR_VIBE_PRESETS.filter((v) => vibeTab === 'standard' ? v.category === 'standard' : v.category === 'spicy');
const charStyles = CHARACTER_STYLE_PRESETS.filter((s) => vibeTab === 'standard' ? s.category === 'standard' : s.category === 'spicy');
// ---------------------------------------------------------------------------
// Step navigation
// ---------------------------------------------------------------------------
const canProceed = true; // All steps are optional β€” user can skip ahead
const goNext = () => { if (canProceed && step < 6)
setStep((s) => (s + 1)); };
const goBack = () => { if (step > 0)
setStep((s) => (s - 1)); };
// ---------------------------------------------------------------------------
// Apply profession defaults
// ---------------------------------------------------------------------------
const applyProfession = useCallback((profId) => {
const prof = getProfession(profId);
if (!prof)
return;
update({
professionId: profId,
tools: [...prof.defaults.tools],
memoryEngine: prof.defaults.memoryEngine,
autonomy: prof.defaults.autonomy,
tone: prof.defaults.tone,
systemPrompt: prof.defaults.systemPrompt,
responseStyle: prof.defaults.responseStyle,
});
}, [update]);
// ---------------------------------------------------------------------------
// File upload (for reference/faceswap)
// ---------------------------------------------------------------------------
const handleFileUpload = useCallback(async (file) => {
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch(`${backendUrl}/upload`, {
method: 'POST',
headers: apiKey ? { 'x-api-key': apiKey } : undefined,
body: formData,
});
if (!res.ok)
throw new Error('Upload failed');
const data = await res.json();
const url = data.url || data.file_url || '';
update({ referenceUrl: url, referencePreview: URL.createObjectURL(file) });
}
catch {
showToast('Failed to upload reference photo', 'error');
}
}, [backendUrl, apiKey, update, showToast]);
// ---------------------------------------------------------------------------
// Generate
// ---------------------------------------------------------------------------
const avatarSettings = loadAvatarSettings();
const checkpoint = resolveCheckpoint(avatarSettings, globalModelImages);
const onGenerate = useCallback(async () => {
// Send the real mode to the backend β€” it routes studio_random to the
// avatar-service (StyleGAN) and other modes to ComfyUI automatically.
const apiMode = draft.generationMode;
try {
const result = await gen.run({
mode: apiMode,
count,
prompt: prompt || undefined,
reference_image_url: draft.generationMode !== 'studio_random'
? draft.referenceUrl || undefined
: undefined,
truncation: 0.7,
checkpoint_override: checkpoint,
});
if (result?.results?.length) {
setSelectedResultIndex(null);
if (result.results.length === 1) {
showToast('Avatar generated β€” click to select, then Create Avatar', 'success');
}
else {
showToast(`${result.results.length} avatars generated β€” pick your favourite`, 'success');
}
}
}
catch {
showToast('Generation failed. Click Generate to try again.', 'error');
}
}, [gen, draft.generationMode, draft.referenceUrl, count, prompt, checkpoint, showToast]);
// Keyboard shortcut
useEffect(() => {
const handler = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && step === 6 && !gen.loading) {
e.preventDefault();
onGenerate();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onGenerate, step, gen.loading]);
// ---------------------------------------------------------------------------
// Create avatar (gallery only)
// ---------------------------------------------------------------------------
const handleCreateAvatar = useCallback(() => {
if (selectedResultIndex === null || !gen.result?.results?.[selectedResultIndex])
return;
const allResults = gen.result.results;
const chosen = allResults[selectedResultIndex];
// Save selected as anchor + non-selected as linked portraits (one atomic op)
const portraits = allResults.filter((_, i) => i !== selectedResultIndex);
// Build wizard metadata so persona export can access profession/tools/tone
const prof = getProfession(draft.professionId);
const wizardMeta = {
professionId: draft.professionId,
professionLabel: prof?.label,
professionDescription: prof?.description,
tools: draft.tools,
memoryEngine: draft.memoryEngine,
autonomy: draft.autonomy,
tone: draft.tone,
systemPrompt: draft.systemPrompt || prof?.defaults.systemPrompt,
responseStyle: draft.responseStyle,
gender: draft.gender,
ageRange: draft.ageRange,
outfitStyle: draft.outfitStyle,
};
if (onSaveGeneration) {
onSaveGeneration(chosen, portraits, draft.generationMode, prompt, draft.referenceUrl, isSpicy, wizardMeta);
}
else {
const framingType = draft.portraitType === 'headshot' ? 'headshot'
: draft.portraitType === 'mid_body' ? 'mid_body' : 'half_body';
gallery.addAnchorWithPortraits(chosen, portraits, draft.generationMode, prompt, draft.referenceUrl, { nsfw: isSpicy || undefined, wizardMeta, framingType });
}
const portraitCount = portraits.length;
showToast(portraitCount > 0
? `Avatar created! ${portraitCount} alternative${portraitCount > 1 ? 's' : ''} saved as portraits`
: 'Avatar created!', 'success');
// Clear session draft β€” wizard is complete
try {
sessionStorage.removeItem(DRAFT_STORAGE_KEY);
sessionStorage.removeItem(STEP_STORAGE_KEY);
}
catch { /* */ }
onClose();
}, [selectedResultIndex, gen.result, draft, prompt, isSpicy, gallery, onSaveGeneration, onClose, showToast]);
// ---------------------------------------------------------------------------
// Render: Step content
// ---------------------------------------------------------------------------
function renderStep() {
switch (step) {
// =====================================================================
// STEP 0 β€” IDENTITY
// =====================================================================
case 0:
return (<div className="space-y-6">
<SectionLabel>Gender</SectionLabel>
<div className="flex gap-2">
{GENDER_OPTIONS.map((g) => (<PillButton key={g.id} label={g.label} icon={g.icon} selected={draft.gender === g.id} onClick={() => update({ gender: g.id })}/>))}
</div>
<SectionLabel>Age Range</SectionLabel>
<div className="flex gap-2">
{['young_adult', 'adult', 'mature'].map((a) => (<PillButton key={a} label={a === 'young_adult' ? 'Young Adult' : a === 'adult' ? 'Adult' : 'Mature'} selected={draft.ageRange === a} onClick={() => update({ ageRange: a })}/>))}
</div>
</div>);
// =====================================================================
// STEP 1 β€” BODY
// =====================================================================
case 1:
return (<div className="space-y-6">
<SectionLabel>Body Type</SectionLabel>
<div className="flex flex-wrap gap-2">
{['slim', 'average', 'athletic', 'curvy'].map((b) => (<PillButton key={b} label={b.charAt(0).toUpperCase() + b.slice(1)} selected={draft.bodyType === b} onClick={() => update({ bodyType: b })}/>))}
</div>
<SliderField label="Height" value={draft.heightCm} min={150} max={200} unit=" cm" onChange={(v) => update({ heightCm: v })}/>
<SectionLabel>Posture</SectionLabel>
<div className="flex gap-2">
{['upright', 'relaxed', 'confident'].map((p) => (<PillButton key={p} label={p.charAt(0).toUpperCase() + p.slice(1)} selected={draft.posture === p} onClick={() => update({ posture: p })}/>))}
</div>
<SectionLabel>Skin Tone</SectionLabel>
<div className="flex flex-wrap gap-2">
{SKIN_TONES.map((t) => (<ColorSwatch key={t.id} color={t.color} label={t.label} selected={draft.skinTone === t.id} onClick={() => update({ skinTone: t.id })}/>))}
</div>
<SectionLabel>Polish</SectionLabel>
<div className="flex gap-2">
{['natural', 'light_makeup', 'formal'].map((p) => (<PillButton key={p} label={p === 'light_makeup' ? 'Light Makeup' : p.charAt(0).toUpperCase() + p.slice(1)} selected={draft.polish === p} onClick={() => update({ polish: p })}/>))}
</div>
</div>);
// =====================================================================
// STEP 2 β€” FACE
// =====================================================================
case 2:
return (<div className="space-y-6">
<SectionLabel>Face Shape</SectionLabel>
<div className="grid grid-cols-4 gap-2">
{FACE_PRESETS.map((f) => (<PillButton key={f.id} label={f.label} selected={draft.facePreset === f.id} onClick={() => update({ facePreset: f.id })}/>))}
</div>
<SectionLabel>Eye Color</SectionLabel>
<div className="flex flex-wrap gap-2">
{EYE_COLORS.map((c) => (<ColorSwatch key={c.id} color={c.color} label={c.label} selected={draft.eyeColor === c.id} onClick={() => update({ eyeColor: c.id })}/>))}
</div>
<SectionLabel>Eye Shape</SectionLabel>
<div className="flex flex-wrap gap-2">
{EYE_SHAPES.map((s) => (<PillButton key={s.id} label={s.label} selected={draft.eyeShape === s.id} onClick={() => update({ eyeShape: s.id })}/>))}
</div>
<SectionLabel>Default Expression</SectionLabel>
<div className="flex flex-wrap gap-2">
{EXPRESSIONS.map((e) => (<PillButton key={e.id} label={e.label} selected={draft.expression === e.id} onClick={() => update({ expression: e.id })}/>))}
</div>
<AdvancedToggle open={advancedOpen} onToggle={() => setAdvancedOpen(!advancedOpen)}/>
{advancedOpen && (<div className="space-y-4 animate-fadeSlideIn">
<SliderField label="Jawline" value={draft.jawline} min={0} max={100} onChange={(v) => update({ jawline: v })}/>
<SliderField label="Lips" value={draft.lips} min={0} max={100} onChange={(v) => update({ lips: v })}/>
<SliderField label="Brow Definition" value={draft.browDefinition} min={0} max={100} onChange={(v) => update({ browDefinition: v })}/>
</div>)}
</div>);
// =====================================================================
// STEP 3 β€” HAIR
// =====================================================================
case 3:
return (<div className="space-y-6">
<SectionLabel>Hairstyle</SectionLabel>
<div className="grid grid-cols-3 gap-2">
{HAIR_STYLES.map((h) => (<PillButton key={h.id} label={h.label} selected={draft.hairStyle === h.id} onClick={() => update({ hairStyle: h.id })}/>))}
</div>
<SectionLabel>Hair Color</SectionLabel>
<div className="flex flex-wrap gap-2">
{HAIR_COLORS.map((c) => (<ColorSwatch key={c.id} color={c.color} label={c.label} selected={draft.hairColor === c.id} onClick={() => update({ hairColor: c.id })}/>))}
</div>
<SliderField label="Shine" value={draft.hairShine} min={0} max={100} onChange={(v) => update({ hairShine: v })}/>
</div>);
// =====================================================================
// STEP 4 β€” PROFESSION
// =====================================================================
case 4: {
const q = professionSearch.toLowerCase().trim();
const filteredProfs = q
? PROFESSIONS.filter((p) => p.label.toLowerCase().includes(q) || p.description.toLowerCase().includes(q) || p.category.toLowerCase().includes(q))
: PROFESSIONS;
return (<div className="space-y-6">
<SectionLabel>Choose a Profession</SectionLabel>
<p className="text-[10px] text-white/25 -mt-3">Profession influences outfit style, pose, and scene context</p>
{/* Search / filter */}
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-white/25"/>
<input type="text" value={professionSearch} onChange={(e) => setProfessionSearch(e.target.value)} placeholder="Search professions..." className="w-full pl-9 pr-3 py-2.5 rounded-xl bg-white/[0.04] border border-white/[0.08] text-xs text-white/70 placeholder-white/25 outline-none focus:border-purple-500/30 transition-colors"/>
</div>
<div className="space-y-2" role="listbox" aria-label="Profession list">
{filteredProfs.length === 0 && (<p className="text-[10px] text-white/25 text-center py-4">No professions match &ldquo;{professionSearch}&rdquo;</p>)}
{filteredProfs.map((p) => {
const active = draft.professionId === p.id;
return (<button key={p.id} role="option" aria-selected={active} onClick={() => { applyProfession(p.id); setProfessionSearch(''); }} className={[
'w-full flex items-center gap-3 px-4 py-3 rounded-xl border text-left transition-all',
active
? 'border-purple-500/30 bg-purple-500/10 text-purple-200'
: 'border-white/[0.06] bg-white/[0.02] text-white/50 hover:bg-white/[0.05] hover:text-white/70',
].join(' ')}>
<span className="text-lg">{p.icon}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold">{p.label}</span>
{p.recommended && <span className="text-[8px] px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-300 font-bold">Recommended</span>}
</div>
<p className="text-[10px] text-white/30 mt-0.5 truncate">{p.description}</p>
</div>
{active && <Check size={16} className="text-purple-400 flex-shrink-0"/>}
</button>);
})}
</div>
</div>);
}
// =====================================================================
// STEP 5 β€” OUTFIT
// =====================================================================
case 5: {
const outfits = vibeTab === 'spicy' && nsfwEnabled
? [...OUTFIT_STYLES_SFW, ...OUTFIT_STYLES_NSFW]
: OUTFIT_STYLES_SFW;
return (<div className="space-y-6">
{/* SFW / NSFW tabs β€” only show when NSFW is globally enabled */}
{nsfwEnabled && (<div className="flex items-center gap-1 p-1 rounded-xl bg-white/[0.03] border border-white/[0.06] w-fit">
<button onClick={() => setVibeTab('standard')} className={[
'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all',
vibeTab === 'standard' ? 'bg-white/10 text-white shadow-sm' : 'text-white/40 hover:text-white/60',
].join(' ')}>
<Star size={12}/> Standard
</button>
<button onClick={() => setVibeTab('spicy')} className={[
'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all',
vibeTab === 'spicy'
? 'bg-gradient-to-r from-rose-500/20 to-orange-500/20 text-rose-300 border border-rose-500/20 shadow-sm'
: 'text-white/40 hover:text-rose-300/60',
].join(' ')}>
<Flame size={12}/> Romance &amp; Roleplay
<span className="text-[8px] px-1 py-0.5 rounded bg-rose-500/20 text-rose-300 font-bold">18+</span>
</button>
</div>)}
<SectionLabel>Outfit Style</SectionLabel>
<div className="grid grid-cols-2 gap-2">
{outfits.map((o) => (<PillButton key={o.id} label={o.label} selected={draft.outfitStyle === o.id} accent={OUTFIT_STYLES_NSFW.some((n) => n.id === o.id) ? 'rose' : 'purple'} onClick={() => update({ outfitStyle: o.id })}/>))}
</div>
{/* ── Lingerie Builder β€” only when lingerie/boudoir_wear is selected ── */}
{(draft.outfitStyle === 'lingerie' || draft.outfitStyle === 'boudoir_wear') && (() => {
const activeGarments = draft.lingerieActiveGarments ?? [];
const selections = draft.lingerieSelections ?? {};
const viewingGarment = draft.lingerieType;
const currentSel = viewingGarment ? (selections[viewingGarment] ?? {}) : {};
return (<div className="space-y-4 p-4 rounded-xl bg-rose-500/[0.03] border border-rose-500/10">
{/* Garment Type β€” multi-select, click toggles active, also sets viewing */}
<div>
<SectionLabel>Garment Type</SectionLabel>
<div className="text-[9px] text-white/30 mb-2">Select one or more pieces. Click to toggle &amp; configure.</div>
<div className="flex flex-wrap gap-2">
{LINGERIE_GARMENTS.map((g) => {
const isActive = activeGarments.includes(g.id);
const isViewing = viewingGarment === g.id;
return (<PillButton key={g.id} label={`${g.icon} ${g.label}${isActive && !isViewing ? ' βœ“' : ''}`} selected={isViewing} accent="rose" onClick={() => {
if (isViewing) {
// Second click on viewing garment β†’ deactivate it
update({
lingerieType: null,
lingerieActiveGarments: activeGarments.filter((x) => x !== g.id),
});
}
else if (isActive) {
// Already active but not viewing β†’ switch to view it
update({ lingerieType: g.id });
}
else {
// Not active β†’ activate + view, preserve previous selections
update({
lingerieType: g.id,
lingerieActiveGarments: [...activeGarments, g.id],
});
}
}}/>);
})}
</div>
{/* Active garment summary chips */}
{activeGarments.length > 1 && (<div className="flex flex-wrap gap-1 mt-2">
{activeGarments.map((gId) => {
const g = LINGERIE_GARMENTS.find((x) => x.id === gId);
const sel = selections[gId];
const styleLabel = sel?.style && LINGERIE_STYLES[gId]?.find((s) => s.id === sel.style)?.label;
return (<span key={gId} className={[
'text-[9px] px-2 py-0.5 rounded-full border cursor-pointer transition-all',
viewingGarment === gId
? 'border-rose-500/40 bg-rose-500/15 text-rose-200'
: 'border-white/10 bg-white/5 text-white/40 hover:text-white/60',
].join(' ')} onClick={() => update({ lingerieType: gId })}>
{g?.icon} {g?.label}{styleLabel ? `: ${styleLabel}` : ''}
</span>);
})}
</div>)}
</div>
{/* Style Picker β€” scoped to currently viewed garment */}
{viewingGarment && LINGERIE_STYLES[viewingGarment] && (<div>
<SectionLabel>
{viewingGarment === 'bottom' ? 'Panty Style' : viewingGarment === 'top' ? 'Bra Style' : viewingGarment === 'set' ? 'Set Style' : 'Bodysuit Style'}
</SectionLabel>
<div className="flex flex-wrap gap-2">
{LINGERIE_STYLES[viewingGarment].map((s) => (<PillButton key={s.id} label={s.label} selected={currentSel.style === s.id} accent="rose" onClick={() => {
const newSel = { ...currentSel, style: currentSel.style === s.id ? null : s.id };
update({ lingerieSelections: { ...selections, [viewingGarment]: newSel } });
}}/>))}
</div>
</div>)}
{/* Fabric β€” scoped to currently viewed garment */}
{viewingGarment && (<div>
<SectionLabel>Fabric</SectionLabel>
<div className="flex flex-wrap gap-2">
{LINGERIE_FABRICS.map((f) => (<PillButton key={f.id} label={f.label} selected={currentSel.fabric === f.id} accent="rose" onClick={() => {
const newSel = { ...currentSel, fabric: currentSel.fabric === f.id ? null : f.id };
update({ lingerieSelections: { ...selections, [viewingGarment]: newSel } });
}}/>))}
</div>
</div>)}
{/* Brand Inspired β€” scoped to currently viewed garment */}
{viewingGarment && (<div>
<SectionLabel>Brand Inspired</SectionLabel>
<div className="flex flex-wrap gap-2">
{LINGERIE_BRANDS.map((b) => (<PillButton key={b.id} label={b.label} selected={currentSel.brand === b.id} accent="rose" onClick={() => {
const newSel = { ...currentSel, brand: currentSel.brand === b.id ? null : b.id };
update({ lingerieSelections: { ...selections, [viewingGarment]: newSel } });
}}/>))}
</div>
</div>)}
{/* Lingerie Accessories β€” shared across all garments */}
<div>
<SectionLabel>Lingerie Accessories</SectionLabel>
<div className="flex flex-wrap gap-2">
{LINGERIE_EXTRAS.map((e) => {
const active = (draft.lingerieExtras ?? []).includes(e.id);
return (<PillButton key={e.id} label={`${e.icon} ${e.label}`} selected={active} accent="rose" onClick={() => {
const prev = draft.lingerieExtras ?? [];
update({ lingerieExtras: active ? prev.filter((x) => x !== e.id) : [...prev, e.id] });
}}/>);
})}
</div>
</div>
{/* Primary & Secondary Color β€” inside lingerie builder for piece coloring */}
<div className="grid grid-cols-2 gap-4">
<div>
<SectionLabel>{activeGarments.includes('top') && activeGarments.includes('bottom') ? 'Top Color' : 'Primary Color'}</SectionLabel>
<div className="flex flex-wrap gap-1.5">
{COLOR_PALETTE.map((c) => (<ColorSwatch key={c.id} color={c.color} label={c.label} selected={draft.outfitPrimaryColor === c.id} onClick={() => update({ outfitPrimaryColor: c.id })}/>))}
</div>
</div>
<div>
<SectionLabel>{activeGarments.includes('top') && activeGarments.includes('bottom') ? 'Bottom Color' : 'Secondary Color'}</SectionLabel>
<div className="flex flex-wrap gap-1.5">
{COLOR_PALETTE.map((c) => (<ColorSwatch key={c.id} color={c.color} label={c.label} selected={draft.outfitSecondaryColor === c.id} onClick={() => update({ outfitSecondaryColor: c.id })}/>))}
</div>
</div>
</div>
</div>);
})()}
{/* Primary & Secondary Color β€” shown only when NOT lingerie (lingerie has its own colors above) */}
{draft.outfitStyle !== 'lingerie' && draft.outfitStyle !== 'boudoir_wear' && (<div className="grid grid-cols-2 gap-4">
<div>
<SectionLabel>Primary Color</SectionLabel>
<div className="flex flex-wrap gap-1.5">
{COLOR_PALETTE.map((c) => (<ColorSwatch key={c.id} color={c.color} label={c.label} selected={draft.outfitPrimaryColor === c.id} onClick={() => update({ outfitPrimaryColor: c.id })}/>))}
</div>
</div>
<div>
<SectionLabel>Secondary Color</SectionLabel>
<div className="flex flex-wrap gap-1.5">
{COLOR_PALETTE.map((c) => (<ColorSwatch key={c.id} color={c.color} label={c.label} selected={draft.outfitSecondaryColor === c.id} onClick={() => update({ outfitSecondaryColor: c.id })}/>))}
</div>
</div>
</div>)}
{/* Accessories β€” collapsible, hidden by default */}
<button onClick={() => setAdvancedOpen(!advancedOpen)} className="flex items-center gap-1.5 text-[10px] text-white/25 hover:text-white/45 font-medium uppercase tracking-wider transition-colors mt-2">
<ChevronDown size={10} className={`transition-transform ${advancedOpen ? 'rotate-0' : '-rotate-90'}`}/>
Accessories
{draft.accessories.length > 0 && (<span className="px-1.5 py-0.5 rounded-full bg-purple-500/20 text-purple-300 text-[9px] font-bold">
{draft.accessories.length}
</span>)}
</button>
{advancedOpen && (<div className="flex flex-wrap gap-2 mt-2">
{ACCESSORIES.map((a) => {
const active = draft.accessories.includes(a.id);
return (<PillButton key={a.id} label={a.label} selected={active} onClick={() => {
const next = active ? draft.accessories.filter((x) => x !== a.id) : [...draft.accessories, a.id];
update({ accessories: next });
}}/>);
})}
</div>)}
{/* ── NSFW customization (18+ gated) ── */}
{nsfwEnabled && vibeTab === 'spicy' && (<div className="mt-6 pt-5 border-t border-rose-500/20">
<div className="text-xs text-rose-400 font-bold uppercase tracking-wider mb-5 flex items-center gap-2">
<Flame size={14}/> Adult Content Controls
<span className="text-[8px] px-1.5 py-0.5 rounded bg-rose-500/20 text-rose-300 font-bold ml-1">18+</span>
</div>
<div className="space-y-6">
{/* Exposure Level β€” 6-tier granular selector */}
<div>
<div className="text-[10px] text-rose-400/70 font-semibold uppercase tracking-wider mb-3">Nudity / Exposure Level</div>
<div className="grid grid-cols-3 gap-2">
{([
{ id: 'suggestive', label: 'Suggestive', desc: 'Tasteful hints' },
{ id: 'clothed_revealing', label: 'Clothed Revealing', desc: 'Clothed but revealing' },
{ id: 'partial_nudity', label: 'Partial Nudity', desc: 'Some exposed skin' },
{ id: 'topless', label: 'Topless', desc: 'Topless / implied nude' },
{ id: 'full_nude', label: 'Full Nude', desc: 'Artistic full nude' },
{ id: 'explicit', label: 'Explicit', desc: 'Explicit adult content' },
]).map((e) => (<button key={e.id} onClick={() => update({ nsfwExposure: e.id })} className={[
'flex flex-col items-center gap-1 px-3 py-3 rounded-xl border text-center transition-all',
draft.nsfwExposure === e.id
? 'border-rose-500/40 bg-rose-500/15 text-rose-200 shadow-[0_0_12px_rgba(244,63,94,0.15)]'
: 'border-white/[0.06] bg-white/[0.02] text-white/40 hover:bg-white/[0.05] hover:text-white/60',
].join(' ')}>
<span className="text-xs font-semibold">{e.label}</span>
<span className="text-[9px] text-white/30">{e.desc}</span>
</button>))}
</div>
</div>
{/* Intensity slider */}
<SliderField label="Explicitness Intensity" value={draft.nsfwIntensity ?? 5} min={0} max={10} onChange={(v) => update({ nsfwIntensity: v })}/>
{/* Sensual Pose β€” unified flat grid of all 9 poses */}
<div>
<div className="text-[10px] text-rose-400/70 font-semibold uppercase tracking-wider mb-3">Sensual Pose</div>
<div className="grid grid-cols-3 gap-2">
{([
{ id: 'subtle', label: 'Subtle Tease' },
{ id: 'confident', label: 'Confident Display' },
{ id: 'intimate', label: 'Intimate Close' },
...NSFW_POSES.map((p) => ({ id: p.id, label: p.label })),
]).map((p) => (<PillButton key={p.id} label={p.label} selected={draft.nsfwPose === p.id} accent="rose" onClick={() => update({ nsfwPose: p.id })}/>))}
</div>
</div>
{/* Dominance / Power dynamic */}
<div>
<div className="text-[10px] text-rose-400/70 font-semibold uppercase tracking-wider mb-3">Power Dynamic</div>
<div className="flex gap-2">
{([
{ id: 'soft', label: 'Soft & Romantic' },
{ id: 'balanced', label: 'Balanced' },
{ id: 'strong', label: 'Dominant & Bold' },
]).map((d) => (<PillButton key={d.id} label={d.label} selected={draft.nsfwDominanceStyle === d.id} accent="rose" onClick={() => update({ nsfwDominanceStyle: d.id })}/>))}
</div>
</div>
{/* Fantasy Tone */}
<div>
<div className="text-[10px] text-rose-400/70 font-semibold uppercase tracking-wider mb-3">Fantasy Tone</div>
<div className="flex gap-2">
{([
{ id: 'romantic', label: 'Romantic & Tender' },
{ id: 'seductive', label: 'Seductive & Alluring' },
{ id: 'dramatic', label: 'Dramatic & Intense' },
]).map((t) => (<PillButton key={t.id} label={t.label} selected={draft.nsfwFantasyTone === t.id} accent="rose" onClick={() => update({ nsfwFantasyTone: t.id })}/>))}
</div>
</div>
{/* NSFW Scene / Background */}
<div>
<div className="text-[10px] text-rose-400/70 font-semibold uppercase tracking-wider mb-3">Scene / Setting</div>
<div className="grid grid-cols-3 gap-1.5">
{NSFW_BACKGROUNDS.map((b) => (<PillButton key={b.id} label={b.label} selected={draft.background === b.id} accent="rose" onClick={() => update({ background: b.id })}/>))}
</div>
</div>
</div>
</div>)}
</div>);
}
// =====================================================================
// STEP 6 β€” GENERATE
// =====================================================================
case 6: {
const needsRef = draft.generationMode === 'studio_reference' || draft.generationMode === 'studio_faceswap';
const canGen = !gen.loading && (!needsRef || !!draft.referenceUrl);
return (<div className="space-y-6">
{/* ── Generation Mode tabs ── */}
<SectionLabel>Generation Mode</SectionLabel>
<div className="flex items-center gap-2" role="radiogroup">
{[
{ mode: 'studio_random', label: 'Design Character', icon: <Sparkles size={13}/> },
{ mode: 'studio_reference', label: 'From Reference', icon: <User size={13}/> },
{ mode: 'studio_faceswap', label: 'Face + Style', icon: <Palette size={13}/> },
].map((m) => (<button key={m.mode} onClick={() => update({ generationMode: m.mode })} className={[
'flex items-center gap-2 px-4 py-2 rounded-full text-xs font-medium transition-all border',
draft.generationMode === m.mode
? 'bg-white/10 text-white border-white/20 shadow-[0_0_12px_rgba(255,255,255,0.05)]'
: 'text-white/40 hover:text-white/60 hover:bg-white/[0.04] border-transparent',
].join(' ')}>
{m.icon} {m.label}
</button>))}
</div>
{/* ── Reference upload (for ref/faceswap) ── */}
{needsRef && (<div>
<SectionLabel>Upload a Face</SectionLabel>
<div className={[
'flex items-center gap-3 px-4 py-3 rounded-2xl border transition-all',
'bg-white/[0.04] border-white/10',
draft.referencePreview ? 'border-purple-500/30' : 'hover:border-white/15',
].join(' ')}>
{draft.referencePreview ? (<div className="relative flex-shrink-0">
<div className="w-10 h-10 rounded-full overflow-hidden border-2 border-purple-500/40">
<img src={draft.referencePreview} alt="Reference" className="w-full h-full object-cover"/>
</div>
<button onClick={() => update({ referenceUrl: undefined, referencePreview: undefined })} className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500/80 flex items-center justify-center text-white hover:bg-red-500 transition-colors">
<X size={8}/>
</button>
</div>) : (<button onClick={() => fileInputRef.current?.click()} className="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center text-white/20 hover:text-white/50 hover:bg-white/5 transition-all border border-dashed border-white/10">
<Camera size={18}/>
</button>)}
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f)
handleFileUpload(f); e.target.value = ''; }}/>
<div className="flex-1 min-w-0">
{draft.referencePreview ? (<span className="text-sm text-white/60">Reference photo attached</span>) : (<button onClick={() => fileInputRef.current?.click()} className="text-sm text-white/25 hover:text-white/40 transition-colors cursor-pointer text-left">
Click to upload a reference photo
</button>)}
</div>
</div>
</div>)}
{/* ── Style / Vibe selection (for reference/faceswap OR design character) ── */}
{draft.generationMode === 'studio_random' ? (<>
{/* Standard / Spicy tabs β€” only when NSFW globally enabled */}
{nsfwEnabled && (<div className="flex items-center gap-1 p-1 rounded-xl bg-white/[0.03] border border-white/[0.06] w-fit">
<button onClick={() => setVibeTab('standard')} className={[
'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all',
vibeTab === 'standard' ? 'bg-white/10 text-white shadow-sm' : 'text-white/40 hover:text-white/60',
].join(' ')}>
<Star size={12}/> Standard
</button>
<button onClick={() => setVibeTab('spicy')} className={[
'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all',
vibeTab === 'spicy'
? 'bg-gradient-to-r from-rose-500/20 to-orange-500/20 text-rose-300 border border-rose-500/20 shadow-sm'
: 'text-white/40 hover:text-rose-300/60',
].join(' ')}>
<Flame size={12}/> Romance &amp; Roleplay
<span className="text-[8px] px-1 py-0.5 rounded bg-rose-500/20 text-rose-300 font-bold">18+</span>
</button>
</div>)}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{charStyles.map((s) => {
const active = draft.outfitStyle === s.id || false;
return (<button key={s.id} onClick={() => update({ outfitStyle: s.id })} className={[
'flex items-center gap-2.5 px-3.5 py-3 rounded-xl text-left transition-all border',
active
? vibeTab === 'spicy'
? 'border-rose-500/30 bg-rose-500/10 text-rose-200'
: 'border-purple-500/30 bg-purple-500/10 text-purple-200'
: 'border-white/[0.06] bg-white/[0.02] text-white/50 hover:bg-white/[0.04]',
].join(' ')}>
<span className="text-base leading-none">{s.icon}</span>
<span className="text-xs font-medium">{s.label}</span>
</button>);
})}
</div>
</>) : (<>
{/* Vibe grid for reference/faceswap β€” tab bar only when NSFW enabled */}
{nsfwEnabled && (<div className="flex items-center gap-1 p-1 rounded-xl bg-white/[0.03] border border-white/[0.06] w-fit">
<button onClick={() => setVibeTab('standard')} className={[
'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all',
vibeTab === 'standard' ? 'bg-white/10 text-white shadow-sm' : 'text-white/40 hover:text-white/60',
].join(' ')}>
<Star size={12}/> Standard
</button>
<button onClick={() => setVibeTab('spicy')} className={[
'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all',
vibeTab === 'spicy'
? 'bg-gradient-to-r from-rose-500/20 to-orange-500/20 text-rose-300 border border-rose-500/20 shadow-sm'
: 'text-white/40 hover:text-rose-300/60',
].join(' ')}>
<Flame size={12}/> Romance &amp; Roleplay
<span className="text-[8px] px-1 py-0.5 rounded bg-rose-500/20 text-rose-300 font-bold">18+</span>
</button>
</div>)}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{vibes.map((v) => (<button key={v.id} onClick={() => update({ outfitStyle: v.id })} className={[
'flex items-center gap-2.5 px-3.5 py-3 rounded-xl text-left transition-all border',
draft.outfitStyle === v.id
? 'border-purple-500/40 bg-purple-500/10 text-white ring-1 ring-purple-500/20'
: 'border-white/[0.06] bg-white/[0.02] text-white/50 hover:bg-white/[0.04]',
].join(' ')}>
<span className="text-base leading-none">{v.icon}</span>
<span className="text-xs font-medium">{v.label}</span>
</button>))}
</div>
</>)}
{/* ── Portrait / Pose / Background controls ── */}
<SectionLabel>Portrait Type</SectionLabel>
<div className="flex gap-2 flex-wrap">
{['headshot', 'half_body', 'mid_body', 'full_body'].map((t) => (<PillButton key={t} label={t === 'half_body' ? 'Half Body' : t === 'mid_body' ? 'Mid Body' : t === 'full_body' ? 'Full Body' : 'Headshot'} selected={draft.portraitType === t} onClick={() => update({ portraitType: t })}/>))}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<SectionLabel>Pose</SectionLabel>
<div className="flex flex-wrap gap-1.5">
{POSES.map((p) => (<PillButton key={p.id} label={p.label} selected={draft.pose === p.id} onClick={() => update({ pose: p.id })}/>))}
</div>
</div>
<div>
<SectionLabel>Background</SectionLabel>
<div className="flex flex-wrap gap-1.5">
{BACKGROUNDS.map((b) => (<PillButton key={b.id} label={b.label} selected={draft.background === b.id} onClick={() => update({ background: b.id })}/>))}
</div>
</div>
</div>
{/* ── Advanced ── */}
<AdvancedToggle open={advancedOpen} onToggle={() => setAdvancedOpen(!advancedOpen)}/>
{advancedOpen && (<div className="space-y-4 animate-fadeSlideIn">
<SliderField label="Realism" value={draft.realism} min={0} max={100} onChange={(v) => update({ realism: v })}/>
<SliderField label="Detail Level" value={draft.detailLevel} min={0} max={100} onChange={(v) => update({ detailLevel: v })}/>
<SectionLabel>Lighting</SectionLabel>
<div className="flex flex-wrap gap-2">
{LIGHTING_OPTIONS.map((l) => (<PillButton key={l.id} label={l.label} selected={draft.lighting === l.id} onClick={() => update({ lighting: l.id })}/>))}
</div>
</div>)}
{/* ── Generate Button + Count Selector ── */}
<div className="flex items-center justify-center gap-3 pt-4">
<div className="relative flex items-stretch">
<button onClick={onGenerate} disabled={!canGen} className={[
'flex items-center gap-2 pl-5 pr-3 py-2.5 rounded-l-xl text-sm font-semibold transition-all',
canGen
? 'bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-lg shadow-purple-500/20 hover:shadow-purple-500/30 hover:brightness-110 active:scale-[0.98]'
: 'bg-white/[0.06] text-white/25 cursor-not-allowed',
].join(' ')}>
{gen.loading ? (<><Loader2 size={16} className="animate-spin"/> Generating...</>) : (<><Sparkles size={16}/> Generate ({count})</>)}
</button>
<div className="relative">
<button onClick={() => setShowCountMenu(!showCountMenu)} className={[
'h-full px-2.5 rounded-r-xl border-l transition-all flex items-center',
canGen
? 'bg-gradient-to-r from-pink-600 to-pink-700 border-white/10 text-white/80 hover:text-white'
: 'bg-white/[0.06] border-white/5 text-white/15 cursor-not-allowed',
].join(' ')} disabled={!canGen && !gen.loading}>
<ChevronDown size={14}/>
</button>
{showCountMenu && (<>
<div className="fixed inset-0 z-30" onClick={() => setShowCountMenu(false)}/>
<div className="absolute right-0 top-full mt-1 bg-[#1a1a1a] border border-white/10 rounded-lg shadow-2xl z-40 overflow-hidden min-w-[80px]">
{[1, 4, 8].map((n) => (<button key={n} onClick={() => { setCount(n); setShowCountMenu(false); }} className={[
'w-full px-4 py-2 text-left text-sm transition-colors',
count === n ? 'bg-purple-500/15 text-purple-300 font-medium' : 'text-white/60 hover:bg-white/5',
].join(' ')}>{n} image{n > 1 ? 's' : ''}</button>))}
</div>
</>)}
</div>
</div>
{gen.loading && (<button onClick={gen.cancel} className="flex items-center gap-1.5 px-3.5 py-2.5 rounded-xl text-sm font-medium border border-red-500/30 bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-all">
<X size={14}/> Cancel
</button>)}
{canGen && !gen.loading && (<span className="text-[10px] text-white/20 hidden sm:inline ml-1">Ctrl+Enter</span>)}
</div>
{/* ── Loading skeleton ── */}
{gen.loading && (<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4">
{Array.from({ length: count }).map((_, i) => (<div key={i} className="rounded-xl overflow-hidden border border-white/[0.06] bg-white/[0.02]">
<div className="aspect-square bg-white/[0.03] animate-pulse flex items-center justify-center">
<Loader2 size={20} className="animate-spin text-white/10"/>
</div>
</div>))}
</div>)}
{/* ── Results grid ── */}
{gen.result?.results?.length ? (<div className="mt-4 animate-fadeSlideIn">
<div className="text-xs text-white/30 mb-2 font-medium uppercase tracking-wider text-center">
{gen.result.results.length === 1 ? 'Your Avatar' : 'Choose Your Avatar'}
</div>
<div className={`grid gap-3 ${gen.result.results.length === 1 ? 'grid-cols-1 max-w-xs mx-auto' : 'grid-cols-2 sm:grid-cols-4'}`}>
{gen.result.results.map((item, i) => {
const imgUrl = resolveFileUrl(item.url, backendUrl);
const blurred = isSpicy && !showNsfw;
const isSelected = selectedResultIndex === i;
const hasSelection = selectedResultIndex !== null;
return (<div key={i} className={[
'group relative rounded-xl overflow-hidden border-2 bg-white/[0.02] transition-all duration-300 cursor-pointer',
isSelected
? 'border-purple-400 shadow-[0_0_20px_rgba(168,85,247,0.3)] scale-[1.02] z-10'
: hasSelection
? 'border-white/[0.04] opacity-50 grayscale-[30%] hover:opacity-70 hover:grayscale-0'
: 'border-white/[0.06] hover:border-white/20 hover:shadow-lg',
].join(' ')} onClick={() => {
if (blurred) {
setShowNsfw(true);
return;
}
setSelectedResultIndex(isSelected ? null : i);
}}>
<div className="aspect-square bg-white/[0.03] relative">
<img src={imgUrl} alt={`Avatar ${i + 1}`} className={`w-full h-full object-cover transition-all duration-300 ${blurred ? 'blur-xl scale-110' : ''}`} loading="lazy"/>
{blurred && (<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/30">
<EyeOff size={20} className="text-white/40 mb-1"/>
<span className="text-[10px] text-white/40 font-medium">Click to reveal</span>
</div>)}
{isSelected && !blurred && (<div className="absolute top-2 right-2 w-7 h-7 rounded-full bg-purple-500 flex items-center justify-center shadow-lg animate-scaleIn">
<CheckCircle2 size={16} className="text-white"/>
</div>)}
</div>
</div>);
})}
</div>
{/* ── Create Avatar button ── */}
{selectedResultIndex !== null && gen.result.results[selectedResultIndex] && (<div className="flex flex-col items-center mt-6 animate-fadeSlideIn">
<button onClick={handleCreateAvatar} className="flex items-center gap-3 px-8 py-3.5 rounded-2xl bg-gradient-to-r from-purple-600 to-pink-600 text-white font-semibold text-sm shadow-[0_0_30px_rgba(168,85,247,0.25)] hover:shadow-[0_0_40px_rgba(168,85,247,0.4)] hover:scale-[1.03] active:scale-[0.98] transition-all duration-200">
<User size={18}/>
Create Avatar
</button>
<p className="text-[10px] text-white/20 mt-2">
Saves avatar to your gallery β€” export as persona later
</p>
</div>)}
</div>) : !gen.loading && !gen.result ? (<div className="flex flex-col items-center justify-center py-12 text-white/15">
<Sparkles size={36} strokeWidth={1}/>
<p className="mt-3 text-sm text-white/25">Your avatars will appear here</p>
<p className="mt-1 text-[10px] text-white/15">Configure your character above, then click Generate</p>
</div>) : null}
</div>);
}
default:
return null;
}
}
// ---------------------------------------------------------------------------
// Render: Quick Create mode
// ---------------------------------------------------------------------------
function renderQuickCreate() {
const canGen = !gen.loading;
return (<div className="max-w-md mx-auto space-y-6">
<div className="text-center mb-2">
<h3 className="text-lg font-semibold text-white/90">Quick Create</h3>
<p className="text-xs text-white/30 mt-1">Gender, profession, style β€” done in 60 seconds</p>
</div>
<SectionLabel>Gender</SectionLabel>
<div className="flex gap-2">
{GENDER_OPTIONS.map((g) => (<PillButton key={g.id} label={g.label} icon={g.icon} selected={draft.gender === g.id} onClick={() => update({ gender: g.id })}/>))}
</div>
<SectionLabel>Profession</SectionLabel>
<div className="space-y-1.5">
{PROFESSIONS.filter((p) => p.id !== 'custom').slice(0, 5).map((p) => (<button key={p.id} onClick={() => applyProfession(p.id)} className={[
'w-full flex items-center gap-3 px-3 py-2.5 rounded-xl border text-left transition-all text-xs',
draft.professionId === p.id
? 'border-purple-500/30 bg-purple-500/10 text-purple-200'
: 'border-white/[0.06] bg-white/[0.02] text-white/45 hover:bg-white/[0.05]',
].join(' ')}>
<span>{p.icon}</span>
<span className="font-medium">{p.label}</span>
{p.recommended && <span className="text-[7px] px-1 py-0.5 rounded bg-purple-500/20 text-purple-300 font-bold ml-auto">Recommended</span>}
</button>))}
</div>
{/* Standard / Romance & Roleplay / 18+ tabs β€” only when NSFW globally enabled */}
{nsfwEnabled && (<div className="flex items-center gap-1 p-1 rounded-xl bg-white/[0.03] border border-white/[0.06] w-fit">
<button onClick={() => setVibeTab('standard')} className={[
'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all',
vibeTab === 'standard' ? 'bg-white/10 text-white shadow-sm' : 'text-white/40 hover:text-white/60',
].join(' ')}>
Standard
</button>
<button onClick={() => setVibeTab('spicy')} className={[
'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all',
vibeTab === 'spicy'
? 'bg-gradient-to-r from-rose-500/20 to-orange-500/20 text-rose-300 border border-rose-500/20 shadow-sm'
: 'text-white/40 hover:text-rose-300/60',
].join(' ')}>
<Flame size={12}/> Romance &amp; Roleplay
</button>
<button onClick={() => setVibeTab('spicy')} className={[
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all',
vibeTab === 'spicy'
? 'bg-gradient-to-r from-red-500/20 to-rose-500/20 text-red-300 border border-red-500/20 shadow-sm'
: 'text-white/40 hover:text-red-300/60',
].join(' ')}>
18+
</button>
</div>)}
{/* Style vibe β€” switches between standard & spicy presets */}
<SectionLabel>Style Vibe</SectionLabel>
<div className={`grid gap-1.5 ${vibeTab === 'spicy' && nsfwEnabled ? 'grid-cols-4' : 'grid-cols-4'}`}>
{CHARACTER_STYLE_PRESETS
.filter((s) => vibeTab === 'spicy' && nsfwEnabled ? s.category === 'spicy' : s.category === 'standard')
.slice(0, vibeTab === 'spicy' && nsfwEnabled ? 8 : 4)
.map((s) => (<button key={s.id} onClick={() => update({ outfitStyle: s.id })} className={[
'flex items-center gap-1.5 px-2.5 py-2 rounded-lg text-[11px] font-medium border transition-all',
draft.outfitStyle === s.id
? vibeTab === 'spicy' && nsfwEnabled
? 'border-rose-500/30 bg-rose-500/10 text-rose-200'
: 'border-purple-500/30 bg-purple-500/10 text-purple-200'
: 'border-white/[0.06] bg-white/[0.02] text-white/40 hover:bg-white/[0.04]',
].join(' ')}>
<span>{s.icon}</span> {s.label}
</button>))}
</div>
{/* Randomize + Reset + Generate */}
<div className="flex justify-center gap-3 pt-4">
<button onClick={resetToDefaults} className="flex items-center gap-2 px-4 py-3 rounded-2xl text-sm font-medium text-white/30 hover:text-white/55 hover:bg-white/[0.04] border border-white/[0.06] transition-all">
<RotateCcw size={14}/> Reset
</button>
<button onClick={randomizeAppearance} className="flex items-center gap-2 px-5 py-3 rounded-2xl text-sm font-medium text-white/45 hover:text-white/70 hover:bg-white/[0.06] border border-white/[0.08] transition-all">
<Shuffle size={14}/> Randomize
</button>
<button onClick={async () => {
try {
const result = await gen.run({
mode: 'studio_random',
count: 4,
prompt: prompt || undefined,
truncation: 0.7,
checkpoint_override: checkpoint,
});
if (result?.results?.length) {
setWizardMode('studio');
setStep(6);
showToast(`${result.results.length} avatars generated β€” pick your favourite`, 'success');
}
}
catch {
showToast('Generation failed. Try again.', 'error');
}
}} disabled={gen.loading} className={[
'flex items-center gap-2.5 px-8 py-3 rounded-2xl text-sm font-semibold transition-all',
!gen.loading
? 'bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-lg shadow-purple-500/20 hover:shadow-purple-500/30 hover:brightness-110 active:scale-[0.98]'
: 'bg-white/[0.06] text-white/25 cursor-not-allowed',
].join(' ')}>
{gen.loading ? <Loader2 size={16} className="animate-spin"/> : <Zap size={16}/>}
{gen.loading ? 'Generating...' : 'Generate & Choose'}
</button>
</div>
</div>);
}
// (Character Preview sidebar removed β€” not needed for avatar-only flow)
// ---------------------------------------------------------------------------
// Main render
// ---------------------------------------------------------------------------
return (<div className="flex flex-col h-full bg-[#0a0a0f] text-white">
{/* ═══════════ HEADER ═══════════ */}
<div className="px-6 pt-5 pb-4 border-b border-white/[0.06] flex-shrink-0">
<div className="flex items-center justify-between max-w-6xl mx-auto">
<div className="flex items-center gap-3">
<button onClick={onClose} className="flex items-center gap-2 text-white/40 hover:text-white/70 transition-colors text-sm" title="Back to Gallery">
<ChevronLeft size={16}/>
</button>
<div className="flex items-center gap-2.5">
<Sparkles size={18} className="text-purple-400"/>
<h1 className="text-base font-semibold tracking-tight">Avatar Studio</h1>
</div>
</div>
{/* Quick / Studio toggle */}
<div className="flex items-center gap-1 p-1 rounded-xl bg-white/[0.03] border border-white/[0.06]">
<button onClick={() => setWizardMode('quick')} className={[
'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all',
wizardMode === 'quick' ? 'bg-white/10 text-white shadow-sm' : 'text-white/40 hover:text-white/60',
].join(' ')}>
<Zap size={12}/> Quick Create
</button>
<button onClick={() => setWizardMode('studio')} className={[
'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all',
wizardMode === 'studio' ? 'bg-white/10 text-white shadow-sm' : 'text-white/40 hover:text-white/60',
].join(' ')}>
<Layers size={12}/> Studio
</button>
</div>
</div>
</div>
{/* ═══════════ BODY ═══════════ */}
{wizardMode === 'quick' ? (<div className="flex-1 overflow-y-auto min-h-0 px-6 py-8">
{renderQuickCreate()}
</div>) : (<div className="flex-1 flex min-h-0">
{/* ── Sidebar ── */}
<nav className="w-48 flex-shrink-0 border-r border-white/[0.06] py-6 px-3 overflow-y-auto" aria-label="Wizard steps">
{WIZARD_STEPS.map((s, i) => {
const active = step === i;
const completed = i < step;
return (<button key={s.key} onClick={() => setStep(i)} aria-current={active ? 'step' : undefined} aria-label={`Step ${i + 1}: ${s.label}${completed ? ' (completed)' : ''}`} className={[
'w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-xs font-medium transition-all mb-1',
active
? 'bg-purple-500/10 text-purple-300 border border-purple-500/20'
: completed
? 'text-white/50 hover:bg-white/[0.04] border border-transparent'
: 'text-white/25 hover:bg-white/[0.03] border border-transparent',
].join(' ')}>
<span className={[
'w-5 h-5 rounded-full flex items-center justify-center text-[9px] font-bold flex-shrink-0',
active ? 'bg-purple-500/25 text-purple-300'
: completed ? 'bg-white/10 text-white/50' : 'bg-white/[0.05] text-white/20',
].join(' ')}>
{completed ? <Check size={10}/> : i + 1}
</span>
<span className="flex-1 text-left">{s.label}</span>
{!active && !completed && !isStepCustomized(i, draft) && i > 0 && (<span className="text-[8px] text-white/15 font-medium">default</span>)}
</button>);
})}
{/* Randomize / Reset buttons */}
<div className="mt-4 pt-4 border-t border-white/[0.06] space-y-1.5">
<button onClick={randomizeAppearance} className="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-xl text-xs font-medium text-white/35 hover:text-white/60 hover:bg-white/[0.04] border border-white/[0.06] transition-all">
<Shuffle size={12}/> Randomize Look
</button>
<button onClick={resetToDefaults} className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-xl text-[10px] font-medium text-white/20 hover:text-white/45 hover:bg-white/[0.03] transition-all">
<RotateCcw size={10}/> Reset Defaults
</button>
</div>
</nav>
{/* ── Main step content ── */}
<div className="flex-1 overflow-y-auto min-h-0 px-8 py-6">
<div className="max-w-2xl">
{/* Step header */}
<div className="mb-6">
<div className="text-xs text-white/20 mb-1">Step {step + 1} of 7</div>
<h2 className="text-lg font-semibold text-white/90">{WIZARD_STEPS[step].label}</h2>
</div>
{renderStep()}
{/* Navigation (not on Generate step β€” it has its own buttons) */}
{step < 6 && (<div className="flex items-center justify-between mt-8 pt-6 border-t border-white/[0.06]">
<button onClick={goBack} disabled={step === 0} className={[
'flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-all',
step > 0 ? 'text-white/50 hover:text-white/70 hover:bg-white/[0.04]' : 'text-white/15 cursor-not-allowed',
].join(' ')}>
<ChevronLeft size={14}/> Back
</button>
<button onClick={goNext} disabled={!canProceed} className={[
'flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold transition-all',
canProceed
? 'bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-md hover:shadow-lg hover:brightness-110 active:scale-[0.98]'
: 'bg-white/[0.06] text-white/25 cursor-not-allowed',
].join(' ')}>
Next <ChevronRight size={14}/>
</button>
</div>)}
</div>
</div>
{/* Preview sidebar removed β€” avatar-only flow */}
</div>)}
{/* ═══════════ TOAST ═══════════ */}
{toast && (<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-toastSlideUp">
<div className={[
'flex items-center gap-2.5 px-5 py-3 rounded-xl shadow-2xl backdrop-blur-md border text-sm font-medium',
toast.type === 'error' ? 'bg-red-500/15 border-red-500/20 text-red-300'
: toast.type === 'success' ? 'bg-green-500/15 border-green-500/20 text-green-300'
: 'bg-white/10 border-white/10 text-white/70',
].join(' ')}>
{toast.type === 'success' && <Sparkles size={16}/>}
<span>{toast.message}</span>
<button onClick={() => setToast(null)} className="ml-2 text-white/30 hover:text-white/60"><X size={14}/></button>
</div>
</div>)}
<style>{`
@keyframes fadeSlideIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
.animate-fadeSlideIn { animation: fadeSlideIn 0.35s ease-out; }
@keyframes toastSlideUp { from { opacity: 0; transform: translate(-50%, 16px); } to { opacity: 1; transform: translate(-50%, 0); } }
.animate-toastSlideUp { animation: toastSlideUp 0.25s ease-out; }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.5); } to { opacity: 1; transform: scale(1); } }
.animate-scaleIn { animation: scaleIn 0.2s ease-out; }
`}</style>
</div>);
}