Responsive polish: pointer-events-none on animations, sticky header with scroll fade, desktop scroll-to-active fix, iOS audio unlock, player and UI tweaks

- Add pointer-events-none/select-none to feature cards, voice creator, and ControlUI mock
- Sticky header with gradient fade overlay (matching real app 3-layer technique)
- Fix desktop scroll-to-active: separate mobile/desktop card refs to prevent mobile refs overwriting desktop
- Scroll selected card to 2nd row when outside safe zone above generate box
- iOS Safari audio unlock via WaveSurfer's actual media element
- Player: accent fill play/pause button, padding on volume slider, remove close button
- Profile cards: fixed 143px height, mobile edge fades with scroll-aware left fade
- Generate box: accent effect pill when active, white fill sparkle icon, edge-aligned on desktop
- Voice creator: animated waveform background with height-based bars
- 12 profiles (added Attenborough, Zendaya, Obama) for 4-row grid with scroll
This commit is contained in:
James Pine
2026-03-15 06:17:54 -07:00
parent f80782a90a
commit 0c6aa15746
4 changed files with 148 additions and 52 deletions
+110 -42
View File
@@ -85,6 +85,24 @@ const PROFILES: VoiceProfile[] = [
language: 'en', language: 'en',
hasEffects: false, hasEffects: false,
}, },
{
name: 'David Attenborough',
description: 'Warm, reverent narration with wonder and precision',
language: 'en',
hasEffects: false,
},
{
name: 'Zendaya',
description: 'Relaxed, modern delivery with effortless cool',
language: 'en',
hasEffects: false,
},
{
name: 'Barack Obama',
description: 'Measured cadence with rhythmic pauses and gravitas',
language: 'en',
hasEffects: false,
},
]; ];
/** Each entry is one cycle of the demo animation: select a profile → type text → generate → play audio. */ /** Each entry is one cycle of the demo animation: select a profile → type text → generate → play audio. */
@@ -474,7 +492,7 @@ function FloatingGenerateBox({
{/* Generate button */} {/* Generate button */}
<button className="h-8 w-8 rounded-full bg-accent flex items-center justify-center shrink-0 shadow-lg"> <button className="h-8 w-8 rounded-full bg-accent flex items-center justify-center shrink-0 shadow-lg">
<Sparkles className="h-3.5 w-3.5 text-accent-foreground" /> <Sparkles className="h-3.5 w-3.5 text-white fill-white" />
</button> </button>
</div> </div>
@@ -486,8 +504,14 @@ function FloatingGenerateBox({
<span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground"> <span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground">
{engine} {engine}
</span> </span>
<span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground flex items-center gap-1"> <span
<Sparkles className="h-2.5 w-2.5" /> className={`text-[10px] px-2 py-1 rounded-full border flex items-center gap-1 ${
effect
? 'border-accent/30 bg-accent/10 text-accent'
: 'border-border bg-card text-muted-foreground'
}`}
>
<Sparkles className={`h-2.5 w-2.5 ${effect ? 'fill-accent' : ''}`} />
{effect || 'Effect'} {effect || 'Effect'}
</span> </span>
</div> </div>
@@ -508,18 +532,46 @@ export function ControlUI() {
const [pageHidden, setPageHidden] = useState(false); const [pageHidden, setPageHidden] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const phaseRef = useRef(phase); const phaseRef = useRef(phase);
const profileCardRefs = useRef<Map<number, HTMLDivElement>>(new Map()); const mobileCardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const desktopCardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const profileGridRef = useRef<HTMLDivElement>(null);
const [scrollLeft, setScrollLeft] = useState(0);
phaseRef.current = phase; phaseRef.current = phase;
const step = DEMO_SCRIPT[cycle % DEMO_SCRIPT.length]; const step = DEMO_SCRIPT[cycle % DEMO_SCRIPT.length];
const selectedProfile = PROFILES[selectedIndex]; const selectedProfile = PROFILES[selectedIndex];
// Scroll to selected profile card // Scroll to selected profile card — accounts for generate box overlay on desktop
useEffect(() => { useEffect(() => {
const el = profileCardRefs.current.get(selectedIndex); const isMobile = window.innerWidth < 768;
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); if (isMobile) {
const el = mobileCardRefs.current.get(selectedIndex);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
return;
} }
// Desktop
const el = desktopCardRefs.current.get(selectedIndex);
const scrollContainer = profileGridRef.current;
if (!el || !scrollContainer) return;
const containerTop = scrollContainer.getBoundingClientRect().top;
const elTop = el.getBoundingClientRect().top;
const elRelTop = elTop - containerTop + scrollContainer.scrollTop;
const rowHeight = 145;
const generateBoxHeight = 200;
const visibleTop = scrollContainer.scrollTop;
const visibleBottom = visibleTop + scrollContainer.clientHeight - generateBoxHeight;
const elRelBottom = elRelTop + el.offsetHeight;
if (elRelTop >= visibleTop && elRelBottom <= visibleBottom) {
return;
}
const target = elRelTop - rowHeight;
scrollContainer.scrollTo({ top: Math.max(0, target), behavior: 'smooth' });
}, [selectedIndex]); }, [selectedIndex]);
// Visibility detection // Visibility detection
@@ -674,7 +726,7 @@ export function ControlUI() {
</div> </div>
</div> </div>
<div className="overflow-hidden rounded-2xl border border-app-line bg-app-box shadow-[0_25px_60px_rgba(0,0,0,0.5),0_8px_20px_rgba(0,0,0,0.3)] md:h-[640px]"> <div className="overflow-hidden rounded-2xl border border-app-line bg-app-box shadow-[0_25px_60px_rgba(0,0,0,0.5),0_8px_20px_rgba(0,0,0,0.3)] md:h-[640px] pointer-events-none select-none">
<div className="flex flex-col md:flex-row h-full"> <div className="flex flex-col md:flex-row h-full">
{/* ── Sidebar (hidden on mobile) ─────────────────────────── */} {/* ── Sidebar (hidden on mobile) ─────────────────────────── */}
<div className="hidden md:flex w-16 shrink-0 border-r border-app-line bg-sidebar flex-col items-center py-4 gap-4"> <div className="hidden md:flex w-16 shrink-0 border-r border-app-line bg-sidebar flex-col items-center py-4 gap-4">
@@ -722,60 +774,76 @@ export function ControlUI() {
{/* ── Main content ──────────────────────────────────────── */} {/* ── Main content ──────────────────────────────────────── */}
<div className="flex-1 flex flex-col md:flex-row min-w-0 relative"> <div className="flex-1 flex flex-col md:flex-row min-w-0 relative">
{/* Left: Profiles + Generate box */} {/* Left: Profiles + Generate box */}
<div className="flex flex-col min-w-0 relative md:flex-1"> <div className="flex flex-col min-w-0 relative md:flex-1 md:overflow-hidden">
{/* Header */} {/* Gradient fade overlay — sits between header and scroll content */}
<div className="px-4 pt-4 md:pt-6 pb-2 flex items-center justify-between relative z-10"> <div className="hidden md:block absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-app-box to-transparent z-[1] pointer-events-none" />
{/* Header — floats above everything */}
<div className="absolute top-0 left-0 right-0 z-10 px-4 pt-4 md:pt-6 pb-2 flex items-center justify-between">
<h2 className="text-base font-bold">Voicebox</h2> <h2 className="text-base font-bold">Voicebox</h2>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<button className="h-6 text-[10px] px-2.5 rounded-full border border-border bg-card text-muted-foreground flex items-center gap-1"> <button className="h-6 text-[10px] px-2.5 rounded-full border border-border bg-card text-muted-foreground flex items-center gap-1">
Import Voice Import Voice
</button> </button>
<button className="h-6 text-[10px] px-2.5 rounded-full bg-accent text-accent-foreground flex items-center gap-1"> <button className="h-6 text-[10px] px-2.5 rounded-full bg-accent text-accent-foreground flex items-center">
<Sparkles className="h-2.5 w-2.5" />
Create Voice Create Voice
</button> </button>
</div> </div>
</div> </div>
{/* Profile cards — horizontal scroll on mobile, 3-col grid on desktop */} {/* Scrollable profile cards — scrolls behind header + gradient */}
<div className="px-4"> <div
{/* Mobile: horizontal scroll strip */} ref={profileGridRef}
<div className="flex gap-2 overflow-x-auto pb-2 md:hidden"> className="flex-1 min-h-0 md:overflow-y-auto md:pt-14 pt-12"
{PROFILES.map((profile, i) => ( >
<div className="px-4">
{/* Mobile: horizontal scroll strip with edge fade */}
<div className="relative md:hidden">
{scrollLeft > 0 && (
<div className="absolute left-0 top-0 bottom-0 w-6 bg-gradient-to-r from-app-box to-transparent z-10" />
)}
<div className="absolute right-0 top-0 bottom-0 w-6 bg-gradient-to-l from-app-box to-transparent z-10" />
<div <div
key={profile.name} className="flex gap-2 overflow-x-auto pb-2"
className="shrink-0 w-[140px]" onScroll={(e) => setScrollLeft(e.currentTarget.scrollLeft)}
ref={(el) => {
if (el) profileCardRefs.current.set(i, el);
}}
> >
{PROFILES.map((profile, i) => (
<div
key={profile.name}
className="shrink-0 w-[140px]"
ref={(el) => {
if (el) mobileCardRefs.current.set(i, el);
}}
>
<ProfileCard
profile={profile}
selected={i === selectedIndex}
selecting={phase === 'selecting'}
/>
</div>
))}
</div>
</div>
{/* Desktop: 3-col grid */}
<div className="hidden md:grid grid-cols-3 gap-2 mt-1 pb-44">
{PROFILES.map((profile, i) => (
<ProfileCard <ProfileCard
key={profile.name}
profile={profile} profile={profile}
selected={i === selectedIndex} selected={i === selectedIndex}
selecting={phase === 'selecting'} selecting={phase === 'selecting'}
cardRef={(el: HTMLDivElement | null) => {
if (el) desktopCardRefs.current.set(i, el);
}}
/> />
</div> ))}
))} </div>
</div>
{/* Desktop: 3-col grid */}
<div className="hidden md:grid grid-cols-3 gap-2 mt-1 pb-24">
{PROFILES.map((profile, i) => (
<ProfileCard
key={profile.name}
profile={profile}
selected={i === selectedIndex}
selecting={phase === 'selecting'}
cardRef={(el: HTMLDivElement | null) => {
if (el) profileCardRefs.current.set(i, el);
}}
/>
))}
</div> </div>
</div> </div>
{/* Floating generate box — desktop: absolute overlay, mobile: inline */} {/* Floating generate box — desktop: absolute overlay, mobile: inline */}
<div className="px-3 pt-2 pb-3 md:pt-0 md:absolute md:left-3 md:right-3 md:bottom-[117px] md:z-20 md:pb-0"> <div className="px-3 pt-2 pb-3 md:pt-0 md:absolute md:left-4 md:right-4 md:bottom-[117px] md:z-20 md:pb-0 md:px-0">
<FloatingGenerateBox <FloatingGenerateBox
phase={phase} phase={phase}
typingText={step.text} typingText={step.text}
+3 -1
View File
@@ -814,7 +814,9 @@ function FeatureCard({ feature }: { feature: (typeof FEATURES)[number] }) {
return ( return (
<div className="rounded-lg border border-app-line bg-app-darkBox overflow-hidden"> <div className="rounded-lg border border-app-line bg-app-darkBox overflow-hidden">
<LazyLoad> <LazyLoad>
<Animation /> <div className="pointer-events-none select-none">
<Animation />
</div>
</LazyLoad> </LazyLoad>
<div className="p-5"> <div className="p-5">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
+34 -8
View File
@@ -10,10 +10,32 @@ function formatDuration(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`; return `${m}:${s.toString().padStart(2, '0')}`;
} }
// Unlock Web Audio on iOS Safari — must be called from a user gesture (click/tap) // Shared ref so the unmute button can unlock WaveSurfer's audio on iOS Safari
let audioContextUnlocked = false; // Must call .play() on WaveSurfer's actual media element during a user gesture
let sharedWaveSurfer: WaveSurfer | null = null;
let audioUnlocked = false;
export function unlockAudioContext() { export function unlockAudioContext() {
if (audioContextUnlocked) return; if (audioUnlocked) return;
audioUnlocked = true;
// Unlock WaveSurfer's internal audio element
if (sharedWaveSurfer) {
const media = sharedWaveSurfer.getMediaElement();
if (media) {
media.muted = true;
media
.play()
.then(() => {
media.pause();
media.muted = false;
media.currentTime = 0;
})
.catch(() => {});
}
}
// Also unlock a standalone AudioContext as fallback
try { try {
const ctx = new ( const ctx = new (
window.AudioContext || window.AudioContext ||
@@ -24,9 +46,8 @@ export function unlockAudioContext() {
source.buffer = buffer; source.buffer = buffer;
source.connect(ctx.destination); source.connect(ctx.destination);
source.start(0); source.start(0);
audioContextUnlocked = true;
} catch { } catch {
// Silently fail — non-Safari browsers don't need this // Silently fail
} }
} }
@@ -137,6 +158,7 @@ export function LandingAudioPlayer({
ws.load(audioUrl); ws.load(audioUrl);
wavesurferRef.current = ws; wavesurferRef.current = ws;
sharedWaveSurfer = ws;
}; };
setIsReady(false); setIsReady(false);
@@ -213,9 +235,13 @@ export function LandingAudioPlayer({
<button <button
onClick={handlePlayPause} onClick={handlePlayPause}
disabled={!isReady} disabled={!isReady}
className="h-10 w-10 rounded-full flex items-center justify-center hover:bg-muted shrink-0 disabled:opacity-50 md:order-1" className="h-10 w-10 rounded-full bg-accent flex items-center justify-center shrink-0 disabled:opacity-50 md:order-1 shadow-lg"
> >
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 ml-0.5" />} {isPlaying ? (
<Pause className="h-5 w-5 text-accent-foreground fill-accent-foreground" />
) : (
<Play className="h-5 w-5 ml-0.5 text-accent-foreground fill-accent-foreground" />
)}
</button> </button>
{/* Time */} {/* Time */}
@@ -243,7 +269,7 @@ export function LandingAudioPlayer({
</button> </button>
{/* Volume */} {/* Volume */}
<div className="flex items-center gap-2 shrink-0 w-[120px] md:order-6"> <div className="flex items-center gap-2 shrink-0 w-[140px] md:order-6 mr-3">
<button <button
onClick={() => setVolume(volume > 0 ? 0 : 0.75)} onClick={() => setVolume(volume > 0 ? 0 : 0.75)}
className="h-8 w-8 flex items-center justify-center hover:bg-muted rounded-sm" className="h-8 w-8 flex items-center justify-center hover:bg-muted rounded-sm"
+1 -1
View File
@@ -429,7 +429,7 @@ export function VoiceCreator() {
</div> </div>
{/* Right: Animated UI mock */} {/* Right: Animated UI mock */}
<div className="rounded-xl border border-app-line bg-app-darkBox overflow-hidden"> <div className="rounded-xl border border-app-line bg-app-darkBox overflow-hidden pointer-events-none select-none">
<div className="p-5"> <div className="p-5">
{/* Tab bar */} {/* Tab bar */}
<div className="flex rounded-lg border border-border bg-card/50 p-1 mb-4"> <div className="flex rounded-lg border border-border bg-card/50 p-1 mb-4">