Исходники пакета · What Are They Wearing?
CC BY 4.0 · укажите @joe · v1
Берите, делайте ремикс, учите с ним
Код ниже — только играемый входной HTML пакета. Скачайте полный ZIP, чтобы править или ремикшировать игру со всеми относительными ресурсами.
Заготовка подсказки для ИИ
Edit this classroom game for interactive whiteboards. Keep large touch targets and whole-class play. Tracking, logins, personal-data collection, and remote API calls are prohibited. Use the downloaded complete ZIP—not only the entry HTML shown below—and preserve its browser-ready folder structure. Preserve the educational goal. Original creator: @joe on ClassBoard Games (CC BY 4.0).
Только входной HTML · index.html · 10707.6 KB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>What Are They Wearing?</title>
<!-- Modern Editorial Typography: Outfit & Plus Jakarta Sans -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@600;700;800;900&family=Plus+Jakarta+Sans:wght@500;600;700;800&display=swap" rel="stylesheet">
<!-- React & ReactDOM 18 -->
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Pre-rendered Commanding British Male Voice Library (en-GB-RyanNeural -8Hz Baritone) -->
<script src="assets/audio_ryan_uk.js"></script>
<style>
:root {
--font-display: 'Outfit', system-ui, -apple-system, sans-serif;
--font-sans: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
--bg-app: #0F172A;
--card-bg: rgba(255, 255, 255, 0.97);
--card-border: rgba(226, 232, 240, 0.95);
--text-main: #0F172A;
--text-muted: #475569;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
width: 100vw;
height: 100vh;
overflow: hidden;
font-family: var(--font-sans);
background: radial-gradient(120% 120% at 50% 15%, #1E293B 0%, #0F172A 65%, #090D16 100%);
color: var(--text-main);
-webkit-font-smoothing: antialiased;
user-select: none;
}
@keyframes subtleBreathe {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-3px); }
}
@keyframes quickShuffle {
0% { transform: scale(0.97) translateY(3px); filter: blur(1.2px); opacity: 0.85; }
50% { transform: scale(1.015) translateY(-3px); filter: blur(1.8px); opacity: 0.95; }
100% { transform: scale(0.97) translateY(3px); filter: blur(1.2px); opacity: 0.85; }
}
@keyframes eyeBlink {
0%, 94%, 100% { transform: scaleY(1); }
97% { transform: scaleY(0.08); }
}
@keyframes slideUpFade {
0% { opacity: 0; transform: translateY(8px); }
100% { opacity: 1; transform: translateY(0); }
}
@keyframes arrowPulseLeft {
0%, 100% { transform: translateX(0px); }
50% { transform: translateX(8px); }
}
@keyframes arrowPulseRight {
0%, 100% { transform: translateX(0px); }
50% { transform: translateX(-8px); }
}
@keyframes targetRingPing {
0% { r: 8px; opacity: 0.95; }
100% { r: 26px; opacity: 0; }
}
.person-idle {
animation: subtleBreathe 3.8s ease-in-out infinite;
}
.person-spinning {
animation: quickShuffle 0.14s ease-in-out infinite;
}
.eye-blink-group {
transform-origin: center 82px;
animation: eyeBlink 5.2s infinite;
}
.garment-hotspot {
cursor: pointer;
transition: filter 0.18s ease, opacity 0.18s ease;
}
.garment-hotspot:hover {
filter: brightness(1.08) drop-shadow(0 0 8px rgba(37, 99, 235, 0.4));
}
.arrow-nudge-left {
animation: arrowPulseLeft 0.75s ease-in-out infinite;
}
.arrow-nudge-right {
animation: arrowPulseRight 0.75s ease-in-out infinite;
}
.target-ping-circle {
animation: targetRingPing 1.1s cubic-bezier(0, 0, 0.2, 1) infinite;
}
.fade-in-panel {
animation: slideUpFade 0.25s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
/* App Layout */
.app-shell {
width: 100vw;
height: 100vh;
max-width: 1460px;
margin: 0 auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.top-nav {
height: 64px;
background: rgba(15, 23, 42, 0.82);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 16px;
padding: 0 18px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #F8FAFC;
flex-shrink: 0;
}
.workspace-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(360px, 480px) 1fr;
gap: 14px;
}
@media (max-width: 920px) {
.workspace-grid {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
.hide-mobile {
display: none !important;
}
}
.studio-card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 20px;
box-shadow: 0 18px 42px -10px rgba(0, 0, 0, 0.38);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.btn-modern {
font-family: var(--font-sans);
font-weight: 800;
border: none;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.16s ease;
}
.btn-modern:active:not(:disabled) {
transform: scale(0.96);
}
/* Huge Spin Button Styling */
.btn-mega-spin {
background: linear-gradient(135deg, #10B981 0%, #059669 50%, #047857 100%);
color: #FFFFFF;
font-family: var(--font-display);
font-weight: 900;
letter-spacing: 0.02em;
border-radius: 16px;
border: 2px solid #6EE7B7;
box-shadow: 0 6px 0 #065F46, 0 12px 24px rgba(16, 185, 129, 0.4);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: transform 0.14s ease, box-shadow 0.14s ease, filter 0.14s ease;
}
.btn-mega-spin:hover:not(:disabled) {
filter: brightness(1.06);
transform: translateY(-2px);
box-shadow: 0 8px 0 #065F46, 0 16px 28px rgba(16, 185, 129, 0.5);
}
.btn-mega-spin:active:not(:disabled) {
transform: translateY(4px);
box-shadow: 0 2px 0 #065F46, 0 6px 12px rgba(16, 185, 129, 0.3);
}
.custom-scroll::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #CBD5E1;
border-radius: 999px;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useEffect, useMemo, useState, useCallback, useRef } = React;
// ==================== CLOTHING, COLOR & ANATOMY POOLS ====================
const clothingPools = {
tops: [
{ name: 'dress', singular: true },
{ name: 'jacket', singular: true },
{ name: 'sweater', singular: true },
{ name: 'raincoat', singular: true },
{ name: 'shirt', singular: true },
{ name: 't-shirt', singular: true },
{ name: 'hoodie', singular: true },
],
bottoms: [
{ name: 'pants', singular: false },
{ name: 'jeans', singular: false },
{ name: 'shorts', singular: false },
{ name: 'skirt', singular: true },
],
accessories: [
{ name: 'belt', singular: true },
{ name: 'boots', singular: false },
{ name: 'shoes', singular: false },
{ name: 'mittens', singular: false },
{ name: 'big hat', singular: true },
{ name: 'small hat', singular: true },
{ name: 'cap', singular: true },
{ name: 'scarf', singular: true },
{ name: 'sunglasses', singular: false },
],
};
const colors = [
{ name: 'red', hex: '#DC2626', dark: '#991B1B', light: '#FEE2E2', highlight: '#F87171' },
{ name: 'blue', hex: '#2563EB', dark: '#1E40AF', light: '#DBEAFE', highlight: '#60A5FA' },
{ name: 'green', hex: '#16A34A', dark: '#166534', light: '#DCFCE7', highlight: '#4ADE80' },
{ name: 'yellow', hex: '#EAB308', dark: '#A16207', light: '#FEF9C3', highlight: '#FDE047' },
{ name: 'purple', hex: '#7C3AED', dark: '#5B21B6', light: '#EDE9FE', highlight: '#A78BFA' },
{ name: 'orange', hex: '#EA580C', dark: '#9A3412', light: '#FFEDD5', highlight: '#FB923C' },
{ name: 'pink', hex: '#DB2777', dark: '#9D174D', light: '#FCE7F3', highlight: '#F472B6' },
{ name: 'brown', hex: '#78350F', dark: '#451A03', light: '#FEF3C7', highlight: '#B45309' },
];
const skinPalettes = [
{ id: 'fair', base: '#FAD7B8', highlight: '#FFF0E3', shadow: '#D9A47E', deep: '#B37A54', lip: '#D97B78', blush: '#F4978E' },
{ id: 'warm-beige', base: '#ECC096', highlight: '#FCE2C8', shadow: '#C58E62', deep: '#99643C', lip: '#C96D68', blush: '#E8887A' },
{ id: 'olive-tan', base: '#D89E6E', highlight: '#F0C299', shadow: '#B07244', deep: '#834D25', lip: '#B65B56', blush: '#D67464' },
{ id: 'amber-brown', base: '#B97846', highlight: '#D69A6A', shadow: '#8F5326', deep: '#633412', lip: '#9B4745', blush: '#B85A4A' },
{ id: 'espresso', base: '#7E4B28', highlight: '#9D6740', shadow: '#593014', deep: '#3A1C08', lip: '#6E3234', blush: '#8A3E36' },
];
const hairPalettes = [
{ name: 'Espresso', base: '#1E1815', highlight: '#4A3C35', dark: '#0B0908' },
{ name: 'Chestnut', base: '#3B2416', highlight: '#6E4730', dark: '#1F1109' },
{ name: 'Auburn', base: '#633B1F', highlight: '#9A633A', dark: '#381E0D' },
{ name: 'Golden Brown', base: '#8C5B32', highlight: '#C28B58', dark: '#543216' },
{ name: 'Raven', base: '#2B1D28', highlight: '#563D50', dark: '#140C12' },
];
const eyeColors = ['#4A3222', '#2C5E7A', '#3D6B48', '#63472B', '#2B2623'];
const getRandom = (arr) => arr[Math.floor(Math.random() * arr.length)];
const getArticle = (colorName) =>
['a', 'e', 'i', 'o', 'u'].includes(colorName[0].toLowerCase()) ? 'an ' : 'a ';
const formatItemPhrase = (item) => {
const art = item.clothing.singular ? getArticle(item.color.name) : '';
return `${art}${item.color.name} ${item.clothing.name}`;
};
const formatFullSentence = (state) => {
if (!state) return '';
const pronoun = state.gender === 'he' ? 'He' : 'She';
const parts = state.items.map(formatItemPhrase);
const listStr =
parts.length > 1
? `${parts.slice(0, -1).join(', ')}, and ${parts[parts.length - 1]}`
: parts[0];
return `${pronoun} is wearing ${listStr}.`;
};
/**
* Exact anatomical anchor coordinates for arrows pointing to every garment or accessory on the person.
*/
const getGarmentAnchor = (clothingName, hasDress = false) => {
switch (clothingName) {
case 'big hat':
case 'small hat':
case 'cap':
return { side: 'right', tx: 226, ty: 32, sx: 342, sy: 24 };
case 'sunglasses':
return { side: 'right', tx: 228, ty: 83, sx: 342, sy: 76 };
case 'scarf':
return { side: 'right', tx: 222, ty: 152, sx: 342, sy: 145 };
case 'dress':
return { side: 'left', tx: 136, ty: 238, sx: 34, sy: 225 };
case 'jacket':
case 'sweater':
case 'raincoat':
case 'shirt':
case 't-shirt':
case 'hoodie':
return { side: 'left', tx: 134, ty: 214, sx: 34, sy: 200 };
case 'belt':
return {
side: 'right',
tx: hasDress ? 232 : 238,
ty: hasDress ? 258 : 298,
sx: 342,
sy: hasDress ? 250 : 288,
};
case 'mittens':
return { side: 'right', tx: 298, ty: 336, sx: 368, sy: 318 };
case 'shorts':
case 'skirt':
return { side: 'left', tx: 140, ty: 376, sx: 34, sy: 370 };
case 'pants':
case 'jeans':
return { side: 'left', tx: 145, ty: 410, sx: 34, sy: 404 };
case 'boots':
return { side: 'right', tx: 235, ty: 522, sx: 342, sy: 512 };
case 'shoes':
default:
return { side: 'right', tx: 242, ty: 568, sx: 342, sy: 556 };
}
};
const generateRandomState = (forcedGender = null) => {
const gender = forcedGender || (Math.random() > 0.5 ? 'he' : 'she');
const skin = getRandom(skinPalettes);
const hair = getRandom(hairPalettes);
const eyeColor = getRandom(eyeColors);
const hairStyle = Math.floor(Math.random() * 2);
const topPool = gender === 'he'
? clothingPools.tops.filter((item) => item.name !== 'dress')
: clothingPools.tops;
const top = getRandom(topPool);
const accessory = getRandom(clothingPools.accessories);
const items = [{ clothing: top, color: getRandom(colors) }];
if (top.name !== 'dress') {
const bottomPool = gender === 'he'
? clothingPools.bottoms.filter((item) => item.name !== 'skirt')
: clothingPools.bottoms;
const bottom = getRandom(bottomPool);
items.push({ clothing: bottom, color: getRandom(colors) });
}
items.push({ clothing: accessory, color: getRandom(colors) });
return {
id: Math.random().toString(36).slice(2),
gender,
skin,
hair,
eyeColor,
hairStyle,
items,
};
};
// ==================== COMMANDING BRITISH MALE VOICE ENGINE ====================
class CommandingBritishVoiceEngine {
constructor(onSpeakingChange, onSegmentIndexChange) {
this.ctx = null;
this.decodedCache = new Map();
this.activeSource = null;
this.timers = [];
this.onSpeakingChange = onSpeakingChange;
this.onSegmentIndexChange = onSegmentIndexChange;
this.preferredVoice = null;
this.initWebSpeech();
}
initWebSpeech() {
if (!('speechSynthesis' in window)) return;
const pickBritishMale = () => {
const voices = window.speechSynthesis.getVoices() || [];
const rankVoice = (v) => {
const name = v.name.toLowerCase();
const lang = (v.lang || '').toLowerCase();
let score = 0;
if (lang.includes('en-gb') || lang.includes('en_gb')) score += 50;
if (name.includes('ryan')) score += 100;
if (name.includes('google uk english male')) score += 95;
if (name.includes('daniel')) score += 90;
if (name.includes('arthur') || name.includes('oliver') || name.includes('george')) score += 85;
if (name.includes('male') || name.includes('man')) score += 40;
if (name.includes('female') || name.includes('woman') || name.includes('hazel') || name.includes('libby') || name.includes('sonia') || name.includes('serena') || name.includes('kate')) score -= 120;
return score;
};
const sorted = [...voices].sort((a, b) => rankVoice(b) - rankVoice(a));
if (sorted.length > 0 && rankVoice(sorted[0]) > 0) {
this.preferredVoice = sorted[0];
}
};
pickBritishMale();
window.speechSynthesis.onvoiceschanged = pickBritishMale;
}
ensureContext() {
if (!this.ctx) {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
this.ctx = new AudioCtx();
}
if (this.ctx.state === 'suspended') {
this.ctx.resume();
}
}
stop() {
this.timers.forEach((t) => clearTimeout(t));
this.timers = [];
if (this.activeSource) {
try {
this.activeSource.stop();
} catch (_) {}
this.activeSource = null;
}
if ('speechSynthesis' in window) {
window.speechSynthesis.cancel();
}
this.onSpeakingChange?.(false);
this.onSegmentIndexChange?.(-1);
}
async decodeClip(key) {
const map = window.RYAN_UK_AUDIO;
if (!map || !map[key]) return null;
if (this.decodedCache.has(key)) return this.decodedCache.get(key);
this.ensureContext();
const dataUri = map[key];
const base64 = dataUri.split(',')[1];
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const audioBuffer = await this.ctx.decodeAudioData(bytes.buffer.slice(0));
const trimmed = this.trimSilence(audioBuffer);
this.decodedCache.set(key, trimmed);
return trimmed;
}
trimSilence(buffer) {
const channel = buffer.getChannelData(0);
const len = channel.length;
const threshold = 0.009;
let start = 0;
let end = len - 1;
while (start < len && Math.abs(channel[start]) < threshold) start++;
while (end > start && Math.abs(channel[end]) < threshold) end--;
const pad = Math.floor(buffer.sampleRate * 0.025);
start = Math.max(0, start - pad);
end = Math.min(len - 1, end + pad);
const newLen = Math.max(1, end - start);
const out = this.ctx.createBuffer(buffer.numberOfChannels, newLen, buffer.sampleRate);
for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
out.getChannelData(ch).set(buffer.getChannelData(ch).subarray(start, end));
}
return out;
}
async playClipSequence(keys, fallbackText, segmentIndices = []) {
this.stop();
const map = window.RYAN_UK_AUDIO;
if (map && keys.every((k) => Boolean(map[k]))) {
try {
this.ensureContext();
this.onSpeakingChange?.(true);
const buffers = await Promise.all(keys.map((k) => this.decodeClip(k)));
const pauseSamples = Math.floor(this.ctx.sampleRate * 0.055);
const totalLength =
buffers.reduce((acc, b) => acc + b.length, 0) + pauseSamples * Math.max(0, buffers.length - 1);
const combined = this.ctx.createBuffer(
buffers[0].numberOfChannels,
totalLength,
this.ctx.sampleRate
);
let offset = 0;
const offsetsSec = [];
buffers.forEach((buf) => {
offsetsSec.push(offset / this.ctx.sampleRate);
for (let ch = 0; ch < combined.numberOfChannels; ch++) {
const srcChan = buf.getChannelData(Math.min(ch, buf.numberOfChannels - 1));
combined.getChannelData(ch).set(srcChan, offset);
}
offset += buf.length + pauseSamples;
});
offsetsSec.forEach((sec, idx) => {
const segIdx = segmentIndices[idx] ?? -1;
const timer = setTimeout(() => {
this.onSegmentIndexChange?.(segIdx);
}, sec * 1000);
this.timers.push(timer);
});
const source = this.ctx.createBufferSource();
source.buffer = combined;
source.connect(this.ctx.destination);
this.activeSource = source;
source.onended = () => {
if (this.activeSource === source) {
this.activeSource = null;
this.onSpeakingChange?.(false);
this.onSegmentIndexChange?.(-1);
}
};
source.start(0);
return;
} catch (err) {
console.warn('Studio audio fallback to WebSpeech:', err);
}
}
this.speakFallback(fallbackText, segmentIndices[0] ?? -1);
}
speakFallback(text, segIdx = -1) {
if (!('speechSynthesis' in window) || !text) return;
this.stop();
const utterance = new SpeechSynthesisUtterance(text);
if (this.preferredVoice) {
utterance.voice = this.preferredVoice;
}
utterance.lang = 'en-GB';
utterance.pitch = 0.76;
utterance.rate = 0.96;
utterance.onstart = () => {
this.onSpeakingChange?.(true);
this.onSegmentIndexChange?.(segIdx);
};
utterance.onend = () => {
this.onSpeakingChange?.(false);
this.onSegmentIndexChange?.(-1);
};
window.speechSynthesis.speak(utterance);
}
speakOutfitSentence(gameState) {
if (!gameState) return;
const leadKey = gameState.gender === 'he' ? 'lead_he' : 'lead_she';
const keys = [leadKey];
const segIndices = [-1];
gameState.items.forEach((item, idx) => {
const isLast = idx === gameState.items.length - 1 && gameState.items.length > 1;
const clipKey = isLast
? `and_${item.color.name}_${item.clothing.name}`
: `pair_${item.color.name}_${item.clothing.name}`;
keys.push(clipKey);
segIndices.push(idx);
});
this.playClipSequence(keys, formatFullSentence(gameState), segIndices);
}
speakSingleKey(key, fallbackText, segIdx = -1) {
this.playClipSequence([key], fallbackText, [segIdx]);
}
}
// ==================== LIFELIKE HUMAN ILLUSTRATION + DYNAMIC ARROWS ====================
function RealisticPerson({
gender,
skin,
hair,
eyeColor,
hairStyle,
items,
activeSegIdx,
inspectedItem,
showAllArrows,
onSelectItem,
}) {
const get = (name) => items.find((i) => i.clothing.name === name);
const shoeItem = get('shoes');
const bootsItem = get('boots');
const scarfItem = get('scarf');
const mittensItem = get('mittens');
const beltItem = get('belt');
const bigHatItem = get('big hat');
const smallHatItem = get('small hat');
const capItem = get('cap');
const sunglassesItem = get('sunglasses');
const topItem =
get('dress') ||
get('raincoat') ||
get('jacket') ||
get('hoodie') ||
get('sweater') ||
get('shirt') ||
get('t-shirt');
const pantsItem = get('pants');
const jeansItem = get('jeans');
const shortsItem = get('shorts');
const skirtItem = get('skirt');
const dressItem = get('dress');
const topColor = topItem?.color || colors[0];
const bottomItem = pantsItem || jeansItem || shortsItem || skirtItem;
const bottomColor = bottomItem?.color || colors[1];
const isFemale = gender === 'she';
const LINE = '#1E293B';
const handleHotspotClick = (e, item, idx) => {
if (!item) return;
e.stopPropagation();
onSelectItem?.(item, idx);
};
// Determine which item(s) should have arrows pointing to them right now
const focusedItem =
activeSegIdx >= 0 && items[activeSegIdx]
? items[activeSegIdx]
: inspectedItem;
const arrowsToRender = useMemo(() => {
if (focusedItem) {
return [{ item: focusedItem, isPrimary: true }];
}
if (showAllArrows) {
return items.map((it) => ({ item: it, isPrimary: false }));
}
return [];
}, [focusedItem, showAllArrows, items]);
return (
<svg
viewBox="-75 -12 530 650"
xmlns="http://www.w3.org/2000/svg"
style={{ width: '100%', height: '100%', overflow: 'visible' }}
>
<defs>
<filter id="figureShadow" x="-20%" y="-10%" width="140%" height="130%">
<feDropShadow dx="0" dy="10" stdDeviation="9" floodColor="#0F172A" floodOpacity="0.18" />
</filter>
<filter id="subtleShadow" x="-10%" y="-10%" width="120%" height="120%">
<feDropShadow dx="0" dy="3" stdDeviation="3" floodColor="#0F172A" floodOpacity="0.15" />
</filter>
<filter id="arrowDropShadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="4" stdDeviation="4" floodColor="#0F172A" floodOpacity="0.32" />
</filter>
<radialGradient id="pedestalShadow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#0F172A" stopOpacity="0.28" />
<stop offset="65%" stopColor="#0F172A" stopOpacity="0.08" />
<stop offset="100%" stopColor="#0F172A" stopOpacity="0" />
</radialGradient>
<radialGradient id="faceSkinGrad" cx="45%" cy="38%" r="60%">
<stop offset="0%" stopColor={skin.highlight} />
<stop offset="55%" stopColor={skin.base} />
<stop offset="92%" stopColor={skin.shadow} />
<stop offset="100%" stopColor={skin.deep} />
</radialGradient>
<linearGradient id="neckSkinGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={skin.deep} />
<stop offset="32%" stopColor={skin.shadow} />
<stop offset="100%" stopColor={skin.base} />
</linearGradient>
<linearGradient id="limbSkinLeft" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={skin.highlight} />
<stop offset="60%" stopColor={skin.base} />
<stop offset="100%" stopColor={skin.shadow} />
</linearGradient>
<linearGradient id="limbSkinRight" x1="1" y1="0" x2="0" y2="0">
<stop offset="0%" stopColor={skin.highlight} />
<stop offset="60%" stopColor={skin.base} />
<stop offset="100%" stopColor={skin.shadow} />
</linearGradient>
<linearGradient id="hairGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor={hair.highlight} />
<stop offset="45%" stopColor={hair.base} />
<stop offset="100%" stopColor={hair.dark} />
</linearGradient>
<linearGradient id="topGrad" x1="0.2" y1="0" x2="0.8" y2="1">
<stop offset="0%" stopColor={topColor.highlight} />
<stop offset="45%" stopColor={topColor.hex} />
<stop offset="100%" stopColor={topColor.dark} />
</linearGradient>
<linearGradient id="bottomGrad" x1="0.2" y1="0" x2="0.8" y2="1">
<stop offset="0%" stopColor={bottomColor.highlight} />
<stop offset="40%" stopColor={bottomColor.hex} />
<stop offset="100%" stopColor={bottomColor.dark} />
</linearGradient>
</defs>
{/* Studio Floor Pedestal Disc */}
<ellipse cx="190" cy="602" rx="105" ry="18" fill="url(#pedestalShadow)" />
<g filter="url(#figureShadow)">
{/* 1. BACK HAIR LAYER */}
{isFemale && (
<g>
{hairStyle === 0 ? (
<path
d="M 132 72 C 110 95, 104 145, 108 196 C 111 224, 125 238, 142 232 C 155 228, 225 228, 238 232 C 255 238, 269 224, 272 196 C 276 145, 270 95, 248 72 Z"
fill="url(#hairGrad)"
stroke={hair.dark}
strokeWidth="2"
/>
) : (
<path
d="M 134 78 C 116 105, 115 152, 126 178 C 142 190, 238 190, 254 178 C 265 152, 264 105, 246 78 Z"
fill="url(#hairGrad)"
stroke={hair.dark}
strokeWidth="2"
/>
)}
</g>
)}
{/* 2. HOOD BACK LAYER */}
{(topItem?.clothing.name === 'hoodie' || topItem?.clothing.name === 'raincoat') && (
<path
d="M 132 142 C 122 115, 136 96, 190 96 C 244 96, 258 115, 248 142 Z"
fill={topColor.dark}
stroke={LINE}
strokeWidth="2.2"
/>
)}
{/* 3. ANATOMICAL LEGS */}
<g>
<path
d="M 146 330 C 144 375, 147 415, 150 442 C 146 472, 148 515, 154 558 L 172 558 C 176 515, 179 472, 176 442 C 179 415, 183 375, 184 330 Z"
fill="url(#limbSkinLeft)"
stroke={skin.deep}
strokeWidth="1.8"
/>
<path d="M 155 436 Q 163 443 171 436" fill="none" stroke={skin.shadow} strokeWidth="2" strokeLinecap="round" opacity="0.7" />
<ellipse cx="163" cy="430" rx="7" ry="5" fill={skin.highlight} opacity="0.35" />
<path
d="M 196 330 C 197 375, 201 415, 204 442 C 201 472, 204 515, 208 558 L 226 558 C 232 515, 234 472, 230 442 C 233 415, 236 375, 234 330 Z"
fill="url(#limbSkinRight)"
stroke={skin.deep}
strokeWidth="1.8"
/>
<path d="M 209 436 Q 217 443 225 436" fill="none" stroke={skin.shadow} strokeWidth="2" strokeLinecap="round" opacity="0.7" />
<ellipse cx="217" cy="430" rx="7" ry="5" fill={skin.highlight} opacity="0.35" />
</g>
{/* 4. SOCKS & DEFAULT LOAFERS */}
{!bootsItem && (
<g>
<path d="M 152 528 L 173 528 L 172 562 L 153 562 Z" fill="#F8FAFC" stroke="#94A3B8" strokeWidth="1.6" />
<line x1="152" y1="534" x2="173" y2="534" stroke="#CBD5E1" strokeWidth="2" />
<path d="M 207 528 L 228 528 L 227 562 L 208 562 Z" fill="#F8FAFC" stroke="#94A3B8" strokeWidth="1.6" />
<line x1="207" y1="534" x2="228" y2="534" stroke="#CBD5E1" strokeWidth="2" />
</g>
)}
{!shoeItem && !bootsItem && (
<g>
<path d="M 151 556 C 144 562, 134 572, 134 581 C 134 587, 144 590, 162 590 C 172 590, 176 586, 175 576 L 173 556 Z" fill="#E2E8F0" stroke="#475569" strokeWidth="2" />
<path d="M 207 556 L 205 576 C 204 586, 208 590, 218 590 C 236 590, 246 587, 246 581 C 246 572, 236 562, 229 556 Z" fill="#E2E8F0" stroke="#475569" strokeWidth="2" />
</g>
)}
{/* 5. BOTTOMS */}
<g className="garment-hotspot" onClick={(e) => handleHotspotClick(e, bottomItem, 1)}>
{shortsItem && (
<g>
<path
d="M 140 296 L 240 296 L 238 410 C 223 415, 206 415, 194 408 L 190 352 L 186 408 C 174 415, 157 415, 142 410 Z"
fill="url(#bottomGrad)"
stroke={LINE}
strokeWidth="2.4"
strokeLinejoin="round"
/>
<line x1="190" y1="298" x2="190" y2="348" stroke={bottomColor.dark} strokeWidth="2" />
<path d="M 141 306 Q 156 320 144 342" fill="none" stroke={bottomColor.dark} strokeWidth="1.8" />
<path d="M 239 306 Q 224 320 236 342" fill="none" stroke={bottomColor.dark} strokeWidth="1.8" />
</g>
)}
{pantsItem && (
<g>
<path
d="M 140 295 L 240 295 C 241 365, 236 445, 231 548 C 221 553, 212 553, 204 548 C 201 462, 196 392, 190 350 C 184 392, 179 462, 176 548 C 168 553, 159 553, 149 548 C 144 445, 139 365, 140 295 Z"
fill="url(#bottomGrad)"
stroke={LINE}
strokeWidth="2.4"
strokeLinejoin="round"
/>
<line x1="163" y1="340" x2="163" y2="542" stroke="#FFFFFF" strokeOpacity="0.22" strokeWidth="1.8" />
<line x1="217" y1="340" x2="217" y2="542" stroke="#FFFFFF" strokeOpacity="0.22" strokeWidth="1.8" />
<line x1="190" y1="296" x2="190" y2="346" stroke={bottomColor.dark} strokeWidth="2" />
</g>
)}
{jeansItem && (
<g>
<path
d="M 140 295 L 240 295 C 241 365, 236 445, 231 548 C 221 553, 212 553, 204 548 C 201 462, 196 392, 190 350 C 184 392, 179 462, 176 548 C 168 553, 159 553, 149 548 C 144 445, 139 365, 140 295 Z"
fill="url(#bottomGrad)"
stroke={LINE}
strokeWidth="2.4"
strokeLinejoin="round"
/>
<ellipse cx="164" cy="395" rx="11" ry="48" fill="#FFFFFF" opacity="0.18" />
<ellipse cx="216" cy="395" rx="11" ry="48" fill="#FFFFFF" opacity="0.18" />
<path d="M 190 296 L 190 345 Q 182 344 181 328" fill="none" stroke="#F59E0B" strokeWidth="1.6" strokeDasharray="3 2" />
<rect x="147" y="535" width="31" height="14" rx="3" fill="#E2E8F0" stroke={LINE} strokeWidth="2" />
<rect x="202" y="535" width="31" height="14" rx="3" fill="#E2E8F0" stroke={LINE} strokeWidth="2" />
</g>
)}
{skirtItem && (
<g>
<path
d="M 142 294 L 238 294 C 248 342, 256 392, 260 434 C 215 446, 165 446, 120 434 C 124 392, 132 342, 142 294 Z"
fill="url(#bottomGrad)"
stroke={LINE}
strokeWidth="2.4"
strokeLinejoin="round"
/>
<path d="M 162 302 L 148 437" stroke={bottomColor.dark} strokeWidth="2" opacity="0.55" />
<path d="M 198 302 L 204 440" stroke={bottomColor.dark} strokeWidth="2" opacity="0.55" />
</g>
)}
</g>
{/* 6. SHOES & BOOTS */}
<g className="garment-hotspot" onClick={(e) => handleHotspotClick(e, shoeItem || bootsItem, items.length - 1)}>
{shoeItem && (
<g>
<path d="M 150 552 L 174 552 L 176 578 C 176 588, 168 592, 152 592 L 130 592 C 122 592, 120 582, 128 574 C 136 568, 146 562, 150 552 Z" fill={shoeItem.color.hex} stroke={LINE} strokeWidth="2.2" />
<path d="M 122 583 L 176 583 L 175 592 L 124 592 Z" fill="#FFFFFF" stroke={LINE} strokeWidth="1.8" />
<path d="M 142 565 L 152 569 M 145 560 L 155 564" stroke="#FFFFFF" strokeWidth="2.2" strokeLinecap="round" />
<path d="M 206 552 L 230 552 C 234 562, 244 568, 252 574 C 260 582, 258 592, 250 592 L 228 592 C 212 592, 204 588, 204 578 Z" fill={shoeItem.color.hex} stroke={LINE} strokeWidth="2.2" />
<path d="M 204 583 L 258 583 L 256 592 L 205 592 Z" fill="#FFFFFF" stroke={LINE} strokeWidth="1.8" />
<path d="M 238 565 L 228 569 M 235 560 L 225 564" stroke="#FFFFFF" strokeWidth="2.2" strokeLinecap="round" />
</g>
)}
{bootsItem && (
<g>
<path d="M 146 476 L 176 476 L 176 578 C 176 588, 168 592, 152 592 L 128 592 C 120 592, 118 580, 126 572 C 136 564, 144 556, 146 540 Z" fill={bootsItem.color.hex} stroke={LINE} strokeWidth="2.4" />
<path d="M 120 584 L 176 584 L 176 593 L 121 593 Z" fill="#1E293B" stroke={LINE} strokeWidth="1.5" />
<path d="M 204 476 L 234 476 L 234 540 C 236 556, 244 564, 254 572 C 262 580, 260 592, 252 592 L 228 592 C 212 592, 204 588, 204 578 Z" fill={bootsItem.color.hex} stroke={LINE} strokeWidth="2.4" />
<path d="M 204 584 L 260 584 L 259 593 L 204 593 Z" fill="#1E293B" stroke={LINE} strokeWidth="1.5" />
</g>
)}
</g>
{/* 7. ANATOMICAL ARMS & HANDS */}
<g>
<path d="M 134 166 C 116 182, 104 212, 98 244 C 93 272, 88 298, 86 320 L 103 323 C 108 300, 114 274, 120 248 C 126 222, 134 198, 144 180 Z" fill="url(#limbSkinLeft)" stroke={skin.deep} strokeWidth="1.8" />
<path d="M 86 318 C 81 328, 79 342, 83 350 C 87 356, 96 355, 100 347 C 103 340, 104 332, 106 336 C 109 340, 113 336, 111 329 C 109 324, 105 320, 102 318 Z" fill="url(#limbSkinLeft)" stroke={skin.deep} strokeWidth="1.6" />
<path d="M 246 166 C 264 182, 276 212, 282 244 C 287 272, 292 298, 294 320 L 277 323 C 272 300, 266 274, 260 248 C 254 222, 246 198, 236 180 Z" fill="url(#limbSkinRight)" stroke={skin.deep} strokeWidth="1.8" />
<path d="M 294 318 C 299 328, 301 342, 297 350 C 293 356, 284 355, 280 347 C 277 340, 276 332, 274 336 C 271 340, 267 336, 269 329 C 271 324, 275 320, 278 318 Z" fill="url(#limbSkinRight)" stroke={skin.deep} strokeWidth="1.6" />
</g>
{/* 8. NECK, COLLARBONE & TORSO BASE */}
<g>
<path d="M 134 158 C 154 148, 226 148, 246 158 L 240 300 L 140 300 Z" fill="url(#faceSkinGrad)" stroke={skin.deep} strokeWidth="1.8" />
<path d="M 173 116 L 171 154 C 178 162, 202 162, 209 154 L 207 116 Z" fill="url(#neckSkinGrad)" stroke={skin.deep} strokeWidth="1.6" />
<path d="M 156 157 Q 172 163 184 159 M 224 157 Q 208 163 196 159" fill="none" stroke={skin.shadow} strokeWidth="1.6" strokeLinecap="round" opacity="0.65" />
</g>
{/* 9. TOPS & DRESS */}
<g className="garment-hotspot" onClick={(e) => handleHotspotClick(e, topItem, 0)}>
{topItem?.clothing.name === 't-shirt' && (
<g>
<path
d="M 134 156 C 116 164, 104 188, 99 216 L 123 224 C 127 206, 133 192, 139 184 L 138 302 C 172 308, 208 308, 242 302 L 241 184 C 247 192, 253 206, 257 224 L 281 216 C 276 188, 264 164, 246 156 C 232 151, 218 148, 210 148 C 204 164, 176 164, 170 148 C 162 148, 148 151, 134 156 Z"
fill="url(#topGrad)"
stroke={LINE}
strokeWidth="2.4"
strokeLinejoin="round"
/>
<path d="M 166 148 C 174 168, 206 168, 214 148 C 209 146, 208 159, 190 159 C 172 159, 171 146, 166 148 Z" fill={topColor.dark} stroke={LINE} strokeWidth="1.8" />
</g>
)}
{topItem?.clothing.name === 'shirt' && (
<g>
<path
d="M 134 155 C 114 166, 100 205, 86 308 L 107 312 C 117 244, 126 204, 139 184 L 138 304 C 172 312, 208 312, 242 304 L 241 184 C 254 204, 263 244, 273 312 L 294 308 C 280 205, 266 166, 246 155 L 212 146 L 190 164 L 168 146 Z"
fill="url(#topGrad)"
stroke={LINE}
strokeWidth="2.4"
strokeLinejoin="round"
/>
<rect x="185" y="164" width="10" height="142" fill="#FFFFFF" fillOpacity="0.22" stroke={topColor.dark} strokeWidth="1.2" />
<path d="M 168 145 L 190 166 L 174 176 L 160 154 Z" fill="#FFFFFF" stroke={LINE} strokeWidth="2" />
<path d="M 212 145 L 190 166 L 206 176 L 220 154 Z" fill="#FFFFFF" stroke={LINE} strokeWidth="2" />
{[184, 212, 240, 268].map((y) => (
<circle key={y} cx="190" cy={y} r="3" fill="#F8FAFC" stroke={LINE} strokeWidth="1.4" />
))}
</g>
)}
{topItem?.clothing.name === 'sweater' && (
<g>
<path
d="M 132 154 C 112 166, 98 205, 84 310 L 108 314 C 118 248, 126 206, 138 186 L 136 304 L 244 304 L 242 186 C 254 206, 262 248, 272 314 L 296 310 C 282 205, 268 166, 248 154 C 232 148, 214 146, 212 146 C 202 162, 178 162, 168 146 Z"
fill="url(#topGrad)"
stroke={LINE}
strokeWidth="2.4"
strokeLinejoin="round"
/>
<rect x="136" y="290" width="108" height="14" rx="4" fill={topColor.dark} stroke={LINE} strokeWidth="2" />
<g opacity="0.32" stroke="#FFFFFF" strokeWidth="2" fill="none">
<path d="M 170 174 L 190 198 L 170 222 L 190 246 L 170 270" />
<path d="M 210 174 L 190 198 L 210 222 L 190 246 L 210 270" />
</g>
</g>
)}
{topItem?.clothing.name === 'jacket' && (
<g>
<path d="M 164 150 C 176 164, 204 164, 216 150 L 216 300 L 164 300 Z" fill="#F8FAFC" stroke={LINE} strokeWidth="1.8" />
<path d="M 132 154 C 112 166, 98 205, 84 312 L 108 316 C 118 248, 126 206, 138 186 L 136 304 L 174 304 L 174 160 L 166 146 Z" fill="url(#topGrad)" stroke={LINE} strokeWidth="2.4" />
<path d="M 248 154 C 268 166, 282 205, 296 312 L 272 316 C 262 248, 254 206, 242 186 L 244 304 L 206 304 L 206 160 L 214 146 Z" fill="url(#topGrad)" stroke={LINE} strokeWidth="2.4" />
<path d="M 166 146 L 152 178 L 174 188 Z" fill={topColor.dark} stroke={LINE} strokeWidth="2" />
<path d="M 214 146 L 228 178 L 206 188 Z" fill={topColor.dark} stroke={LINE} strokeWidth="2" />
</g>
)}
{topItem?.clothing.name === 'hoodie' && (
<g>
<path
d="M 132 154 C 112 166, 98 205, 84 310 L 108 314 C 118 248, 126 206, 138 186 L 136 306 L 244 306 L 242 186 C 254 206, 262 248, 272 314 L 296 310 C 282 205, 268 166, 248 154 L 220 144 C 204 162, 176 162, 160 144 Z"
fill="url(#topGrad)"
stroke={LINE}
strokeWidth="2.4"
strokeLinejoin="round"
/>
<path d="M 152 242 L 228 242 L 236 292 L 144 292 Z" fill={topColor.dark} fillOpacity="0.4" stroke={LINE} strokeWidth="2" />
<path d="M 178 156 Q 175 182 177 202 M 202 156 Q 205 182 203 202" fill="none" stroke="#F8FAFC" strokeWidth="3" strokeLinecap="round" />
</g>
)}
{topItem?.clothing.name === 'raincoat' && (
<g>
<path
d="M 132 154 C 110 166, 96 205, 82 312 L 108 316 C 118 248, 126 206, 138 186 L 130 342 C 170 350, 210 350, 250 342 L 242 186 C 254 206, 262 248, 272 316 L 298 312 C 284 205, 270 166, 248 154 L 218 144 C 204 162, 176 162, 162 144 Z"
fill="url(#topGrad)"
stroke={LINE}
strokeWidth="2.4"
strokeLinejoin="round"
/>
<rect x="183" y="156" width="14" height="188" fill={topColor.highlight} stroke={LINE} strokeWidth="1.8" />
{[178, 218, 258, 298].map((y) => (
<circle key={y} cx="190" cy={y} r="3.6" fill="#E2E8F0" stroke={LINE} strokeWidth="1.5" />
))}
</g>
)}
{dressItem && (
<g>
<path
d="M 136 156 C 120 164, 108 184, 104 208 L 126 215 C 130 198, 136 186, 142 180 L 146 258 C 130 322, 118 386, 112 448 C 162 462, 218 462, 268 448 C 262 386, 250 322, 234 258 L 238 180 C 244 186, 250 198, 254 215 L 276 208 C 272 184, 260 164, 244 156 L 214 148 C 204 166, 176 166, 166 148 Z"
fill="url(#topGrad)"
stroke={LINE}
strokeWidth="2.4"
strokeLinejoin="round"
/>
<path d="M 146 258 Q 190 266 234 258" fill="none" stroke={topColor.dark} strokeWidth="2.5" />
</g>
)}
</g>
{/* 10. BELT, MITTENS, SCARF */}
<g className="garment-hotspot" onClick={(e) => handleHotspotClick(e, beltItem || mittensItem || scarfItem, items.length - 1)}>
{beltItem && (
<g>
<rect x={dressItem ? 145 : 139} y={dressItem ? 252 : 292} width={dressItem ? 90 : 102} height="13" rx="4" fill={beltItem.color.hex} stroke={LINE} strokeWidth="2.2" />
<rect x="180" y={dressItem ? 249 : 289} width="20" height="19" rx="3" fill="none" stroke="#FBBF24" strokeWidth="3.5" />
</g>
)}
{mittensItem && (
<g>
<path d="M 83 314 C 74 324, 72 344, 80 355 C 88 364, 102 360, 105 348 C 109 348, 115 342, 113 334 C 111 326, 106 322, 104 316 Z" fill={mittensItem.color.hex} stroke={LINE} strokeWidth="2.2" />
<path d="M 297 314 C 306 324, 308 344, 300 355 C 292 364, 278 360, 275 348 C 271 348, 265 342, 267 334 C 269 326, 274 322, 276 316 Z" fill={mittensItem.color.hex} stroke={LINE} strokeWidth="2.2" />
</g>
)}
{scarfItem && (
<g filter="url(#subtleShadow)">
<path d="M 152 136 C 172 148, 208 148, 228 136 C 234 148, 228 164, 190 166 C 152 164, 146 148, 152 136 Z" fill={scarfItem.color.hex} stroke={LINE} strokeWidth="2.2" />
<path d="M 198 158 C 204 188, 202 222, 206 248 L 226 246 C 224 218, 224 186, 216 154 Z" fill={scarfItem.color.highlight} stroke={LINE} strokeWidth="2.2" />
</g>
)}
</g>
{/* 11. REALISTIC SCULPTED HEAD & FACIAL FEATURES */}
<g>
<path d="M 145 76 C 136 74, 134 90, 141 100 C 144 104, 148 102, 149 98" fill={skin.base} stroke={skin.deep} strokeWidth="1.8" />
<path d="M 235 76 C 244 74, 246 90, 239 100 C 236 104, 232 102, 231 98" fill={skin.base} stroke={skin.deep} strokeWidth="1.8" />
<path
d="M 146 68 C 146 36, 166 24, 190 24 C 214 24, 234 36, 234 68 C 234 86, 231 102, 222 114 C 212 126, 200 133, 190 133 C 180 133, 168 126, 158 114 C 149 102, 146 86, 146 68 Z"
fill="url(#faceSkinGrad)"
stroke={skin.deep}
strokeWidth="2"
/>
<ellipse cx="163" cy="95" rx="9" ry="5.5" fill={skin.blush} opacity="0.28" />
<ellipse cx="217" cy="95" rx="9" ry="5.5" fill={skin.blush} opacity="0.28" />
{isFemale ? (
<g fill={hair.dark}>
<path d="M 158 71 Q 169 66 180 70 Q 169 69 158 73 Z" />
<path d="M 222 71 Q 211 66 200 70 Q 211 69 222 73 Z" />
</g>
) : (
<g fill={hair.dark}>
<path d="M 157 70 Q 169 66 181 70 L 180 73 Q 169 70 157 73 Z" />
<path d="M 223 70 Q 211 66 199 70 L 200 73 Q 211 70 223 73 Z" />
</g>
)}
<g className="eye-blink-group">
<path d="M 160 82 Q 170 75 180 82 Q 170 88 160 82 Z" fill="#FFFFFF" stroke={skin.deep} strokeWidth="1.2" />
<circle cx="170" cy="82" r="4.8" fill={eyeColor} />
<circle cx="170" cy="82" r="2.3" fill="#0F172A" />
<circle cx="171.6" cy="80.4" r="1.3" fill="#FFFFFF" />
<path d="M 159 82 Q 170 74 181 82" fill="none" stroke="#1E1815" strokeWidth={isFemale ? '2.4' : '1.9'} strokeLinecap="round" />
<path d="M 200 82 Q 210 75 220 82 Q 210 88 200 82 Z" fill="#FFFFFF" stroke={skin.deep} strokeWidth="1.2" />
<circle cx="210" cy="82" r="4.8" fill={eyeColor} />
<circle cx="210" cy="82" r="2.3" fill="#0F172A" />
<circle cx="211.6" cy="80.4" r="1.3" fill="#FFFFFF" />
<path d="M 199 82 Q 210 74 221 82" fill="none" stroke="#1E1815" strokeWidth={isFemale ? '2.4' : '1.9'} strokeLinecap="round" />
</g>
<path d="M 188 78 C 187 86, 185 93, 185 96 C 186 99, 194 99, 195 96" fill="none" stroke={skin.deep} strokeWidth="1.6" strokeLinecap="round" />
<circle cx="190" cy="94" r="2.2" fill={skin.highlight} opacity="0.55" />
<path d="M 178 109 Q 185 106 190 108 Q 195 106 202 109 Q 190 113 178 109 Z" fill={skin.lip} />
<path d="M 178 109 Q 190 113 202 109 Q 196 117 190 117 Q 184 117 178 109 Z" fill={skin.lip} opacity="0.85" />
<path d="M 177 108.5 Q 190 114.5 203 108.5" fill="none" stroke={skin.deep} strokeWidth="1.6" strokeLinecap="round" />
{/* 12. FRONT HAIR LAYER */}
{!isFemale ? (
<path
d="M 144 74 C 142 46, 156 20, 190 18 C 224 20, 238 46, 236 74 C 234 62, 228 50, 218 46 C 202 54, 174 56, 156 48 C 149 54, 146 64, 144 74 Z"
fill="url(#hairGrad)"
stroke={hair.dark}
strokeWidth="1.8"
/>
) : (
<path
d="M 143 78 C 140 42, 160 18, 190 18 C 220 18, 240 42, 237 78 C 232 58, 214 44, 192 40 C 172 46, 152 58, 143 78 Z"
fill="url(#hairGrad)"
stroke={hair.dark}
strokeWidth="1.8"
/>
)}
{/* 13. EYEWEAR & HEADWEAR */}
<g
className="garment-hotspot"
onClick={(e) =>
handleHotspotClick(e, sunglassesItem || bigHatItem || smallHatItem || capItem, items.length - 1)
}
>
{sunglassesItem && (
<g filter="url(#subtleShadow)">
<rect x="153" y="74" width="32" height="19" rx="6" fill={sunglassesItem.color.hex} stroke={LINE} strokeWidth="2.2" />
<rect x="195" y="74" width="32" height="19" rx="6" fill={sunglassesItem.color.hex} stroke={LINE} strokeWidth="2.2" />
<path d="M 185 80 Q 190 77 195 80" fill="none" stroke={LINE} strokeWidth="2.8" />
<line x1="145" y1="79" x2="153" y2="80" stroke={LINE} strokeWidth="2.5" />
<line x1="227" y1="80" x2="235" y2="79" stroke={LINE} strokeWidth="2.5" />
<path d="M 159 78 L 168 78 L 163 89 L 156 89 Z M 201 78 L 210 78 L 205 89 L 198 89 Z" fill="#FFFFFF" opacity="0.32" />
</g>
)}
{bigHatItem && (
<g filter="url(#subtleShadow)">
<ellipse cx="190" cy="36" rx="82" ry="16" fill={bigHatItem.color.hex} stroke={LINE} strokeWidth="2.4" />
<path d="M 148 34 C 152 4, 168 -4, 190 -4 C 212 -4, 228 4, 232 34 Z" fill={bigHatItem.color.highlight} stroke={LINE} strokeWidth="2.4" />
<path d="M 149 27 Q 190 34 231 27 L 232 34 Q 190 41 148 34 Z" fill={bigHatItem.color.dark} />
</g>
)}
{smallHatItem && (
<g filter="url(#subtleShadow)">
<path d="M 144 48 C 144 16, 164 8, 190 8 C 216 8, 236 16, 236 48 Z" fill={smallHatItem.color.hex} stroke={LINE} strokeWidth="2.2" />
<rect x="141" y="38" width="98" height="14" rx="6" fill={smallHatItem.color.dark} stroke={LINE} strokeWidth="2.2" />
</g>
)}
{capItem && (
<g filter="url(#subtleShadow)">
<path d="M 144 46 C 144 16, 164 10, 190 10 C 216 10, 236 16, 236 46 Z" fill={capItem.color.hex} stroke={LINE} strokeWidth="2.2" />
<path d="M 144 44 Q 194 34 254 48 Q 236 60 192 52 Q 162 49 144 44 Z" fill={capItem.color.dark} stroke={LINE} strokeWidth="2.2" />
</g>
)}
</g>
</g>
</g>
{/* ==================== 14. DYNAMIC POINTER ARROWS TO CLOTHES & ACCESSORIES ==================== */}
{arrowsToRender.map(({ item, isPrimary }, idx) => {
const anchor = getGarmentAnchor(item.clothing.name, Boolean(dressItem));
const isLeft = anchor.side === 'left';
const dx = anchor.tx - anchor.sx;
const dy = anchor.ty - anchor.sy;
const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
const phrase = formatItemPhrase(item);
const pillWidth = Math.max(108, phrase.length * 7.8 + 22);
const pillX = isLeft ? anchor.sx - pillWidth + 12 : anchor.sx - 12;
const pillY = anchor.sy - 34;
return (
<g
key={`arrow-${item.clothing.name}-${idx}`}
filter="url(#arrowDropShadow)"
style={{ pointerEvents: 'none' }}
>
{/* Pulsing Bullseye Target Ring on the Garment */}
{isPrimary && (
<circle
cx={anchor.tx}
cy={anchor.ty}
r="12"
fill="none"
stroke={item.color.hex}
strokeWidth="3.5"
className="target-ping-circle"
/>
)}
<circle
cx={anchor.tx}
cy={anchor.ty}
r={isPrimary ? '7' : '5'}
fill={item.color.hex}
stroke="#FFFFFF"
strokeWidth="2.5"
/>
{/* Animated Pointer Shaft & Arrowhead */}
<g className={isLeft ? 'arrow-nudge-left' : 'arrow-nudge-right'}>
{/* White Contrast Outer Shaft */}
<line
x1={anchor.sx}
y1={anchor.sy}
x2={anchor.tx}
y2={anchor.ty}
stroke="#FFFFFF"
strokeWidth={isPrimary ? '10' : '7.5'}
strokeLinecap="round"
/>
{/* Colored Inner Shaft */}
<line
x1={anchor.sx}
y1={anchor.sy}
x2={anchor.tx}
y2={anchor.ty}
stroke={item.color.hex}
strokeWidth={isPrimary ? '6' : '4.5'}
strokeLinecap="round"
/>
{/* Arrowhead at Target (tx, ty) */}
<g transform={`translate(${anchor.tx}, ${anchor.ty}) rotate(${angleDeg})`}>
<polygon
points="0,0 -20,-11 -16,0 -20,11"
fill={item.color.hex}
stroke="#FFFFFF"
strokeWidth="2.5"
strokeLinejoin="round"
/>
</g>
{/* Floating Callout Label Pill at Arrow Origin */}
<g>
<rect
x={pillX}
y={pillY}
width={pillWidth}
height="28"
rx="14"
fill="#0F172A"
stroke={item.color.hex}
strokeWidth="2.5"
/>
<circle
cx={pillX + 14}
cy={pillY + 14}
r="6"
fill={item.color.hex}
stroke="#FFFFFF"
strokeWidth="1.5"
/>
<text
x={pillX + 26}
y={pillY + 18.5}
fill="#FFFFFF"
fontFamily="Outfit, sans-serif"
fontWeight="800"
fontSize="13.5"
>
{phrase}
</text>
</g>
</g>
</g>
);
})}
</svg>
);
}
// ==================== MAIN APPLICATION ====================
function App() {
const [gameState, setGameState] = useState(() => generateRandomState());
const [mode, setMode] = useState('reveal'); // 'reveal' | 'quiz' | 'wardrobe'
const [isSpinning, setIsSpinning] = useState(false);
const [showAnswer, setShowAnswer] = useState(false);
const [autoSpeak, setAutoSpeak] = useState(true);
const [isSpeaking, setIsSpeaking] = useState(false);
const [speakingSegIdx, setSpeakingSegIdx] = useState(-1);
const [inspectedItem, setInspectedItem] = useState(null);
// Quiz State
const [quizOptions, setQuizOptions] = useState([]);
const [selectedQuizIdx, setSelectedQuizIdx] = useState(null);
const [score, setScore] = useState(0);
const [streak, setStreak] = useState(0);
const voiceRef = useRef(null);
if (!voiceRef.current) {
voiceRef.current = new CommandingBritishVoiceEngine(
(speaking) => setIsSpeaking(speaking),
(idx) => setSpeakingSegIdx(idx)
);
}
// Build multiple choice options whenever gameState changes
useEffect(() => {
if (!gameState) return;
const correctSentence = formatFullSentence(gameState);
const distractors = [];
while (distractors.length < 3) {
const fake = generateRandomState(gameState.gender);
const s = formatFullSentence(fake);
if (s !== correctSentence && !distractors.includes(s)) {
distractors.push(s);
}
}
const combined = [
{ text: correctSentence, isCorrect: true },
...distractors.map((d) => ({ text: d, isCorrect: false })),
].sort(() => Math.random() - 0.5);
setQuizOptions(combined);
setSelectedQuizIdx(null);
}, [gameState]);
const spinRoulette = useCallback(
(forcedGender = null) => {
if (isSpinning) return;
voiceRef.current?.stop();
setIsSpinning(true);
setShowAnswer(false);
setInspectedItem(null);
let spins = 0;
let finalState = null;
const iv = setInterval(() => {
finalState = generateRandomState(forcedGender);
setGameState(finalState);
spins += 1;
if (spins >= 8) {
clearInterval(iv);
setIsSpinning(false);
if (autoSpeak && finalState) {
const qKey = finalState.gender === 'he' ? 'spin_he' : 'spin_she';
const qText = `Look closely. What is ${finalState.gender} wearing?`;
voiceRef.current?.speakSingleKey(qKey, qText, -1);
}
}
}, 70);
},
[isSpinning, autoSpeak]
);
const handleRevealAnswer = useCallback(() => {
if (isSpinning) return;
setShowAnswer(true);
setInspectedItem(null);
if (autoSpeak) {
voiceRef.current?.speakOutfitSentence(gameState);
}
}, [isSpinning, autoSpeak, gameState]);
// Keyboard Shortcuts: Space = Spin, Enter = Reveal / Speak
useEffect(() => {
const onKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
if (e.code === 'Space') {
e.preventDefault();
spinRoulette();
} else if (e.code === 'Enter') {
e.preventDefault();
if (!showAnswer) handleRevealAnswer();
else voiceRef.current?.speakOutfitSentence(gameState);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [spinRoulette, showAnswer, handleRevealAnswer, gameState]);
const handleSelectGarment = (item, idx = -1) => {
if (!item) return;
setInspectedItem(item);
const key = `pair_${item.color.name}_${item.clothing.name}`;
voiceRef.current?.speakSingleKey(key, formatItemPhrase(item), idx);
};
const handleQuizPick = (opt, idx) => {
if (selectedQuizIdx !== null) return;
setSelectedQuizIdx(idx);
if (opt.isCorrect) {
setScore((s) => s + 10);
setStreak((st) => st + 1);
setShowAnswer(true);
voiceRef.current?.speakSingleKey('quiz_correct', 'Spot on! Magnificent observation.', -1);
} else {
setStreak(0);
voiceRef.current?.speakSingleKey('quiz_wrong', 'Not quite right. Inspect the outfit closely and try again.', -1);
setTimeout(() => setSelectedQuizIdx(null), 1300);
}
};
const updateWardrobeSlot = (slotIdx, newClothing, newColor) => {
setGameState((prev) => {
const nextItems = [...prev.items];
if (slotIdx < nextItems.length) {
nextItems[slotIdx] = {
clothing: newClothing || nextItems[slotIdx].clothing,
color: newColor || nextItems[slotIdx].color,
};
}
const updated = { ...prev, items: nextItems };
const changed = nextItems[slotIdx];
if (changed) {
setInspectedItem(changed);
if (autoSpeak) {
const key = `pair_${changed.color.name}_${changed.clothing.name}`;
voiceRef.current?.speakSingleKey(key, formatItemPhrase(changed), slotIdx);
}
}
return updated;
});
};
const pronoun = gameState.gender === 'he' ? 'He' : 'She';
return (
<div className="app-shell">
{/* ==================== TOP COMMAND BAR (NO VERSION BADGE, HUGE SPIN BUTTON) ==================== */}
<nav className="top-nav">
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: 38,
height: 38,
borderRadius: 11,
background: 'linear-gradient(135deg, #3B82F6, #6366F1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 800,
fontSize: 20,
}}
>
👔
</div>
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: '21px', letterSpacing: '-0.01em' }}>
What Are They Wearing?
</span>
</div>
{/* Mode Segmented Switcher */}
<div
style={{
display: 'flex',
background: 'rgba(15, 23, 42, 0.9)',
padding: '4px',
borderRadius: '12px',
border: '1px solid rgba(255,255,255,0.12)',
gap: '4px',
}}
>
{[
{ id: 'reveal', label: '👁️ Reveal' },
{ id: 'quiz', label: '🎯 Quiz' },
{ id: 'wardrobe', label: '🧥 Wardrobe' },
].map((tab) => (
<button
key={tab.id}
onClick={() => {
setMode(tab.id);
if (tab.id === 'wardrobe') setShowAnswer(true);
}}
className="btn-modern"
style={{
padding: '7px 15px',
borderRadius: '9px',
fontSize: '14px',
background: mode === tab.id ? '#2563EB' : 'transparent',
color: mode === tab.id ? '#FFFFFF' : '#94A3B8',
}}
>
{tab.label}
</button>
))}
</div>
{/* Voice Status + Oversized Spin Button */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<button
onClick={() => setAutoSpeak((v) => !v)}
className="btn-modern"
title="Toggle Commanding British Male Voice Auto-Narration"
style={{
background: autoSpeak ? 'rgba(16, 185, 129, 0.18)' : 'rgba(255,255,255,0.08)',
border: `1.5px solid ${autoSpeak ? '#10B981' : 'rgba(255,255,255,0.15)'}`,
color: autoSpeak ? '#6EE7B7' : '#94A3B8',
padding: '7px 14px',
borderRadius: '999px',
fontSize: '13px',
}}
>
<span>🇬🇧 {isSpeaking ? '🔊 Speaking...' : 'UK Baritone'}</span>
<span className="hide-mobile" style={{ opacity: 0.85 }}>
({autoSpeak ? 'Auto' : 'Manual'})
</span>
</button>
<button
onClick={() => spinRoulette()}
disabled={isSpinning}
className="btn-mega-spin"
style={{
padding: '10px 26px',
fontSize: '18px',
}}
>
<span>🎲 SPIN</span>
</button>
</div>
</nav>
{/* ==================== 2-COLUMN STUDIO WORKSPACE ==================== */}
<div className="workspace-grid">
{/* LEFT PANEL: REALISTIC PERSON + DYNAMIC CALLOUT ARROWS */}
<section
className="studio-card"
style={{
background: 'radial-gradient(circle at 50% 42%, #FFFFFF 0%, #F1F5F9 72%, #E2E8F0 100%)',
justifyContent: 'space-between',
padding: '14px',
}}
>
{/* Stage Top Bar */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', zIndex: 10 }}>
<div style={{ display: 'flex', gap: '4px', background: '#E2E8F0', padding: '4px', borderRadius: '10px' }}>
{[
{ label: 'Random', val: null },
{ label: 'He ♂', val: 'he' },
{ label: 'She ♀', val: 'she' },
].map((g) => (
<button
key={g.label}
onClick={() => spinRoulette(g.val)}
className="btn-modern"
style={{
padding: '5px 11px',
borderRadius: '7px',
fontSize: '12.5px',
background: g.val && gameState.gender === g.val ? '#FFFFFF' : 'transparent',
color: '#0F172A',
boxShadow: g.val && gameState.gender === g.val ? '0 1px 3px rgba(0,0,0,0.12)' : 'none',
}}
>
{g.label}
</button>
))}
</div>
<button
onClick={() => {
const qKey = gameState.gender === 'he' ? 'q_he' : 'q_she';
voiceRef.current?.speakSingleKey(qKey, `What is ${gameState.gender} wearing?`, -1);
}}
className="btn-modern"
style={{
background: '#FFFFFF',
border: '1.5px solid #CBD5E1',
borderRadius: '999px',
padding: '6px 13px',
fontSize: '12.5px',
color: '#0F172A',
}}
>
🔊 Ask Question
</button>
</div>
{/* Realistic Human Figure + Dynamic Arrows Viewport */}
<div
onClick={() => spinRoulette()}
title="Click person to spin, or click any garment to point an arrow and hear its British pronunciation"
style={{
flex: 1,
minHeight: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: isSpinning ? 'wait' : 'pointer',
padding: '2px 0',
}}
>
<div
className={isSpinning ? 'person-spinning' : 'person-idle'}
style={{ width: '100%', height: '100%', maxHeight: '510px', aspectRatio: '530 / 650' }}
>
<RealisticPerson
gender={gameState.gender}
skin={gameState.skin}
hair={gameState.hair}
eyeColor={gameState.eyeColor}
hairStyle={gameState.hairStyle}
items={gameState.items}
activeSegIdx={speakingSegIdx}
inspectedItem={inspectedItem}
showAllArrows={showAnswer}
onSelectItem={handleSelectGarment}
/>
</div>
</div>
{/* Giant Stage Spin Button Bar */}
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<button
onClick={() => spinRoulette()}
disabled={isSpinning}
className="btn-mega-spin"
style={{
width: '100%',
padding: '15px 24px',
fontSize: 'clamp(20px, 2.2vw, 26px)',
}}
>
<span>🎲 SPIN NEW OUTFIT</span>
</button>
</div>
</section>
{/* RIGHT PANEL: QUESTION, MASSIVE SENTENCE & INTERACTIVE MODES */}
<section className="studio-card" style={{ padding: '20px 26px', gap: '16px', justifyContent: 'space-between' }}>
{/* 1. Question Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: '#F8FAFC',
border: '2px solid #E2E8F0',
borderRadius: '16px',
padding: '14px 22px',
gap: '14px',
flexWrap: 'wrap',
}}
>
<h1
style={{
fontFamily: 'var(--font-display)',
fontSize: 'clamp(28px, 3.2vw, 42px)',
fontWeight: 900,
color: '#0F172A',
letterSpacing: '-0.02em',
}}
>
What is{' '}
<span
style={{
color: gameState.gender === 'he' ? '#2563EB' : '#DB2777',
background: gameState.gender === 'he' ? '#DBEAFE' : '#FCE7F3',
padding: '2px 16px',
borderRadius: '999px',
}}
>
{gameState.gender}
</span>{' '}
wearing?
</h1>
<div style={{ display: 'flex', gap: '10px' }}>
<button
onClick={() => {
setShowAnswer(true);
setInspectedItem(null);
voiceRef.current?.speakOutfitSentence(gameState);
}}
className="btn-modern"
style={{
background: '#0F172A',
color: '#FFFFFF',
padding: '12px 20px',
borderRadius: '12px',
fontSize: '15px',
}}
>
<span>🔊 Speak Full Sentence</span>
</button>
</div>
</div>
{/* 2. Dynamic Mode Body */}
<div
className="custom-scroll"
style={{
flex: 1,
minHeight: 0,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
{mode === 'reveal' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '18px' }}>
{!showAnswer ? (
<div
style={{
background: 'linear-gradient(135deg, #EFF6FF 0%, #EEF2FF 100%)',
border: '3px dashed #93C5FD',
borderRadius: '20px',
padding: '44px 28px',
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '20px',
}}
>
<p style={{ fontSize: 'clamp(18px, 2vw, 24px)', fontWeight: 700, color: '#334155', maxWidth: '540px' }}>
Look closely at the person on the left. Click any garment to point an arrow and hear it, or reveal the complete sentence!
</p>
<button
onClick={handleRevealAnswer}
disabled={isSpinning}
className="btn-modern"
style={{
background: 'linear-gradient(135deg, #2563EB 0%, #4F46E5 100%)',
color: '#FFFFFF',
padding: '20px 44px',
borderRadius: '18px',
fontSize: 'clamp(22px, 2.5vw, 30px)',
fontFamily: 'var(--font-display)',
fontWeight: 900,
boxShadow: '0 8px 0 #1E3A8A, 0 16px 32px rgba(37, 99, 235, 0.4)',
}}
>
👁️ REVEAL ANSWER
</button>
</div>
) : (
<div className="fade-in-panel" style={{ display: 'flex', flexDirection: 'column', gap: '18px' }}>
{/* MASSIVE CLASSROOM-SIZED SENTENCE CARD */}
<div
style={{
background: '#FFFFFF',
border: '3px solid #CBD5E1',
borderRadius: '22px',
padding: '26px 30px',
boxShadow: '0 12px 30px -8px rgba(15, 23, 42, 0.08)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<span style={{ fontSize: '13px', fontWeight: 800, color: '#059669', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
✓ Complete Sentence — Click Any Garment to Point Arrow & Hear
</span>
<button
onClick={() => {
setShowAnswer(false);
setInspectedItem(null);
}}
className="btn-modern"
style={{
background: '#F1F5F9',
color: '#475569',
padding: '6px 14px',
borderRadius: '8px',
fontSize: '13px',
}}
>
Hide Answer
</button>
</div>
<p
style={{
fontFamily: 'var(--font-display)',
fontSize: 'clamp(36px, 4.3vw, 60px)',
fontWeight: 900,
lineHeight: 1.35,
color: '#0F172A',
letterSpacing: '-0.015em',
}}
>
{pronoun} is wearing{' '}
{gameState.items.map((item, i) => {
const isCurrentSpoken =
speakingSegIdx === i ||
(inspectedItem && inspectedItem.clothing.name === item.clothing.name);
const suffix =
i < gameState.items.length - 2
? ', '
: i === gameState.items.length - 2
? ', and '
: '.';
return (
<React.Fragment key={`${item.clothing.name}-${i}`}>
<span
onClick={() => handleSelectGarment(item, i)}
onMouseEnter={() => setInspectedItem(item)}
style={{
cursor: 'pointer',
padding: '2px 10px',
margin: '0 2px',
borderRadius: '14px',
background: isCurrentSpoken ? item.color.light : 'transparent',
boxShadow: isCurrentSpoken ? `0 0 0 3px ${item.color.hex}` : 'none',
transition: 'all 0.15s ease',
display: 'inline',
}}
>
{item.clothing.singular ? getArticle(item.color.name) : ''}
<span style={{ color: item.color.hex, fontWeight: 900 }}>{item.color.name}</span>{' '}
<span
style={{
fontWeight: 900,
color: '#0F172A',
borderBottom: `5px solid ${item.color.hex}`,
}}
>
{item.clothing.name}
</span>
</span>
<span>{suffix}</span>
</React.Fragment>
);
})}
</p>
</div>
{/* Large Interactive Garment Cards (Hover or Click to Point Arrow on Person) */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(210px, 1fr))', gap: '12px' }}>
{gameState.items.map((item, i) => {
const isHighlighted =
speakingSegIdx === i ||
(inspectedItem && inspectedItem.clothing.name === item.clothing.name);
return (
<button
key={`${item.clothing.name}-card-${i}`}
onClick={() => handleSelectGarment(item, i)}
onMouseEnter={() => setInspectedItem(item)}
className="btn-modern"
style={{
background: item.color.light,
border: `3px solid ${isHighlighted ? '#0F172A' : item.color.hex}`,
borderRadius: '16px',
padding: '15px 18px',
justifyContent: 'space-between',
textAlign: 'left',
transform: isHighlighted ? 'translateY(-3px)' : 'none',
boxShadow: isHighlighted ? '0 10px 20px rgba(0,0,0,0.12)' : 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span
style={{
width: 22,
height: 22,
borderRadius: '50%',
background: item.color.hex,
border: '2px solid rgba(0,0,0,0.2)',
flexShrink: 0,
}}
/>
<div>
<div style={{ fontSize: '11.5px', fontWeight: 800, color: '#475569', textTransform: 'uppercase' }}>
{i === 0 ? '↖ Top / Main' : i === 1 && gameState.items.length === 3 ? '↙ Bottom' : '↗ Accessory'}
</div>
<div style={{ fontSize: 'clamp(17px, 1.6vw, 21px)', fontWeight: 900, color: '#0F172A' }}>
{formatItemPhrase(item)}
</div>
</div>
</div>
<span style={{ fontSize: '20px' }}>🔊</span>
</button>
);
})}
</div>
</div>
)}
</div>
)}
{mode === 'quiz' && (
<div className="fade-in-panel" style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
<span style={{ fontSize: '16px', fontWeight: 800, color: '#334155' }}>
Select the exact sentence matching the outfit:
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<span style={{ background: '#FEF3C7', color: '#92400E', padding: '5px 12px', borderRadius: '999px', fontSize: '14px', fontWeight: 800 }}>
🔥 Streak: {streak}
</span>
<span style={{ background: '#DBEAFE', color: '#1E40AF', padding: '5px 12px', borderRadius: '999px', fontSize: '14px', fontWeight: 800 }}>
⭐ Score: {score}
</span>
</div>
</div>
{quizOptions.map((opt, idx) => {
const isPicked = selectedQuizIdx === idx;
const bg = isPicked ? (opt.isCorrect ? '#DCFCE7' : '#FEE2E2') : '#F8FAFC';
const border = isPicked ? (opt.isCorrect ? '#16A34A' : '#DC2626') : '#CBD5E1';
return (
<button
key={idx}
onClick={() => handleQuizPick(opt, idx)}
className="btn-modern"
style={{
width: '100%',
textAlign: 'left',
justifyContent: 'space-between',
padding: '16px 20px',
borderRadius: '14px',
background: bg,
border: `2.5px solid ${border}`,
color: '#0F172A',
fontSize: 'clamp(17px, 1.8vw, 22px)',
fontWeight: 800,
}}
>
<span>{opt.text}</span>
{isPicked && <span style={{ fontSize: '22px' }}>{opt.isCorrect ? '✅' : '❌'}</span>}
</button>
);
})}
</div>
)}
{mode === 'wardrobe' && (
<div className="fade-in-panel" style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{gameState.items.map((slot, slotIdx) => {
const pool =
slotIdx === 0
? clothingPools.tops
: slotIdx === 1 && gameState.items.length === 3
? clothingPools.bottoms
: clothingPools.accessories;
const label =
slotIdx === 0 ? 'Top / Outfit' : slotIdx === 1 && gameState.items.length === 3 ? 'Bottoms' : 'Accessory';
return (
<div
key={slotIdx}
style={{
background: '#F8FAFC',
border: '1.5px solid #CBD5E1',
borderRadius: '14px',
padding: '12px 16px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '8px' }}>
<span style={{ fontSize: '14px', fontWeight: 800, color: '#334155', textTransform: 'uppercase' }}>
{label}: <strong style={{ color: slot.color.hex, fontSize: '16px' }}>{formatItemPhrase(slot)}</strong>
</span>
<div style={{ display: 'flex', gap: '6px' }}>
{colors.map((c) => (
<button
key={c.name}
onClick={() => updateWardrobeSlot(slotIdx, null, c)}
title={c.name}
style={{
width: 22,
height: 22,
borderRadius: '50%',
background: c.hex,
border: slot.color.name === c.name ? '3px solid #0F172A' : '1px solid rgba(0,0,0,0.2)',
cursor: 'pointer',
}}
/>
))}
</div>
</div>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{pool.map((g) => (
<button
key={g.name}
onClick={() => updateWardrobeSlot(slotIdx, g, null)}
className="btn-modern"
style={{
padding: '6px 13px',
borderRadius: '999px',
fontSize: '13.5px',
background: slot.clothing.name === g.name ? '#0F172A' : '#FFFFFF',
color: slot.clothing.name === g.name ? '#FFFFFF' : '#334155',
border: '1.5px solid #CBD5E1',
}}
>
{g.name}
</button>
))}
</div>
</div>
);
})}
</div>
)}
</div>
{/* 3. Bottom Action Footer */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: '12px',
borderTop: '1.5px solid #E2E8F0',
gap: '12px',
}}
>
<div style={{ fontSize: '13px', color: '#64748B', fontWeight: 700 }}>
<kbd style={{ background: '#F1F5F9', padding: '3px 8px', borderRadius: '6px', border: '1px solid #CBD5E1' }}>Space</kbd> Spin Outfit •{' '}
<kbd style={{ background: '#F1F5F9', padding: '3px 8px', borderRadius: '6px', border: '1px solid #CBD5E1' }}>Enter</kbd> Reveal & Speak
</div>
<button
onClick={() => spinRoulette()}
disabled={isSpinning}
className="btn-mega-spin"
style={{
padding: '12px 30px',
fontSize: '18px',
}}
>
🎲 SPIN AGAIN
</button>
</div>
</section>
</div>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>