/**
* 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 (
{icon && {icon} }{label}
);
}
function ColorSwatch({ color, selected, onClick, label }) {
return ( );
}
function SliderField({ label, value, min, max, step, onChange, unit }) {
return (
);
}
function SectionLabel({ children }) {
return {children}
;
}
function AdvancedToggle({ open, onToggle }) {
return (
Advanced
);
}
// ---------------------------------------------------------------------------
// 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 (
Gender
{GENDER_OPTIONS.map((g) => (
update({ gender: g.id })}/>))}
Age Range
{['young_adult', 'adult', 'mature'].map((a) => (
update({ ageRange: a })}/>))}
);
// =====================================================================
// STEP 1 — BODY
// =====================================================================
case 1:
return (
Body Type
{['slim', 'average', 'athletic', 'curvy'].map((b) => (
update({ bodyType: b })}/>))}
update({ heightCm: v })}/>
Posture
{['upright', 'relaxed', 'confident'].map((p) => (
update({ posture: p })}/>))}
Skin Tone
{SKIN_TONES.map((t) => ( update({ skinTone: t.id })}/>))}
Polish
{['natural', 'light_makeup', 'formal'].map((p) => (
update({ polish: p })}/>))}
);
// =====================================================================
// STEP 2 — FACE
// =====================================================================
case 2:
return (
Face Shape
{FACE_PRESETS.map((f) => (
update({ facePreset: f.id })}/>))}
Eye Color
{EYE_COLORS.map((c) => ( update({ eyeColor: c.id })}/>))}
Eye Shape
{EYE_SHAPES.map((s) => (
update({ eyeShape: s.id })}/>))}
Default Expression
{EXPRESSIONS.map((e) => (
update({ expression: e.id })}/>))}
setAdvancedOpen(!advancedOpen)}/>
{advancedOpen && (
update({ jawline: v })}/>
update({ lips: v })}/>
update({ browDefinition: v })}/>
)}
);
// =====================================================================
// STEP 3 — HAIR
// =====================================================================
case 3:
return (
Hairstyle
{HAIR_STYLES.map((h) => (
update({ hairStyle: h.id })}/>))}
Hair Color
{HAIR_COLORS.map((c) => ( update({ hairColor: c.id })}/>))}
update({ hairShine: v })}/>
);
// =====================================================================
// 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 (
Choose a Profession
Profession influences outfit style, pose, and scene context
{/* Search / filter */}
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"/>
{filteredProfs.length === 0 && (
No professions match “{professionSearch}”
)}
{filteredProfs.map((p) => {
const active = draft.professionId === p.id;
return (
{ 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(' ')}>
{p.icon}
{p.label}
{p.recommended && Recommended }
{p.description}
{active && }
);
})}
);
}
// =====================================================================
// STEP 5 — OUTFIT
// =====================================================================
case 5: {
const outfits = vibeTab === 'spicy' && nsfwEnabled
? [...OUTFIT_STYLES_SFW, ...OUTFIT_STYLES_NSFW]
: OUTFIT_STYLES_SFW;
return (
{/* SFW / NSFW tabs — only show when NSFW is globally enabled */}
{nsfwEnabled && (
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
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(' ')}>
Romance & Roleplay
18+
)}
Outfit Style
{outfits.map((o) => (
n.id === o.id) ? 'rose' : 'purple'} onClick={() => update({ outfitStyle: o.id })}/>))}
{/* ── 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 (
{/* Garment Type — multi-select, click toggles active, also sets viewing */}
Garment Type
Select one or more pieces. Click to toggle & configure.
{LINGERIE_GARMENTS.map((g) => {
const isActive = activeGarments.includes(g.id);
const isViewing = viewingGarment === g.id;
return (
{
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],
});
}
}}/>);
})}
{/* Active garment summary chips */}
{activeGarments.length > 1 && (
{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 ( update({ lingerieType: gId })}>
{g?.icon} {g?.label}{styleLabel ? `: ${styleLabel}` : ''}
);
})}
)}
{/* Style Picker — scoped to currently viewed garment */}
{viewingGarment && LINGERIE_STYLES[viewingGarment] && (
{viewingGarment === 'bottom' ? 'Panty Style' : viewingGarment === 'top' ? 'Bra Style' : viewingGarment === 'set' ? 'Set Style' : 'Bodysuit Style'}
{LINGERIE_STYLES[viewingGarment].map((s) => (
{
const newSel = { ...currentSel, style: currentSel.style === s.id ? null : s.id };
update({ lingerieSelections: { ...selections, [viewingGarment]: newSel } });
}}/>))}
)}
{/* Fabric — scoped to currently viewed garment */}
{viewingGarment && (
Fabric
{LINGERIE_FABRICS.map((f) => (
{
const newSel = { ...currentSel, fabric: currentSel.fabric === f.id ? null : f.id };
update({ lingerieSelections: { ...selections, [viewingGarment]: newSel } });
}}/>))}
)}
{/* Brand Inspired — scoped to currently viewed garment */}
{viewingGarment && (
Brand Inspired
{LINGERIE_BRANDS.map((b) => (
{
const newSel = { ...currentSel, brand: currentSel.brand === b.id ? null : b.id };
update({ lingerieSelections: { ...selections, [viewingGarment]: newSel } });
}}/>))}
)}
{/* Lingerie Accessories — shared across all garments */}
Lingerie Accessories
{LINGERIE_EXTRAS.map((e) => {
const active = (draft.lingerieExtras ?? []).includes(e.id);
return (
{
const prev = draft.lingerieExtras ?? [];
update({ lingerieExtras: active ? prev.filter((x) => x !== e.id) : [...prev, e.id] });
}}/>);
})}
{/* Primary & Secondary Color — inside lingerie builder for piece coloring */}
{activeGarments.includes('top') && activeGarments.includes('bottom') ? 'Top Color' : 'Primary Color'}
{COLOR_PALETTE.map((c) => ( update({ outfitPrimaryColor: c.id })}/>))}
{activeGarments.includes('top') && activeGarments.includes('bottom') ? 'Bottom Color' : 'Secondary Color'}
{COLOR_PALETTE.map((c) => ( update({ outfitSecondaryColor: c.id })}/>))}
);
})()}
{/* Primary & Secondary Color — shown only when NOT lingerie (lingerie has its own colors above) */}
{draft.outfitStyle !== 'lingerie' && draft.outfitStyle !== 'boudoir_wear' && (
Primary Color
{COLOR_PALETTE.map((c) => ( update({ outfitPrimaryColor: c.id })}/>))}
Secondary Color
{COLOR_PALETTE.map((c) => ( update({ outfitSecondaryColor: c.id })}/>))}
)}
{/* Accessories — collapsible, hidden by default */}
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">
Accessories
{draft.accessories.length > 0 && (
{draft.accessories.length}
)}
{advancedOpen && (
{ACCESSORIES.map((a) => {
const active = draft.accessories.includes(a.id);
return (
{
const next = active ? draft.accessories.filter((x) => x !== a.id) : [...draft.accessories, a.id];
update({ accessories: next });
}}/>);
})}
)}
{/* ── NSFW customization (18+ gated) ── */}
{nsfwEnabled && vibeTab === 'spicy' && (
Adult Content Controls
18+
{/* Exposure Level — 6-tier granular selector */}
Nudity / Exposure Level
{([
{ 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) => ( 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(' ')}>
{e.label}
{e.desc}
))}
{/* Intensity slider */}
update({ nsfwIntensity: v })}/>
{/* Sensual Pose — unified flat grid of all 9 poses */}
Sensual Pose
{([
{ 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) => (
update({ nsfwPose: p.id })}/>))}
{/* Dominance / Power dynamic */}
Power Dynamic
{([
{ id: 'soft', label: 'Soft & Romantic' },
{ id: 'balanced', label: 'Balanced' },
{ id: 'strong', label: 'Dominant & Bold' },
]).map((d) => (
update({ nsfwDominanceStyle: d.id })}/>))}
{/* Fantasy Tone */}
Fantasy Tone
{([
{ id: 'romantic', label: 'Romantic & Tender' },
{ id: 'seductive', label: 'Seductive & Alluring' },
{ id: 'dramatic', label: 'Dramatic & Intense' },
]).map((t) => (
update({ nsfwFantasyTone: t.id })}/>))}
{/* NSFW Scene / Background */}
Scene / Setting
{NSFW_BACKGROUNDS.map((b) => (
update({ background: b.id })}/>))}
)}
);
}
// =====================================================================
// STEP 6 — GENERATE
// =====================================================================
case 6: {
const needsRef = draft.generationMode === 'studio_reference' || draft.generationMode === 'studio_faceswap';
const canGen = !gen.loading && (!needsRef || !!draft.referenceUrl);
return (
{/* ── Generation Mode tabs ── */}
Generation Mode
{[
{ mode: 'studio_random', label: 'Design Character', icon:
},
{ mode: 'studio_reference', label: 'From Reference', icon: },
{ mode: 'studio_faceswap', label: 'Face + Style', icon: },
].map((m) => ( 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}
))}
{/* ── Reference upload (for ref/faceswap) ── */}
{needsRef && (
Upload a Face
{draft.referencePreview ? (
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">
) : (
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">
)}
{ const f = e.target.files?.[0]; if (f)
handleFileUpload(f); e.target.value = ''; }}/>
{draft.referencePreview ? (Reference photo attached ) : ( 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
)}
)}
{/* ── Style / Vibe selection (for reference/faceswap OR design character) ── */}
{draft.generationMode === 'studio_random' ? (<>
{/* Standard / Spicy tabs — only when NSFW globally enabled */}
{nsfwEnabled && (
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
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(' ')}>
Romance & Roleplay
18+
)}
{charStyles.map((s) => {
const active = draft.outfitStyle === s.id || false;
return ( 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(' ')}>
{s.icon}
{s.label}
);
})}
>) : (<>
{/* Vibe grid for reference/faceswap — tab bar only when NSFW enabled */}
{nsfwEnabled && (
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
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(' ')}>
Romance & Roleplay
18+
)}
{vibes.map((v) => ( 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(' ')}>
{v.icon}
{v.label}
))}
>)}
{/* ── Portrait / Pose / Background controls ── */}
Portrait Type
{['headshot', 'half_body', 'mid_body', 'full_body'].map((t) => (
update({ portraitType: t })}/>))}
Pose
{POSES.map((p) => (
update({ pose: p.id })}/>))}
Background
{BACKGROUNDS.map((b) => (
update({ background: b.id })}/>))}
{/* ── Advanced ── */}
setAdvancedOpen(!advancedOpen)}/>
{advancedOpen && (
update({ realism: v })}/>
update({ detailLevel: v })}/>
Lighting
{LIGHTING_OPTIONS.map((l) => (
update({ lighting: l.id })}/>))}
)}
{/* ── Generate Button + Count Selector ── */}
{gen.loading ? (<> Generating...>) : (<> Generate ({count})>)}
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}>
{showCountMenu && (<>
setShowCountMenu(false)}/>
{[1, 4, 8].map((n) => ( { 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' : ''} ))}
>)}
{gen.loading && (
Cancel
)}
{canGen && !gen.loading && (
Ctrl+Enter )}
{/* ── Loading skeleton ── */}
{gen.loading && (
{Array.from({ length: count }).map((_, i) => (
))}
)}
{/* ── Results grid ── */}
{gen.result?.results?.length ? (
{gen.result.results.length === 1 ? 'Your Avatar' : 'Choose Your Avatar'}
{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 (
{
if (blurred) {
setShowNsfw(true);
return;
}
setSelectedResultIndex(isSelected ? null : i);
}}>
{blurred && (
Click to reveal
)}
{isSelected && !blurred && (
)}
);
})}
{/* ── Create Avatar button ── */}
{selectedResultIndex !== null && gen.result.results[selectedResultIndex] && (
Create Avatar
Saves avatar to your gallery — export as persona later
)}
) : !gen.loading && !gen.result ? (
Your avatars will appear here
Configure your character above, then click Generate
) : null}
);
}
default:
return null;
}
}
// ---------------------------------------------------------------------------
// Render: Quick Create mode
// ---------------------------------------------------------------------------
function renderQuickCreate() {
const canGen = !gen.loading;
return (
Quick Create
Gender, profession, style — done in 60 seconds
Gender
{GENDER_OPTIONS.map((g) => (
update({ gender: g.id })}/>))}
Profession
{PROFESSIONS.filter((p) => p.id !== 'custom').slice(0, 5).map((p) => ( 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(' ')}>
{p.icon}
{p.label}
{p.recommended && Recommended }
))}
{/* Standard / Romance & Roleplay / 18+ tabs — only when NSFW globally enabled */}
{nsfwEnabled && (
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
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(' ')}>
Romance & Roleplay
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+
)}
{/* Style vibe — switches between standard & spicy presets */}
Style Vibe
{CHARACTER_STYLE_PRESETS
.filter((s) => vibeTab === 'spicy' && nsfwEnabled ? s.category === 'spicy' : s.category === 'standard')
.slice(0, vibeTab === 'spicy' && nsfwEnabled ? 8 : 4)
.map((s) => ( 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(' ')}>
{s.icon} {s.label}
))}
{/* Randomize + Reset + Generate */}
Reset
Randomize
{
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 ? : }
{gen.loading ? 'Generating...' : 'Generate & Choose'}
);
}
// (Character Preview sidebar removed — not needed for avatar-only flow)
// ---------------------------------------------------------------------------
// Main render
// ---------------------------------------------------------------------------
return (
{/* ═══════════ HEADER ═══════════ */}
{/* Quick / Studio toggle */}
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(' ')}>
Quick Create
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(' ')}>
Studio
{/* ═══════════ BODY ═══════════ */}
{wizardMode === 'quick' ? (
{renderQuickCreate()}
) : (
{/* ── Sidebar ── */}
{WIZARD_STEPS.map((s, i) => {
const active = step === i;
const completed = i < step;
return ( 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(' ')}>
{completed ? : i + 1}
{s.label}
{!active && !completed && !isStepCustomized(i, draft) && i > 0 && (default )}
);
})}
{/* Randomize / Reset buttons */}
Randomize Look
Reset Defaults
{/* ── Main step content ── */}
{/* Step header */}
Step {step + 1} of 7
{WIZARD_STEPS[step].label}
{renderStep()}
{/* Navigation (not on Generate step — it has its own buttons) */}
{step < 6 && (
0 ? 'text-white/50 hover:text-white/70 hover:bg-white/[0.04]' : 'text-white/15 cursor-not-allowed',
].join(' ')}>
Back
Next
)}
{/* Preview sidebar removed — avatar-only flow */}
)}
{/* ═══════════ TOAST ═══════════ */}
{toast && (
{toast.type === 'success' && }
{toast.message}
setToast(null)} className="ml-2 text-white/30 hover:text-white/60">
)}
);
}