feat(ui): 3D round table with person figures + dark theme polish

- RoundTable3D: replace sphere avatars with head+body person figures,
  add per-persona glow pads, expanding pulse rings for active speaker,
  connection line to table center, ambient particles, pill nameplates
  with occupation, rotating center emblem
- FocusGroupSession: fix Rules-of-Hooks (move useMemo before early
  returns), fix personas not loading (data.personas vs data.participants)
- Dark theme: replace hardcoded Tailwind colors across 20+ components
  with semantic CSS tokens (bg-card, text-primary, text-muted-foreground,
  text-brand-success, text-destructive, etc.)
- Add three + @types/three dependencies for 3D rendering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-26 17:26:33 +01:00
parent 0f7b8a5a9e
commit 92657a1c0c
4 changed files with 651 additions and 458 deletions

60
package-lock.json generated
View file

@ -27,7 +27,7 @@
"@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
@ -40,6 +40,7 @@
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@tanstack/react-query": "^5.56.2",
"@types/three": "^0.184.1",
"axios": "^1.6.2",
"caniuse-lite": "^1.0.30001715",
"class-variance-authority": "^0.7.1",
@ -67,6 +68,7 @@
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"three": "^0.184.0",
"vaul": "^0.9.3",
"zod": "^3.23.8"
},
@ -111,6 +113,12 @@
"node": ">=6.9.0"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"license": "Apache-2.0"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@ -2874,6 +2882,12 @@
"react": "^18 || ^19"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"license": "MIT"
},
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
@ -2989,6 +3003,32 @@
"@types/react": "*"
}
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.184.1",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz",
"integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==",
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"fflate": "~0.8.2",
"meshoptimizer": "~1.1.1"
}
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz",
@ -4671,6 +4711,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@ -5415,6 +5461,12 @@
"node": ">= 8"
}
},
"node_modules/meshoptimizer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@ -6729,6 +6781,12 @@
"node": ">=0.8"
}
},
"node_modules/three": {
"version": "0.184.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",

View file

@ -36,7 +36,7 @@
"@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
@ -49,6 +49,7 @@
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@tanstack/react-query": "^5.56.2",
"@types/three": "^0.184.1",
"axios": "^1.6.2",
"caniuse-lite": "^1.0.30001715",
"class-variance-authority": "^0.7.1",
@ -76,6 +77,7 @@
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"three": "^0.184.0",
"vaul": "^0.9.3",
"zod": "^3.23.8"
},

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import {
@ -38,6 +38,7 @@ import { useQuery } from '@tanstack/react-query';
import { useCancellableGeneration } from '@/hooks/useCancellableGeneration';
import { getSocket } from '@/services/websocketServiceNew';
import ProgressModal from '@/components/ui/ProgressModal';
import RoundTable3D from '@/components/focus-group-session/RoundTable3D';
// GPT-5 FIX: Use new singleton WebSocket service
import { initSocket, joinFocusGroup, leaveFocusGroup } from '@/services/websocketServiceNew';
import {
@ -59,6 +60,7 @@ const FocusGroupSession = () => {
const [focusGroup, setFocusGroup] = useState<FocusGroup | null>(null);
const [participants, setParticipants] = useState<Persona[]>([]);
const [allPersonas, setAllPersonas] = useState<Persona[]>([]);
const [showTable, setShowTable] = useState(true);
const [activeTab, setActiveTab] = useState('chat');
const [moderatorStatus, setModeratorStatus] = useState<any>(null);
const [showAutonomousDashboard, setShowAutonomousDashboard] = useState(false);
@ -738,19 +740,19 @@ const FocusGroupSession = () => {
id: data._id || data.id,
name: data.name,
status: data.status || 'in-progress',
participants: data.participants || [],
participants: data.personas || data.participants || [],
date: data.date || new Date().toISOString(),
duration: data.duration || 60,
topic: data.topic || 'general',
discussionGuide: data.discussionGuide || '',
llm_model: data.llm_model || 'gpt-5.4'
};
setFocusGroup(focusGroupData);
setSelectedModel(focusGroupData.llm_model || 'gpt-5.4');
setSelectedReasoningEffort(focusGroupData.reasoning_effort || 'medium');
setSelectedVerbosity(focusGroupData.verbosity || 'medium');
// Handle participants
if (data.participants_data && Array.isArray(data.participants_data)) {
// Use participants data if available
@ -767,7 +769,7 @@ const FocusGroupSession = () => {
setParticipants(groupParticipants);
}
// Load messages and moderator status
await fetchMessages();
await fetchModeratorStatus();
@ -860,7 +862,7 @@ const FocusGroupSession = () => {
id: data._id || data.id,
name: data.name,
status: data.status || 'in-progress',
participants: data.participants || [],
participants: data.personas || data.participants || [],
date: data.date || new Date().toISOString(),
duration: data.duration || 60,
topic: data.topic || 'general',
@ -1777,6 +1779,17 @@ const FocusGroupSession = () => {
});
};
// Derive who spoke last (for 3D table highlight) — must be before any conditional returns
const activeSpeakerId = useMemo<string | null>(() => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.senderId && msg.senderId !== 'moderator' && msg.senderId !== 'facilitator') {
return msg.senderId;
}
}
return null;
}, [messages]);
// Show loading state while fetching
if (isLoading) {
return (
@ -1810,7 +1823,7 @@ const FocusGroupSession = () => {
</div>
);
}
return (
<div className="min-h-screen bg-background flex flex-col">
@ -1818,21 +1831,21 @@ const FocusGroupSession = () => {
{/* WebSocket Connection Status Bar */}
{useWebSocketEnabled && isStatusBarVisible && (
<div className={`w-full transition-all duration-300 ${
wsConnected
? 'bg-green-500'
: wsConnecting
? 'bg-yellow-500'
: 'bg-red-500'
wsConnected
? 'bg-brand-success/90'
: wsConnecting
? 'bg-primary/90'
: 'bg-destructive/90'
}`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between py-2 text-white text-sm font-medium">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${
wsConnected
? 'bg-white animate-pulse'
: wsConnecting
? 'bg-white animate-spin'
: 'bg-white'
wsConnected
? 'bg-brand-success animate-pulse'
: wsConnecting
? 'bg-primary animate-pulse'
: 'bg-muted-foreground/60'
}`} />
<span>
{wsConnected
@ -1870,10 +1883,10 @@ const FocusGroupSession = () => {
onClick={() => setIsStatusBarVisible(true)}
className={`px-3 py-1 rounded-full text-white text-xs font-medium shadow-lg transition-all duration-200 hover:shadow-xl ${
wsConnected
? 'bg-green-500 hover:bg-green-600'
: wsConnecting
? 'bg-yellow-500 hover:bg-yellow-600'
: 'bg-red-500 hover:bg-red-600'
? 'bg-brand-success/80 hover:bg-brand-success'
: wsConnecting
? 'bg-primary/80 hover:bg-primary'
: 'bg-destructive/80 hover:bg-destructive'
}`}
title={
wsConnected
@ -1884,7 +1897,7 @@ const FocusGroupSession = () => {
}
>
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full bg-white ${wsConnected ? 'animate-pulse' : ''}`} />
<div className={`w-2 h-2 rounded-full ${wsConnected ? 'bg-brand-success animate-pulse' : wsConnecting ? 'bg-primary animate-pulse' : 'bg-muted-foreground/60'}`} />
<span>{wsConnected ? 'Live' : wsConnecting ? 'Connecting' : 'Offline'}</span>
</div>
</button>
@ -1946,25 +1959,25 @@ const FocusGroupSession = () => {
{/* Quota warning banner */}
{quotaWarning && (
<div className="px-4 py-2 bg-amber-50 border-b border-amber-200 flex items-center justify-between shrink-0">
<div className="px-4 py-2 bg-brand-amber/10 border-b border-brand-amber/30 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3 flex-1">
<span className="text-sm text-amber-700">
<span className="text-sm text-brand-amber">
Usage at {Math.round(quotaWarning.pct * 100)}% of {quotaWarning.scope} quota
(${quotaWarning.used_usd.toFixed(4)} of ${quotaWarning.limit_usd.toFixed(2)})
</span>
<Progress value={quotaWarning.pct * 100} className="w-24 h-2" />
</div>
<button className="text-xs text-amber-500 hover:text-amber-700 ml-2" onClick={() => setQuotaWarning(null)}>&#x2715;</button>
<button className="text-xs text-brand-amber/70 hover:text-brand-amber ml-2" onClick={() => setQuotaWarning(null)}>&#x2715;</button>
</div>
)}
{/* Quota exceeded banner */}
{quotaExceeded && (
<div className="px-4 py-2 bg-red-50 border-b border-red-200 flex items-center justify-between shrink-0">
<span className="text-sm text-red-700">
<div className="px-4 py-2 bg-destructive/10 border-b border-destructive/30 flex items-center justify-between shrink-0">
<span className="text-sm text-destructive">
Quota exceeded ({quotaExceeded.scope}): ${quotaExceeded.used_usd.toFixed(4)} of ${quotaExceeded.limit_usd.toFixed(2)} used.
</span>
<button className="text-xs text-red-500 hover:text-red-700" onClick={() => setQuotaExceeded(null)}>&#x2715;</button>
<button className="text-xs text-destructive/70 hover:text-destructive" onClick={() => setQuotaExceeded(null)}>&#x2715;</button>
</div>
)}
@ -1991,8 +2004,8 @@ const FocusGroupSession = () => {
</span>
<h1 className="font-semibold text-foreground truncate max-w-[300px]">{focusGroup.name}</h1>
{isAiModeActive && (
<span className="flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-widest text-green-400 border border-green-500/30 bg-green-500/10 rounded-full px-2 py-0.5 shrink-0">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />
<span className="flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-widest text-brand-success border border-brand-success/30 bg-brand-success/10 rounded-full px-2 py-0.5 shrink-0">
<span className="w-1.5 h-1.5 rounded-full bg-brand-success animate-pulse" />
Live
</span>
)}
@ -2015,13 +2028,43 @@ const FocusGroupSession = () => {
/>
</div>
{/* CENTER: Discussion */}
{/* CENTER: Round Table + Discussion */}
<div className="bg-background flex flex-col overflow-hidden">
{/* 3D Round Table */}
{participants.length > 0 && showTable && (
<div className="shrink-0 relative border-b border-border/50">
<RoundTable3D
personas={participants}
activeSpeakerId={activeSpeakerId}
messages={messages}
height={messages.length === 0 ? 420 : 260}
/>
<button
onClick={() => setShowTable(false)}
className="absolute top-2 right-2 z-20 text-[10px] text-muted-foreground/50 hover:text-muted-foreground transition-colors px-2 py-1 rounded bg-background/60 backdrop-blur-sm"
title="Hide 3D view"
>
hide
</button>
</div>
)}
{participants.length > 0 && !showTable && (
<button
onClick={() => setShowTable(true)}
className="shrink-0 w-full text-[10px] text-muted-foreground/50 hover:text-primary/70 transition-colors py-1 border-b border-border/30 bg-card/30"
>
show table
</button>
)}
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full space-y-4">
<p className="text-lg text-muted-foreground">
No messages yet. Start the session to begin the discussion.
</p>
<div className="flex flex-col items-center justify-center flex-1 space-y-4">
{participants.length === 0 && (
<p className="text-lg text-muted-foreground">
No messages yet. Start the session to begin the discussion.
</p>
)}
<Button onClick={startSession} size="lg" className="flex items-center gap-2">
<PlayCircle className="h-5 w-5" />
Start Session