feat(know-your-world): add learning mode takeover animation and fix give-up sequence

- Add react-spring powered takeover animation in learning mode
  - Region name scales up and centers on screen
  - Progress-based transition (letters typed / letters needed)
  - Blurry backdrop scrim with smooth CSS transitions
  - Nav bar remains accessible above scrim
- Fix give-up sequence timing
  - Show given-up region name during animation instead of next region
  - Hide type-to-unlock instruction during give-up
  - Suppress takeover animation during give-up to avoid conflict
- Add remaining count display at top of prompt pane
- Add progress gradient background to prompt pane

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-29 16:21:10 -06:00
parent 7e7a8dc1e8
commit 3fd8472e68
1 changed files with 480 additions and 375 deletions

View File

@ -2,7 +2,8 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { css } from '@styled/css'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useSpring, animated, to } from '@react-spring/web'
import { useViewerId } from '@/lib/arcade/game-sdk'
import { useTheme } from '@/contexts/ThemeContext'
import {
@ -25,6 +26,8 @@ import {
const GIVE_UP_ANIMATION_DURATION = 2000
// Duration for the "attention grab" phase of the name display (ms)
const NAME_ATTENTION_DURATION = 3000
// React-spring config for smooth takeover transitions
const TAKEOVER_ANIMATION_CONFIG = { tension: 170, friction: 20 }
// Helper to get hot/cold feedback emoji (matches MapRenderer's getHotColdEmoji)
function getHotColdEmoji(type: FeedbackType | null | undefined): string {
@ -75,9 +78,6 @@ export function GameInfoPanel({
progress,
onHintsUnlock,
}: GameInfoPanelProps) {
// Get flag emoji for world map countries (not USA states)
const flagEmoji =
selectedMap === 'world' && currentRegionId ? getCountryFlagEmoji(currentRegionId) : ''
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const { state, lastError, clearError, giveUp, controlsState } = useKnowYourWorld()
@ -133,6 +133,18 @@ export function GameInfoPanel({
return lastError
}, [lastError, currentDifficultyLevel])
// During give-up animation, show the given-up region's name instead of the next region
const displayRegionName = isGiveUpAnimating
? state.giveUpReveal?.regionName ?? currentRegionName
: currentRegionName
const displayRegionId = isGiveUpAnimating
? state.giveUpReveal?.regionId ?? currentRegionId
: currentRegionId
// Get flag emoji for the displayed region (not necessarily the current prompt)
const displayFlagEmoji =
selectedMap === 'world' && displayRegionId ? getCountryFlagEmoji(displayRegionId) : ''
// Track if animation is in progress (local state based on timestamp)
const [isAnimating, setIsAnimating] = useState(false)
@ -148,8 +160,89 @@ export function GameInfoPanel({
return getAssistanceLevel(state.assistanceLevel)
}, [state.assistanceLevel])
// Check if name confirmation is required
// Check if name confirmation is required (learning mode)
const requiresNameConfirmation = assistanceConfig.nameConfirmationLetters ?? 0
const isLearningMode = state.assistanceLevel === 'learning'
// Ref to measure the takeover container (region name + instructions)
const takeoverContainerRef = useRef<HTMLDivElement>(null)
// Calculate the safe scale factor and offsets for perfect centering
const [safeScale, setSafeScale] = useState(2.5)
const [centerXOffset, setCenterXOffset] = useState(0)
const [centerYOffset, setCenterYOffset] = useState(0)
// Measure container and calculate safe scale + centering offsets when region changes
useLayoutEffect(() => {
if (!currentRegionName || !isLearningMode) return
// Use requestAnimationFrame to ensure text has rendered
const rafId = requestAnimationFrame(() => {
if (takeoverContainerRef.current) {
const rect = takeoverContainerRef.current.getBoundingClientRect()
// Calculate max scale that keeps element within viewport bounds
// Leave 40px padding on each side
const maxWidthScale = rect.width > 0 ? (window.innerWidth - 80) / rect.width : 2.5
const maxHeightScale = rect.height > 0 ? (window.innerHeight - 80) / rect.height : 2.5
// Use the smaller of width/height constraints, clamped between 1.5 and 3.5
const calculatedScale = Math.min(maxWidthScale, maxHeightScale)
setSafeScale(Math.max(1.5, Math.min(3.5, calculatedScale)))
// Calculate X offset to center horizontally on screen
const elementCenterX = rect.left + rect.width / 2
const screenCenterX = window.innerWidth / 2
setCenterXOffset(screenCenterX - elementCenterX)
// Calculate Y offset to center vertically on screen
const elementCenterY = rect.top + rect.height / 2
const screenCenterY = window.innerHeight / 2
setCenterYOffset(screenCenterY - elementCenterY)
}
})
return () => cancelAnimationFrame(rafId)
}, [currentRegionName, isLearningMode])
// Calculate takeover progress based on letters typed (0 = full takeover, 1 = complete)
// Suppress takeover during give up animation to avoid visual conflict
const takeoverProgress = useMemo(() => {
// During give up animation, suppress takeover (progress = 1 means no takeover)
if (isGiveUpAnimating) return 1
if (!isLearningMode || requiresNameConfirmation === 0) return 1
return Math.min(1, confirmedLetterCount / requiresNameConfirmation)
}, [isLearningMode, requiresNameConfirmation, confirmedLetterCount, isGiveUpAnimating])
// Memoize spring target values to avoid recalculating on every render
const springTargets = useMemo(
() => ({
scale: safeScale - (safeScale - 1) * takeoverProgress,
x: centerXOffset * (1 - takeoverProgress),
y: centerYOffset * (1 - takeoverProgress),
}),
[safeScale, centerXOffset, centerYOffset, takeoverProgress]
)
// React-spring animation for takeover transition
const takeoverSpring = useSpring({
...springTargets,
config: TAKEOVER_ANIMATION_CONFIG,
})
// Memoize the transform interpolation to avoid recreating on every render
const transformStyle = useMemo(
() => ({
transform: to(
[takeoverSpring.scale, takeoverSpring.x, takeoverSpring.y],
(s, x, y) => `translate(${x}px, ${y}px) scale(${s})`
),
}),
[takeoverSpring.scale, takeoverSpring.x, takeoverSpring.y]
)
// Memoize whether we're in active takeover mode
const isInTakeover = isLearningMode && takeoverProgress < 1
const showPulseAnimation = isLearningMode && takeoverProgress < 0.5
// Reset name confirmation when region changes
useEffect(() => {
@ -323,9 +416,40 @@ export function GameInfoPanel({
0% { transform: translateX(-50%) translateY(100%); opacity: 0; }
100% { transform: translateX(-50%) translateY(0); opacity: 1; }
}
@keyframes takeoverPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.85; }
}
`}</style>
{/* Takeover backdrop scrim - blurs background but leaves nav accessible */}
{/* Always rendered but uses CSS transitions for smooth fade in/out */}
<div
data-element="takeover-scrim"
className={css({
position: 'fixed',
// Start below the nav bar to keep it accessible
top: '60px',
left: 0,
right: 0,
bottom: 0,
// Below takeover content (z-index 200) but above map
zIndex: 150,
pointerEvents: 'none',
// Smooth transitions for opacity and blur
transition: 'opacity 0.3s ease-out, backdrop-filter 0.3s ease-out',
})}
style={{
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.5)' : 'rgba(255, 255, 255, 0.5)',
// Animate between blurred (in takeover) and clear (not in takeover)
backdropFilter: isInTakeover ? 'blur(8px)' : 'blur(0px)',
WebkitBackdropFilter: isInTakeover ? 'blur(8px)' : 'blur(0px)',
opacity: isInTakeover ? 1 : 0,
}}
/>
{/* TOP-CENTER: Prompt Display - positioned below game nav (~150px) */}
{/* Background fills left-to-right as progress increases */}
<div
data-element="floating-prompt"
className={css({
@ -333,19 +457,37 @@ export function GameInfoPanel({
top: { base: '130px', sm: '150px' },
left: '50%',
transform: 'translateX(-50%)',
maxWidth: { base: '92vw', sm: '500px', md: '600px' },
width: { base: '92vw', sm: '420px', md: '500px', lg: '560px' },
padding: { base: '2', sm: '3' },
bg: isDark ? 'blue.900/95' : 'blue.50/95',
...floatingPanelBase,
border: { base: '2px solid', sm: '3px solid' },
borderColor: 'blue.500',
rounded: { base: 'xl', sm: '2xl' },
textAlign: 'center',
// Overflow handled in inline style to avoid css() recalculation
})}
style={{
animation: 'glowPulse 2s ease-in-out infinite',
overflow: isInTakeover ? 'visible' : 'hidden',
// Raise z-index above scrim (150) during takeover
zIndex: isInTakeover ? 200 : undefined,
background: isDark
? `linear-gradient(to right, rgba(22, 78, 99, 0.3) ${progress}%, rgba(30, 58, 138, 0.25) ${progress}%)`
: `linear-gradient(to right, rgba(34, 197, 94, 0.25) ${progress}%, rgba(59, 130, 246, 0.2) ${progress}%)`,
}}
>
{/* Remaining count - centered at top */}
<div
data-element="remaining-count"
className={css({
fontSize: { base: 'xs', sm: 'sm' },
fontWeight: 'bold',
color: isDark ? 'cyan.300' : 'cyan.700',
marginBottom: '1',
})}
>
{totalRegions - foundCount} left
</div>
{/* Header row with Find label and controls */}
<div
className={css({
@ -433,190 +575,129 @@ export function GameInfoPanel({
{/* Guidance dropdown - only show if there are options to configure */}
{shouldShowGuidanceDropdown(assistanceConfig) && (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
data-action="guidance-dropdown"
title="Guidance settings"
className={css({
padding: '1 2',
fontSize: 'xs',
cursor: 'pointer',
bg: 'transparent',
color: isDark ? 'blue.400' : 'blue.600',
rounded: 'md',
border: '1px solid',
borderColor: isDark ? 'blue.700' : 'blue.300',
fontWeight: 'medium',
transition: 'all 0.15s',
opacity: 0.7,
display: 'flex',
alignItems: 'center',
gap: '1',
_hover: {
opacity: 1,
borderColor: isDark ? 'blue.500' : 'blue.400',
},
})}
>
<span></span>
<svg
className={css({ w: '3', h: '3' })}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
data-action="guidance-dropdown"
title="Guidance settings"
className={css({
padding: '1 2',
fontSize: 'xs',
cursor: 'pointer',
bg: 'transparent',
color: isDark ? 'blue.400' : 'blue.600',
rounded: 'md',
border: '1px solid',
borderColor: isDark ? 'blue.700' : 'blue.300',
fontWeight: 'medium',
transition: 'all 0.15s',
opacity: 0.7,
display: 'flex',
alignItems: 'center',
gap: '1',
_hover: {
opacity: 1,
borderColor: isDark ? 'blue.500' : 'blue.400',
},
})}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</DropdownMenu.Trigger>
<span></span>
<svg
className={css({ w: '3', h: '3' })}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
data-element="guidance-dropdown-content"
className={css({
bg: isDark ? 'gray.800' : 'white',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
rounded: 'lg',
shadow: 'lg',
padding: '2',
minWidth: '160px',
zIndex: 1000,
})}
sideOffset={5}
align="end"
>
{/* Auto-Show Hints toggle - only if hints are available */}
{shouldShowAutoHintToggle(assistanceConfig) &&
(() => {
const isLocked = requiresNameConfirmation > 0 && !nameConfirmed
return (
<DropdownMenu.CheckboxItem
data-setting="auto-hint"
checked={autoHint}
disabled={isLocked}
onCheckedChange={() => !isLocked && onAutoHintToggle()}
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
padding: '2',
fontSize: 'xs',
cursor: isLocked ? 'not-allowed' : 'pointer',
rounded: 'md',
color: isLocked
? isDark
? 'gray.500'
: 'gray.400'
: isDark
? 'gray.200'
: 'gray.700',
outline: 'none',
opacity: isLocked ? 0.6 : 1,
_hover: isLocked
? {}
: {
bg: isDark ? 'gray.700' : 'gray.100',
},
_focus: isLocked
? {}
: {
bg: isDark ? 'gray.700' : 'gray.100',
},
})}
>
{isLocked ? (
<span>🔒</span>
) : (
<DropdownMenu.ItemIndicator>
<span></span>
</DropdownMenu.ItemIndicator>
)}
<span className={css({ marginLeft: isLocked || autoHint ? '0' : '4' })}>
💡 Auto-Show Hints
</span>
</DropdownMenu.CheckboxItem>
)
})()}
{/* Auto-speak toggle - only if speech supported AND hints available */}
{isSpeechSupported &&
shouldShowAutoSpeakToggle(assistanceConfig) &&
(() => {
const isLocked = requiresNameConfirmation > 0 && !nameConfirmed
return (
<DropdownMenu.CheckboxItem
data-setting="auto-speak"
checked={autoSpeak}
disabled={isLocked}
onCheckedChange={() => !isLocked && onAutoSpeakToggle()}
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
padding: '2',
fontSize: 'xs',
cursor: isLocked ? 'not-allowed' : 'pointer',
rounded: 'md',
color: isLocked
? isDark
? 'gray.500'
: 'gray.400'
: isDark
? 'gray.200'
: 'gray.700',
outline: 'none',
opacity: isLocked ? 0.6 : 1,
_hover: isLocked
? {}
: {
bg: isDark ? 'gray.700' : 'gray.100',
},
_focus: isLocked
? {}
: {
bg: isDark ? 'gray.700' : 'gray.100',
},
})}
>
{isLocked ? (
<span>🔒</span>
) : (
<DropdownMenu.ItemIndicator>
<span></span>
</DropdownMenu.ItemIndicator>
)}
<span className={css({ marginLeft: isLocked || autoSpeak ? '0' : '4' })}>
🔈 Auto Speak
</span>
</DropdownMenu.CheckboxItem>
)
})()}
{/* Hot/Cold toggle - only if available */}
{showHotCold &&
(() => {
const isLocked = requiresNameConfirmation > 0 && !nameConfirmed
return (
<>
<DropdownMenu.Separator
className={css({
height: '1px',
bg: isDark ? 'gray.700' : 'gray.200',
margin: '1 0',
})}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
data-element="guidance-dropdown-content"
className={css({
bg: isDark ? 'gray.800' : 'white',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
rounded: 'lg',
shadow: 'lg',
padding: '2',
minWidth: '160px',
zIndex: 1000,
})}
sideOffset={5}
align="end"
>
{/* Auto-Show Hints toggle - only if hints are available */}
{shouldShowAutoHintToggle(assistanceConfig) &&
(() => {
const isLocked = requiresNameConfirmation > 0 && !nameConfirmed
return (
<DropdownMenu.CheckboxItem
data-setting="hot-cold"
checked={hotColdEnabled}
data-setting="auto-hint"
checked={autoHint}
disabled={isLocked}
onCheckedChange={() => !isLocked && onHotColdToggle?.()}
onCheckedChange={() => !isLocked && onAutoHintToggle()}
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
padding: '2',
fontSize: 'xs',
cursor: isLocked ? 'not-allowed' : 'pointer',
rounded: 'md',
color: isLocked
? isDark
? 'gray.500'
: 'gray.400'
: isDark
? 'gray.200'
: 'gray.700',
outline: 'none',
opacity: isLocked ? 0.6 : 1,
_hover: isLocked
? {}
: {
bg: isDark ? 'gray.700' : 'gray.100',
},
_focus: isLocked
? {}
: {
bg: isDark ? 'gray.700' : 'gray.100',
},
})}
>
{isLocked ? (
<span>🔒</span>
) : (
<DropdownMenu.ItemIndicator>
<span></span>
</DropdownMenu.ItemIndicator>
)}
<span className={css({ marginLeft: isLocked || autoHint ? '0' : '4' })}>
💡 Auto-Show Hints
</span>
</DropdownMenu.CheckboxItem>
)
})()}
{/* Auto-speak toggle - only if speech supported AND hints available */}
{isSpeechSupported &&
shouldShowAutoSpeakToggle(assistanceConfig) &&
(() => {
const isLocked = requiresNameConfirmation > 0 && !nameConfirmed
return (
<DropdownMenu.CheckboxItem
data-setting="auto-speak"
checked={autoSpeak}
disabled={isLocked}
onCheckedChange={() => !isLocked && onAutoSpeakToggle()}
className={css({
display: 'flex',
alignItems: 'center',
@ -654,171 +735,252 @@ export function GameInfoPanel({
</DropdownMenu.ItemIndicator>
)}
<span
className={css({
marginLeft: isLocked || hotColdEnabled ? '0' : '4',
})}
className={css({ marginLeft: isLocked || autoSpeak ? '0' : '4' })}
>
{hotColdEnabled ? '🔥' : '❄️'} Hot/Cold
🔈 Auto Speak
</span>
</DropdownMenu.CheckboxItem>
</>
)
})()}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)
})()}
{/* Hot/Cold toggle - only if available */}
{showHotCold &&
(() => {
const isLocked = requiresNameConfirmation > 0 && !nameConfirmed
return (
<>
<DropdownMenu.Separator
className={css({
height: '1px',
bg: isDark ? 'gray.700' : 'gray.200',
margin: '1 0',
})}
/>
<DropdownMenu.CheckboxItem
data-setting="hot-cold"
checked={hotColdEnabled}
disabled={isLocked}
onCheckedChange={() => !isLocked && onHotColdToggle?.()}
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
padding: '2',
fontSize: 'xs',
cursor: isLocked ? 'not-allowed' : 'pointer',
rounded: 'md',
color: isLocked
? isDark
? 'gray.500'
: 'gray.400'
: isDark
? 'gray.200'
: 'gray.700',
outline: 'none',
opacity: isLocked ? 0.6 : 1,
_hover: isLocked
? {}
: {
bg: isDark ? 'gray.700' : 'gray.100',
},
_focus: isLocked
? {}
: {
bg: isDark ? 'gray.700' : 'gray.100',
},
})}
>
{isLocked ? (
<span>🔒</span>
) : (
<DropdownMenu.ItemIndicator>
<span></span>
</DropdownMenu.ItemIndicator>
)}
<span
className={css({
marginLeft: isLocked || hotColdEnabled ? '0' : '4',
})}
>
{hotColdEnabled ? '🔥' : '❄️'} Hot/Cold
</span>
</DropdownMenu.CheckboxItem>
</>
)
})()}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)}
</div>
</div>
<div
{/* Animated container for takeover - includes region name and instructions */}
<animated.div
ref={takeoverContainerRef}
key={currentRegionId || 'empty'} // Re-trigger animation on change
data-element="region-name-display"
data-element="takeover-container"
className={css({
fontSize: isAttentionPhase
? { base: 'xl', sm: '2xl', md: '3xl' }
: { base: 'lg', sm: 'xl', md: '2xl' },
fontWeight: 'bold',
color: isDark ? 'white' : 'blue.900',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: { base: '1', sm: '2' },
transition: 'font-size 0.5s ease-out',
position: 'relative',
transformOrigin: 'center center',
})}
style={{
animation: `attentionGrab ${NAME_ATTENTION_DURATION}ms ease-out`,
textShadow: isDark ? '0 2px 4px rgba(0,0,0,0.3)' : 'none',
// Dynamic z-index moved to inline style to avoid css() recalculation
zIndex: isInTakeover ? 200 : 'auto',
// React-spring animated transform
...transformStyle,
// Pulse animation during takeover (fades after halfway)
animation: showPulseAnimation ? 'takeoverPulse 0.8s ease-in-out infinite' : 'none',
}}
>
{flagEmoji && (
<span
className={css({
fontSize: isAttentionPhase
? { base: 'xl', sm: '2xl', md: '3xl' }
: { base: 'lg', sm: 'xl', md: '2xl' },
})}
>
{flagEmoji}
</span>
)}
{/* Inline letter highlighting for name confirmation */}
<span>
{currentRegionName
? currentRegionName.split('').map((char, index) => {
// Determine if this letter needs confirmation styling
const needsConfirmation =
requiresNameConfirmation > 0 &&
!nameConfirmed &&
index < requiresNameConfirmation
const isConfirmed = index < confirmedLetterCount
const isNextToConfirm = index === confirmedLetterCount && needsConfirmation
return (
<span
key={index}
className={css({
transition: 'all 0.15s ease-out',
})}
style={{
// Dim unconfirmed letters that need confirmation
opacity: needsConfirmation && !isConfirmed ? 0.4 : 1,
// Highlight the next letter to type
textDecoration: isNextToConfirm ? 'underline' : 'none',
textDecorationColor: isNextToConfirm
? isDark
? '#60a5fa'
: '#3b82f6'
: undefined,
textUnderlineOffset: isNextToConfirm ? '4px' : undefined,
}}
>
{char}
</span>
)
})
: '...'}
</span>
</div>
{/* Type-to-unlock instruction OR inline hint */}
{requiresNameConfirmation > 0 && !nameConfirmed ? (
{/* Region name display */}
<div
data-element="type-to-unlock"
data-element="region-name-display"
className={css({
marginTop: '2',
fontSize: { base: 'sm', sm: 'md' },
color: isDark ? 'amber.300' : 'amber.700',
fontSize: { base: 'lg', sm: 'xl', md: '2xl' },
fontWeight: 'bold',
color: isDark ? 'white' : 'blue.900',
whiteSpace: 'nowrap',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '1.5',
gap: { base: '1', sm: '2' },
})}
style={{
textShadow: isDark ? '0 2px 4px rgba(0,0,0,0.3)' : 'none',
}}
>
<span>👆</span>
<span>Type the underlined letter{requiresNameConfirmation > 1 ? 's' : ''}</span>
{displayFlagEmoji && (
<span
className={css({
fontSize: { base: 'lg', sm: 'xl', md: '2xl' },
})}
>
{displayFlagEmoji}
</span>
)}
{/* Inline letter highlighting for name confirmation */}
<span>
{displayRegionName
? displayRegionName.split('').map((char, index) => {
// Determine if this letter needs confirmation styling
// Disable during give-up animation (showing the given-up region)
const needsConfirmation =
!isGiveUpAnimating &&
requiresNameConfirmation > 0 &&
!nameConfirmed &&
index < requiresNameConfirmation
const isConfirmed = index < confirmedLetterCount
const isNextToConfirm = index === confirmedLetterCount && needsConfirmation
return (
<span
key={index}
className={css({
transition: 'all 0.15s ease-out',
})}
style={{
// Dim unconfirmed letters that need confirmation
opacity: needsConfirmation && !isConfirmed ? 0.4 : 1,
// Highlight the next letter to type
textDecoration: isNextToConfirm ? 'underline' : 'none',
textDecorationColor: isNextToConfirm
? isDark
? '#60a5fa'
: '#3b82f6'
: undefined,
textUnderlineOffset: isNextToConfirm ? '4px' : undefined,
}}
>
{char}
</span>
)
})
: '...'}
</span>
</div>
) : (
currentHint && (
{/* Type-to-unlock instruction - included in takeover container */}
{/* Hide during give-up animation since we're showing the given-up region */}
{!isGiveUpAnimating && requiresNameConfirmation > 0 && !nameConfirmed && (
<div
data-element="inline-hint"
data-element="type-to-unlock"
className={css({
marginTop: '2',
fontSize: 'md',
color: isDark ? 'blue.300' : 'blue.700',
fontSize: { base: 'sm', sm: 'md' },
color: isDark ? 'amber.300' : 'amber.700',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '1.5',
whiteSpace: 'nowrap',
overflow: 'hidden',
maxWidth: '100%',
})}
>
{/* Speaker button - subtle styling */}
{isSpeechSupported ? (
<button
onClick={(e) => {
e.stopPropagation()
if (isSpeaking) {
onStopSpeaking()
} else {
onSpeak()
}
}}
data-action="speak-hint"
title={isSpeaking ? 'Stop speaking' : 'Read hint aloud'}
className={css({
background: 'transparent',
border: 'none',
padding: '0',
cursor: 'pointer',
fontSize: 'inherit',
lineHeight: 'inherit',
color: 'inherit',
opacity: isSpeaking ? 1 : 0.7,
transition: 'opacity 0.2s',
_hover: { opacity: 1 },
flexShrink: 0,
})}
>
{isSpeaking ? '⏹️' : '🔈'}
</button>
) : (
<span className={css({ opacity: 0.7 })}>💡</span>
)}
<span
<span></span>
<span>Type the underlined letter{requiresNameConfirmation > 1 ? 's' : ''}</span>
</div>
)}
</animated.div>
{/* Inline hint - shown after name is confirmed (or always in non-learning modes) */}
{currentHint && (requiresNameConfirmation === 0 || nameConfirmed) && (
<div
data-element="inline-hint"
className={css({
marginTop: '2',
fontSize: 'md',
color: isDark ? 'blue.300' : 'blue.700',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '1.5',
whiteSpace: 'nowrap',
overflow: 'hidden',
maxWidth: '100%',
})}
>
{/* Speaker button - subtle styling */}
{isSpeechSupported ? (
<button
onClick={(e) => {
e.stopPropagation()
if (isSpeaking) {
onStopSpeaking()
} else {
onSpeak()
}
}}
data-action="speak-hint"
title={isSpeaking ? 'Stop speaking' : 'Read hint aloud'}
className={css({
overflow: 'hidden',
textOverflow: 'ellipsis',
background: 'transparent',
border: 'none',
padding: '0',
cursor: 'pointer',
fontSize: 'inherit',
lineHeight: 'inherit',
color: 'inherit',
opacity: isSpeaking ? 1 : 0.7,
transition: 'opacity 0.2s',
_hover: { opacity: 1 },
flexShrink: 0,
})}
>
{currentHint}
</span>
</div>
)
{isSpeaking ? '⏹️' : '🔈'}
</button>
) : (
<span className={css({ opacity: 0.7 })}>💡</span>
)}
<span
className={css({
overflow: 'hidden',
textOverflow: 'ellipsis',
})}
>
{currentHint}
</span>
</div>
)}
{/* Voting status for cooperative mode */}
@ -843,63 +1005,6 @@ export function GameInfoPanel({
)}
</div>
{/* TOP-LEFT: Progress Indicator - positioned below game nav */}
<div
data-element="floating-progress"
className={css({
position: 'absolute',
top: { base: '130px', sm: '150px' },
left: { base: '2', sm: '4' },
padding: { base: '1.5 2', sm: '2 3' },
bg: isDark ? 'gray.800/90' : 'white/90',
...floatingPanelBase,
rounded: { base: 'lg', sm: 'xl' },
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.5',
})}
>
<div
className={css({
fontSize: '2xs',
color: isDark ? 'gray.400' : 'gray.600',
fontWeight: 'semibold',
display: { base: 'none', sm: 'block' },
})}
>
Progress
</div>
<div
className={css({
fontSize: { base: 'md', sm: 'xl' },
fontWeight: 'bold',
color: isDark ? 'green.400' : 'green.600',
})}
>
{foundCount}/{totalRegions}
</div>
{/* Mini progress bar */}
<div
className={css({
width: '60px',
height: '4px',
bg: isDark ? 'gray.700' : 'gray.200',
rounded: 'full',
overflow: 'hidden',
})}
>
<div
className={css({
height: '100%',
bg: 'green.500',
transition: 'width 0.5s ease',
})}
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* BOTTOM-CENTER: Error Banner (toast-style) */}
{lastError && (
<div