feat: Add CHORUS teaser website with mobile-responsive design

- Created complete Next.js 15 teaser website with CHORUS brand styling
- Implemented mobile-responsive 3D logo (128px mobile, 512px desktop)
- Added proper Exo font loading via Next.js Google Fonts for iOS/Chrome compatibility
- Built comprehensive early access form with GDPR compliance and rate limiting
- Integrated PostgreSQL database with complete schema for lead capture
- Added scroll indicators that auto-hide when scrolling begins
- Optimized mobile modal forms with proper scrolling and submit button access
- Deployed via Docker Swarm with Traefik SSL termination at chorus.services
- Includes database migrations, consent tracking, and email notifications

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tony
2025-08-26 13:57:30 +10:00
parent 630d1c26ad
commit c8fb816775
236 changed files with 17525 additions and 0 deletions

View File

@@ -0,0 +1,276 @@
'use client'
import { useState, FormEvent } from 'react'
import { EarlyAccessLead, useEarlyAccessCapture } from '../hooks/useEarlyAccessCapture'
import { LeadSourceType } from '../hooks/useEarlyAccessCapture'
interface EarlyAccessFormProps {
isOpen: boolean
onClose: () => void
leadSource: LeadSourceType
}
export default function EarlyAccessForm({ isOpen, onClose, leadSource }: EarlyAccessFormProps) {
const { submitEarlyAccess, isSubmitting, submitStatus, errorMessage } = useEarlyAccessCapture()
const [formData, setFormData] = useState<EarlyAccessLead>({
firstName: '',
lastName: '',
email: '',
companyName: '',
companyRole: '',
interestLevel: 'general_interest',
leadSource: leadSource,
gdprConsent: false,
marketingConsent: false,
})
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!formData.gdprConsent) {
alert('Please accept the privacy policy to continue.')
return
}
const result = await submitEarlyAccess(formData)
if (result.success) {
// Reset form on success
setFormData({
firstName: '',
lastName: '',
email: '',
companyName: '',
companyRole: '',
interestLevel: 'general_interest',
leadSource: leadSource,
gdprConsent: false,
marketingConsent: false,
})
}
}
const handleInputChange = (field: keyof EarlyAccessLead, value: string | boolean) => {
setFormData(prev => ({
...prev,
[field]: value
}))
}
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm overflow-y-auto"
onClick={onClose}
>
<div
className="relative w-full max-w-md bg-gradient-to-b from-mulberry-900 to-carbon-900 text-white p-4 sm:p-chorus-xl rounded-lg border border-mulberry-700/50 shadow-2xl my-8 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-4 right-4 text-mulberry-300 hover:text-white text-2xl font-light transition-colors duration-200"
>
×
</button>
{/* Form Header */}
<h3 className="text-h3 font-semibold text-white mb-chorus-sm">
{leadSource === 'request_early_access'
? 'Request Early Access to CHORUS'
: 'Join the CHORUS Waitlist'}
</h3>
<p className="text-sm text-mulberry-200 font-light mb-4 sm:mb-chorus-xl">
{leadSource === 'request_early_access'
? 'Get priority access to contextual AI orchestration'
: 'Be notified when CHORUS becomes available'}
</p>
{/* Success State */}
{submitStatus === 'success' ? (
<div className="text-center py-chorus-xl">
<div className="text-5xl text-eucalyptus-400 mb-chorus-lg animate-bounce">
</div>
<h4 className="text-lg font-semibold text-eucalyptus-300 mb-chorus-sm">
{leadSource === 'request_early_access'
? 'Request submitted successfully!'
: 'Welcome to the waitlist!'}
</h4>
<p className="text-sm text-mulberry-200 font-light">
{leadSource === 'request_early_access'
? 'We\'ll prioritize your request and contact you soon.'
: 'We\'ll notify you when CHORUS becomes available.'}
</p>
</div>
) : (
/* Form */
<form onSubmit={handleSubmit}>
{/* Name Fields */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-chorus-sm sm:gap-chorus-md mb-chorus-md">
<div>
<label className="block text-sm font-medium text-mulberry-200 mb-chorus-xs">
First Name *
</label>
<input
type="text"
required
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
className="form-input"
placeholder="John"
/>
</div>
<div>
<label className="block text-sm font-medium text-mulberry-200 mb-chorus-xs">
Last Name *
</label>
<input
type="text"
required
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
className="form-input"
placeholder="Doe"
/>
</div>
</div>
{/* Email */}
<div className="mb-chorus-md">
<label className="block text-sm font-medium text-mulberry-200 mb-chorus-xs">
Email Address *
</label>
<input
type="email"
required
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
className="form-input"
placeholder="john@company.com"
/>
</div>
{/* Company Information */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-chorus-sm sm:gap-chorus-md mb-chorus-md">
<div>
<label className="block text-sm font-medium text-mulberry-200 mb-chorus-xs">
Company
</label>
<input
type="text"
value={formData.companyName || ''}
onChange={(e) => handleInputChange('companyName', e.target.value)}
className="form-input"
placeholder="Company Name"
/>
</div>
<div>
<label className="block text-sm font-medium text-mulberry-200 mb-chorus-xs">
Role
</label>
<input
type="text"
value={formData.companyRole || ''}
onChange={(e) => handleInputChange('companyRole', e.target.value)}
className="form-input"
placeholder="CTO, Director, etc."
/>
</div>
</div>
{/* Interest Level */}
<div className="mb-4 sm:mb-chorus-lg">
<label className="block text-sm font-medium text-mulberry-200 mb-chorus-xs">
Primary Interest
</label>
<select
value={formData.interestLevel}
onChange={(e) => handleInputChange('interestLevel', e.target.value as any)}
className="form-input"
>
<option value="general_interest">General Interest</option>
<option value="technical_evaluation">Technical Evaluation</option>
<option value="strategic_demo">Strategic Demo</option>
</select>
</div>
{/* GDPR Consent */}
<div style={{ marginBottom: '1.5rem', fontSize: '0.85rem' }}>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', marginBottom: '0.75rem' }}>
<input
type="checkbox"
checked={formData.gdprConsent}
onChange={(e) => handleInputChange('gdprConsent', e.target.checked)}
required
/>
<span>
I agree to the privacy policy and consent to processing my personal data for early access communications. *
</span>
</label>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
<input
type="checkbox"
checked={formData.marketingConsent}
onChange={(e) => handleInputChange('marketingConsent', e.target.checked)}
/>
<span>
I would like to receive updates about CHORUS Services and related products.
</span>
</label>
</div>
{/* Error Message */}
{submitStatus === 'error' && errorMessage && (
<div style={{
padding: '0.75rem',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
border: '1px solid #dc2626',
color: '#fca5a5',
fontSize: '0.85rem',
marginBottom: '1rem'
}}>
{errorMessage}
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={isSubmitting || !formData.gdprConsent}
className="btn-primary"
style={{
width: '100%',
opacity: (isSubmitting || !formData.gdprConsent) ? 0.5 : 1,
cursor: (isSubmitting || !formData.gdprConsent) ? 'not-allowed' : 'pointer'
}}
>
{isSubmitting
? (leadSource === 'request_early_access' ? 'Submitting Request...' : 'Joining Waitlist...')
: (leadSource === 'request_early_access' ? 'Submit Request' : 'Join Waitlist')
}
</button>
</form>
)}
{/* Footer */}
<p style={{
fontSize: '0.75rem',
opacity: 0.6,
textAlign: 'center',
marginTop: '1.5rem',
paddingTop: '1.5rem',
borderTop: '1px solid #444'
}}>
By joining our waitlist, you'll receive exclusive early access and product updates.
We respect your privacy and won't spam you.
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,94 @@
'use client'
import ScrollReveal from './ScrollReveal'
export default function MissionStatement() {
return (
<section className="py-chorus-xxl px-chorus-lg bg-gradient-to-b from-sand-200 via-sand-100 to-white dark:from-mulberry-950 dark:via-carbon-950 dark:to-mulberry-950 text-center">
<div className="max-w-4xl mx-auto">
{/* Section Title */}
<ScrollReveal delay={200} duration={600} direction="up">
<h3 className="text-h2 font-bold text-carbon-950 dark:text-white mb-chorus-xl">
Our Mission
</h3>
</ScrollReveal>
{/* Mission Statement */}
<ScrollReveal delay={300} duration={600} direction="up">
<p className="text-xl md:text-2xl leading-relaxed mb-chorus-xl text-carbon-700 dark:text-mulberry-100 font-light">
We are creating a distributed, semantic and temporal knowledge fabric,
for humans and AI, to share reasoning, context and intent, not just files.
</p>
</ScrollReveal>
<ScrollReveal delay={400} duration={600} direction="up">
<div className="w-16 h-px bg-gradient-to-r from-transparent via-carbon-400 dark:via-mulberry-400 to-transparent mx-auto my-chorus-xxl" />
</ScrollReveal>
<ScrollReveal delay={500} duration={600} direction="up">
<p className="text-lg md:text-xl leading-relaxed mb-chorus-xxl text-carbon-600 dark:text-mulberry-200 font-light max-w-3xl mx-auto">
CHORUS transforms how organizations orchestrate AI agents,
ensuring every decision is informed by the right context,
delivered to the right agent, at precisely the right moment.
</p>
</ScrollReveal>
{/* Supporting Points */}
<ScrollReveal delay={600} duration={600} direction="up">
<div className="grid grid-cols-1 md:grid-cols-3 gap-chorus-xl mt-chorus-xxl">
{[
{
title: "Contextual Intelligence",
description: "Beyond data sharing—intelligent context that understands meaning, relationships, and temporal significance.",
color: "ocean"
},
{
title: "Agent Orchestration",
description: "Seamless coordination between human teams and AI agents through sophisticated workflow intelligence.",
color: "eucalyptus"
},
{
title: "Temporal Knowledge",
description: "Understanding not just what happened, but when it mattered and why it influenced subsequent decisions.",
color: "coral"
}
].map((point, index) => (
<div
key={index}
className="text-center p-chorus-lg rounded-lg bg-sand-50/80 dark:bg-mulberry-900/30 border border-sand-200/60 dark:border-mulberry-800/40 backdrop-blur-sm hover:bg-sand-100/90 dark:hover:bg-mulberry-900/50 transition-all duration-500 ease-out"
>
<div className={`w-20 h-20 mx-auto mb-chorus-md rounded-full bg-gradient-to-br from-${point.color}-500 to-${point.color}-700 flex items-center justify-center`}>
{index === 0 && (
// Navigation/Compass Icon
<svg className={`w-12 h-12 text-${point.color}-100`} fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m-6 3l6-3" />
</svg>
)}
{index === 1 && (
// Communication/Chat_Conversation Icon
<svg className={`w-12 h-12 text-${point.color}-100`} fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
)}
{index === 2 && (
// Calendar/Clock Icon
<svg className={`w-12 h-12 text-${point.color}-100`} fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
</div>
<h4 className="text-lg font-semibold text-carbon-950 dark:text-white mb-chorus-sm">
{point.title}
</h4>
<p className="text-sm leading-relaxed text-carbon-600 dark:text-mulberry-300 font-light">
{point.description}
</p>
</div>
))}
</div>
</ScrollReveal>
</div>
</section>
)
}

View File

@@ -0,0 +1,62 @@
'use client';
import React from 'react';
import { useIntersectionObserver } from '@/hooks/useIntersectionObserver';
interface ScrollRevealProps {
children: React.ReactNode;
delay?: number;
duration?: number;
direction?: 'up' | 'down' | 'left' | 'right';
distance?: number;
className?: string;
}
export default function ScrollReveal({
children,
delay = 0,
duration = 600,
direction = 'up',
distance = 24,
className = '',
}: ScrollRevealProps) {
const { elementRef, isVisible } = useIntersectionObserver({
threshold: 0.1,
rootMargin: '0px 0px -100px 0px',
triggerOnce: true,
});
const getTransform = (visible: boolean) => {
if (visible) return 'translate3d(0, 0, 0)';
switch (direction) {
case 'up':
return `translate3d(0, ${distance}px, 0)`;
case 'down':
return `translate3d(0, -${distance}px, 0)`;
case 'left':
return `translate3d(${distance}px, 0, 0)`;
case 'right':
return `translate3d(-${distance}px, 0, 0)`;
default:
return `translate3d(0, ${distance}px, 0)`;
}
};
const revealStyle = {
opacity: isVisible ? 1 : 0,
transform: getTransform(isVisible),
transition: `opacity ${duration}ms ease-out ${delay}ms, transform ${duration}ms ease-out ${delay}ms`,
willChange: 'opacity, transform',
};
return (
<div
ref={elementRef}
style={revealStyle}
className={`scroll-reveal ${className}`}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,68 @@
'use client'
import { useEffect, useState } from 'react'
import { LeadSourceType } from '../hooks/useEarlyAccessCapture'
import ThreeLogo from './ThreeLogo'
interface TeaserHeroProps {
onEarlyAccess: (leadSource: LeadSourceType) => void
}
export default function TeaserHero({ onEarlyAccess }: TeaserHeroProps) {
const [showScrollIndicator, setShowScrollIndicator] = useState(true)
useEffect(() => {
const handleScroll = () => {
setShowScrollIndicator(window.scrollY < 50)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
return (
<section className="min-h-screen flex flex-col items-center justify-center px-chorus-lg py-chorus-xxl bg-gradient-to-b from-white via-sand-100 to-sand-200 dark:from-carbon-950 dark:via-mulberry-950 dark:to-carbon-950 text-center relative">
{/* CHORUS 3D Logo - Responsive sizing */}
<div className="animate-fade-in">
<div className="w-32 h-32 md:w-96 md:h-96 lg:w-[512px] lg:h-[512px]">
<ThreeLogo className="mx-auto drop-shadow-2xl w-full h-full" />
</div>
</div>
{/* CHORUS Title */}
<h1 className="text-h1 font-logo font-thin text-carbon-950 dark:text-white mb-chorus-md animate-slide-up" style={{ animationDelay: '0.3s' }}>
CHORUS
</h1>
{/* Tagline */}
<h2 className="text-xl md:text-2xl text-carbon-700 dark:text-mulberry-100 mb-chorus-xxl max-w-2xl font-light leading-relaxed animate-fade-in-up" style={{ animationDelay: '0.6s' }}>
The right context,<br/>to the right agent,<br/>at the right time.
</h2>
{/* CTA Buttons */}
<div className="flex gap-chorus-md flex-wrap justify-center animate-fade-in-up" style={{ animationDelay: '0.9s' }}>
<button
onClick={() => onEarlyAccess('request_early_access')}
className="btn-primary text-lg px-chorus-xl py-chorus-md"
>
Request Early Access
</button>
<button
onClick={() => onEarlyAccess('early_access_waitlist')}
className="btn-secondary text-lg px-chorus-xl py-chorus-md"
>
Join Waitlist
</button>
</div>
{/* Subtle scroll indicator */}
{showScrollIndicator && (
<div className="absolute bottom-chorus-lg left-1/2 -translate-x-1/2 animate-bounce transition-opacity duration-300">
<div className="w-6 h-10 border-2 border-carbon-600 dark:border-mulberry-400 rounded-full flex justify-center items-start">
<div className="w-1 h-3 bg-carbon-600 dark:bg-mulberry-400 rounded-full mt-2 animate-pulse"></div>
</div>
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,43 @@
'use client'
import React, { useState, useEffect } from 'react'
export default function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('dark')
useEffect(() => {
const isDark = document.documentElement.classList.contains('dark')
setTheme(isDark ? 'dark' : 'light')
}, [])
const toggleTheme = () => {
const newTheme = theme === 'dark' ? 'light' : 'dark'
setTheme(newTheme)
if (newTheme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
return (
<button
onClick={toggleTheme}
className="fixed top-chorus-lg right-chorus-lg z-50 p-chorus-sm hover:bg-black/10 dark:hover:bg-white/10 transition-colors rounded-lg backdrop-blur-sm bg-white/20 dark:bg-black/20 border border-white/30 dark:border-white/20"
aria-label="Toggle theme"
>
{theme === 'dark' ? (
// Sun icon for switching to light mode
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
// Moon icon for switching to dark mode
<svg className="w-6 h-6 text-carbon-950" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
</button>
)
}

View File

@@ -0,0 +1,236 @@
'use client';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
interface ThreeLogoProps {
className?: string;
}
export default function ThreeLogo({ className = "" }: ThreeLogoProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
// Scene / Renderer / Camera
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 0, 3.0); // Move camera back to prevent clipping
camera.lookAt(0, 0, 0); // Ensure camera looks at exact center
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setClearColor(0x000000, 0); // transparent background
container.appendChild(renderer.domElement);
// Resize handling with proper aspect ratio
const resize = () => {
const { clientWidth, clientHeight } = container;
// Ensure square aspect ratio for the logo
const size = Math.min(clientWidth, clientHeight);
renderer.setSize(size, size, false);
camera.aspect = 1; // Always square
camera.updateProjectionMatrix();
};
resize();
window.addEventListener('resize', resize);
// Exact lighting setup from your reference logo.html
const light = new THREE.PointLight(0xffffff, 1.4);
light.position.set(0, 4, 1);
scene.add(light);
const bottomLight = new THREE.PointLight(0x800080, 1.2, 12);
bottomLight.position.set(0, -4, 1);
scene.add(bottomLight);
const leftLight = new THREE.PointLight(0x808000, 1.45, 5);
leftLight.position.set(-5, 0, 4);
scene.add(leftLight);
scene.add(new THREE.AmbientLight(0xffffff, 0.45));
// Load environment map from your horizon gradient image
const expandGradient = (img: HTMLImageElement) => {
const canvas = document.createElement('canvas');
const w = 512, h = 256; // safe HDRI-like size
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d')!;
// Stretch the narrow gradient strip to fill the canvas
ctx.drawImage(img, 0, 0, w, h);
return new THREE.CanvasTexture(canvas);
};
const envLoader = new THREE.ImageLoader();
envLoader.load('/logos/horizon-gradient.png', (image) => {
const tex = expandGradient(image);
tex.mapping = THREE.EquirectangularReflectionMapping;
// Generate proper environment map using PMREM
const pmrem = new THREE.PMREMGenerator(renderer);
const envMap = pmrem.fromEquirectangular(tex).texture;
scene.environment = envMap;
pmrem.dispose();
});
scene.background = null; // keep transparent
// Theme-aware material colors
const getThemeMaterial = (theme: string = 'default') => {
const colorMap = {
'default': 0x333333,
'protanopia': 0x1e40af, // Blue-800 for red-blind
'deuteranopia': 0x6b21a8, // Purple-800 for green-blind
'tritanopia': 0x991b1b, // Red-800 for blue-blind
'achromatopsia': 0x374151, // Gray-700 for color-blind
};
return new THREE.MeshPhysicalMaterial({
color: colorMap[theme as keyof typeof colorMap] || colorMap.default,
roughness: 0.24,
metalness: 1.0,
clearcoat: 0.48,
clearcoatRoughness: 0.15,
reflectivity: 1.2,
sheen: 0.35,
sheenColor: new THREE.Color(0x212121),
sheenRoughness: 0.168,
envMapIntensity: 1,
});
};
// Initialize with default material
let currentMaterial = getThemeMaterial('default');
// Load GLB with DRACO support
const loader = new GLTFLoader();
const draco = new DRACOLoader();
draco.setDecoderPath('/draco/');
loader.setDRACOLoader(draco);
let model: THREE.Object3D | null = null;
console.log('Loading your mobius-ring.glb...');
loader.load(
'/logos/mobius-ring.glb',
(gltf) => {
console.log('🎉 Your GLB loaded successfully!', gltf);
model = gltf.scene;
// Apply the theme-aware material
model.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
(child as THREE.Mesh).material = currentMaterial;
}
});
// Center the model exactly at origin
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
model.position.sub(center); // Move model so its center is at (0,0,0)
scene.add(model);
console.log('🎯 Your Möbius GLB model added to scene and centered at origin');
},
(progress) => {
if (progress.total > 0) {
const percent = Math.round(progress.loaded / progress.total * 100);
console.log(`GLB loading progress: ${percent}% (${progress.loaded}/${progress.total} bytes)`);
}
},
(err) => {
console.error('❌ GLB load error:', err);
// Fallback: create a torus geometry with the theme-aware material
console.log('Creating fallback torus geometry...');
const fallbackGeometry = new THREE.TorusGeometry(0.6, 0.2, 16, 100);
const fallbackMesh = new THREE.Mesh(fallbackGeometry, currentMaterial);
fallbackMesh.position.set(0, 0, 0); // Ensure fallback is also centered
scene.add(fallbackMesh);
model = fallbackMesh;
console.log('⚠️ Fallback torus geometry created (placeholder for your GLB)');
}
);
// Listen for accessibility theme changes
const handleThemeChange = (event: any) => {
const newTheme = event.detail.theme;
const newMaterial = getThemeMaterial(newTheme);
if (model) {
// Update material on all meshes
model.traverse((child: any) => {
if (child.isMesh) {
child.material.dispose(); // Clean up old material
child.material = newMaterial;
}
});
}
// Update current material reference
currentMaterial = newMaterial;
console.log(`🎨 Logo theme changed to: ${newTheme}`);
};
window.addEventListener('accessibilityThemeChanged', handleThemeChange);
let raf = 0;
const tick = () => {
raf = requestAnimationFrame(tick);
if (model) {
// Use exact rotation parameters from your reference logo.html
model.rotation.x += 0.010; // spinSpeedX from params
model.rotation.y += -0.010; // spinSpeedY from params
model.rotation.z += -0.1; // spinSpeedZ from params
}
renderer.render(scene, camera);
};
tick();
// Cleanup
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', resize);
window.removeEventListener('accessibilityThemeChanged', handleThemeChange);
renderer.dispose();
draco.dispose();
if (container.contains(renderer.domElement)) {
container.removeChild(renderer.domElement);
}
scene.traverse((obj: any) => {
if (obj.geometry) obj.geometry.dispose?.();
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach((m: any) => m.dispose?.());
}
});
};
}, []);
return (
<div
ref={containerRef}
className={className}
style={{
width: '100%',
height: '100%',
aspectRatio: '1 / 1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
/>
);
}