包源代码 · What Are They Wearing?
CC BY 4.0 · 请注明 @joe · v1
AI 修改提示的开头
请为交互式白板修改这个课堂游戏。保留大的触摸目标和全班一起玩的方式。禁止追踪、登录、收集个人信息以及远程 API 调用。请使用下载的完整 ZIP,不要只用下面显示的入口 HTML,并保留可在浏览器中直接使用的文件夹结构。请保留教学目标。原创作者:ClassBoard Games 上的 @joe(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>