feat(know-your-world): unify setup UI positions with gameplay

Move setup controls to match gameplay UI positions for minimal transition:
- Move Mode/Guidance selectors to top-right (same position as gameplay controls)
- Keep Start button at bottom-center (prominent, no conflict)
- Adjust selector sizes for vertical stack on mobile, horizontal on desktop

This minimizes map movement when transitioning from setup to gameplay
since both phases now use the same safe zone margins effectively.

🤖 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 06:06:49 -06:00
parent a4f9db6d3f
commit c1a0485b1d
2 changed files with 370 additions and 190 deletions

View File

@ -36,7 +36,6 @@ import {
filterRegionsBySizes,
calculateSafeZoneViewBox,
type SafeZoneMargins,
ASSISTANCE_LEVELS,
} from '../maps'
import { getCustomCrop } from '../customCrops'
import type { RegionSize, ImportanceLevel, PopulationLevel, FilterCriteria } from '../maps'
@ -54,6 +53,7 @@ import {
populationToRange,
rangeToPopulation,
} from '../utils/regionSizeUtils'
import { preventFlexExpansion } from '../utils/responsiveStyles'
/**
* Safe zone margins - must match MapRenderer for consistent positioning
@ -143,19 +143,6 @@ const PLANETS: PlanetData[] = [
{ id: 'saturn', name: 'Saturn', color: '#ead6b8', size: 1.7, hasRings: true },
]
/** Game mode options */
type GameMode = 'cooperative' | 'race' | 'turn-based'
/** Assistance level options */
type AssistanceLevel = 'learning' | 'guided' | 'helpful' | 'standard' | 'none'
/** Game mode display options */
const GAME_MODE_OPTIONS: Array<{ value: GameMode; emoji: string; label: string }> = [
{ value: 'cooperative', emoji: '🤝', label: 'Co-op' },
{ value: 'race', emoji: '🏁', label: 'Race' },
{ value: 'turn-based', emoji: '↔️', label: 'Turns' },
]
interface DrillDownMapSelectorProps {
/** Callback when selection changes (map/continent for game start) */
onSelectionChange: (mapId: 'world' | 'usa', continentId: ContinentId | 'all') => void
@ -173,14 +160,6 @@ interface DrillDownMapSelectorProps {
regionCountsBySize: Record<string, number>
/** When true, fills parent container and uses overlay positioning for UI elements */
fillContainer?: boolean
/** Current game mode (for unified controls in fillContainer mode) */
gameMode?: GameMode
/** Callback when game mode changes */
onGameModeChange?: (mode: GameMode) => void
/** Current assistance level (for unified controls in fillContainer mode) */
assistanceLevel?: AssistanceLevel
/** Callback when assistance level changes */
onAssistanceLevelChange?: (level: AssistanceLevel) => void
}
interface BreadcrumbItem {
@ -199,10 +178,6 @@ export function DrillDownMapSelector({
onRegionSizesChange,
regionCountsBySize,
fillContainer = false,
gameMode,
onGameModeChange,
assistanceLevel,
onAssistanceLevelChange,
}: DrillDownMapSelectorProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@ -1340,8 +1315,8 @@ export function DrillDownMapSelector({
rounded: 'xl',
shadow: 'lg',
width: '205px',
maxHeight: { base: 'none', md: fillContainer ? 'calc(100vh - 200px)' : 'none' },
overflowY: 'auto',
maxHeight: { base: 'none', md: fillContainer ? '450px' : 'none' },
overflow: 'hidden',
})}
>
{/* Filter Criteria Tabs */}
@ -1522,157 +1497,6 @@ export function DrillDownMapSelector({
/>
</div>
)}
{/* Game Mode & Assistance Level - only in fillContainer mode */}
{fillContainer && gameMode && onGameModeChange && (
<div
data-element="game-settings"
className={css({
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.300',
marginTop: '2',
paddingTop: '2',
display: 'flex',
flexDirection: 'column',
gap: '2',
})}
>
{/* Game Mode Selector */}
<div data-element="game-mode-selector">
<div
className={css({
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '1',
})}
>
Mode
</div>
<div
className={css({
display: 'flex',
gap: '1px',
bg: isDark ? 'gray.700' : 'gray.200',
rounded: 'md',
padding: '2px',
})}
>
{GAME_MODE_OPTIONS.map((option) => {
const isActive = gameMode === option.value
return (
<button
key={option.value}
onClick={() => onGameModeChange(option.value)}
className={css({
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '1',
padding: '1.5',
fontSize: 'xs',
fontWeight: isActive ? 'bold' : 'normal',
color: isActive
? isDark
? 'white'
: 'gray.900'
: isDark
? 'gray.400'
: 'gray.600',
bg: isActive ? (isDark ? 'gray.600' : 'white') : 'transparent',
rounded: 'sm',
cursor: 'pointer',
border: 'none',
transition: 'all 0.15s',
_hover: {
bg: isActive
? isDark
? 'gray.600'
: 'white'
: isDark
? 'gray.600/50'
: 'gray.100',
},
})}
>
<span>{option.emoji}</span>
<span>{option.label}</span>
</button>
)
})}
</div>
</div>
{/* Assistance Level Selector */}
{assistanceLevel && onAssistanceLevelChange && (
<div data-element="assistance-selector">
<div
className={css({
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '1',
})}
>
Assistance
</div>
<div
className={css({
display: 'flex',
gap: '1px',
bg: isDark ? 'gray.700' : 'gray.200',
rounded: 'md',
padding: '2px',
flexWrap: 'wrap',
})}
>
{ASSISTANCE_LEVELS.map((level) => {
const isActive = assistanceLevel === level.id
return (
<button
key={level.id}
onClick={() => onAssistanceLevelChange(level.id as AssistanceLevel)}
title={level.description}
className={css({
flex: '1 1 auto',
minWidth: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1.5',
fontSize: 'sm',
fontWeight: isActive ? 'bold' : 'normal',
color: isActive
? isDark
? 'white'
: 'gray.900'
: isDark
? 'gray.400'
: 'gray.600',
bg: isActive ? (isDark ? 'gray.600' : 'white') : 'transparent',
rounded: 'sm',
cursor: 'pointer',
border: 'none',
transition: 'all 0.15s',
_hover: {
bg: isActive
? isDark
? 'gray.600'
: 'white'
: isDark
? 'gray.600/50'
: 'gray.100',
},
})}
>
<span>{level.emoji}</span>
</button>
)
})}
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>

View File

@ -1,11 +1,13 @@
'use client'
import { useCallback, useMemo } from 'react'
import * as Select from '@radix-ui/react-select'
import { css } from '@styled/css'
import { useTheme } from '@/contexts/ThemeContext'
import { useKnowYourWorld } from '../Provider'
import { DrillDownMapSelector } from './DrillDownMapSelector'
import { ALL_REGION_SIZES, getFilteredMapDataBySizesSync } from '../maps'
import { ALL_REGION_SIZES, ASSISTANCE_LEVELS, getFilteredMapDataBySizesSync } from '../maps'
import type { AssistanceLevelConfig } from '../maps'
import { CONTINENTS, type ContinentId } from '../continents'
// Travel-themed content for each region
@ -78,6 +80,49 @@ const REGION_THEMES: Record<string, RegionTheme> = {
const DEFAULT_THEME: RegionTheme = REGION_THEMES.World
// Generate feature badges for an assistance level
function getFeatureBadges(level: AssistanceLevelConfig): Array<{ label: string; icon: string }> {
const badges: Array<{ label: string; icon: string }> = []
if (level.hotColdEnabled) {
badges.push({ label: 'Hot/cold', icon: '🔥' })
}
if (level.hintsMode === 'onRequest') {
if (level.autoHintDefault) {
badges.push({ label: 'Auto-hints', icon: '💡' })
} else {
badges.push({ label: 'Hints', icon: '💡' })
}
} else if (level.hintsMode === 'limited' && level.hintLimit) {
badges.push({ label: `${level.hintLimit} hints`, icon: '💡' })
}
return badges
}
// Game mode options
const GAME_MODE_OPTIONS = [
{
value: 'cooperative' as const,
emoji: '🤝',
label: 'Cooperative',
description: 'Work together to find all regions',
},
{
value: 'race' as const,
emoji: '🏁',
label: 'Race',
description: 'First to click the correct region wins',
},
{
value: 'turn-based' as const,
emoji: '↔️',
label: 'Turn-Based',
description: 'Take turns finding regions',
},
]
export function SetupPhase() {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@ -111,6 +156,11 @@ export function SetupPhase() {
[setMap, setContinent]
)
// Get selected options for display
const selectedMode = GAME_MODE_OPTIONS.find((opt) => opt.value === state.gameMode)
const selectedAssistance = ASSISTANCE_LEVELS.find((level) => level.id === state.assistanceLevel)
const selectedAssistanceBadges = selectedAssistance ? getFeatureBadges(selectedAssistance) : []
// Calculate total region count for start button
const totalRegionCount = useMemo(() => {
return state.includeSizes.reduce((sum, size) => sum + (regionCountsBySize[size] || 0), 0)
@ -130,6 +180,57 @@ export function SetupPhase() {
return REGION_THEMES[contextLabel] ?? DEFAULT_THEME
}, [contextLabel])
// Card trigger styles - responsive dimensions
// On mobile, full width in vertical stack; on desktop, fixed width in horizontal row
const cardTriggerStyles = css({
display: 'flex',
alignItems: 'center',
gap: { base: '1.5', sm: '3' },
padding: { base: '1.5', sm: '3' },
bg: isDark ? 'gray.700/80' : 'white/80',
rounded: 'xl',
cursor: 'pointer',
transition: 'all 0.15s',
width: { base: '160px', sm: '220px' },
height: { base: '48px', sm: '72px' },
textAlign: 'left',
_hover: {
bg: isDark ? 'gray.600/90' : 'white',
},
_focus: {
outline: 'none',
ring: '2px solid',
ringColor: 'blue.500',
},
})
const contentStyles = css({
bg: isDark ? 'gray.800' : 'white',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
rounded: 'xl',
shadow: 'xl',
overflow: 'hidden',
zIndex: 1000,
minWidth: '280px',
})
const itemStyles = css({
display: 'flex',
alignItems: 'center',
gap: '3',
padding: '3',
cursor: 'pointer',
outline: 'none',
transition: 'all 0.1s',
_hover: {
bg: isDark ? 'gray.700' : 'blue.50',
},
'&[data-state="checked"]': {
bg: isDark ? 'blue.900/50' : 'blue.100',
},
})
return (
<div
data-component="setup-phase"
@ -153,15 +254,272 @@ export function SetupPhase() {
onRegionSizesChange={setRegionSizes}
regionCountsBySize={regionCountsBySize}
fillContainer
gameMode={state.gameMode}
onGameModeChange={setMode}
assistanceLevel={state.assistanceLevel}
onAssistanceLevelChange={setAssistanceLevel}
/>
{/* Start Button - centered at bottom */}
{/* TOP-RIGHT: Settings Panel - positioned like gameplay controls */}
<div
data-element="start-button-container"
data-element="setup-settings-panel"
className={css({
position: 'absolute',
top: { base: '160px', sm: '166px' }, // Same as gameplay controls
right: { base: '2', sm: '4' },
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: '2',
zIndex: 50,
})}
>
<div
className={css({
display: 'flex',
flexDirection: { base: 'column', sm: 'row' },
alignItems: 'stretch',
gap: '2',
padding: '2',
bg: isDark ? 'gray.800/95' : 'gray.100/95',
backdropFilter: 'blur(12px)',
rounded: '2xl',
shadow: 'xl',
maxWidth: { base: '180px', sm: 'fit-content' },
})}
>
{/* Game Mode Selector */}
<Select.Root
value={state.gameMode}
onValueChange={(value) => setMode(value as 'cooperative' | 'race' | 'turn-based')}
>
<Select.Trigger className={cardTriggerStyles}>
<span
className={css({
fontSize: { base: 'lg', sm: '2xl' },
flexShrink: 0,
})}
>
{selectedMode?.emoji}
</span>
<div className={css({ flex: 1, minWidth: 0 })}>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: { base: '1', sm: '2' },
})}
>
<span
className={css({
fontWeight: '600',
fontSize: { base: 'sm', sm: 'md' },
color: isDark ? 'gray.100' : 'gray.800',
})}
>
{selectedMode?.label}
</span>
<Select.Icon
className={css({
color: isDark ? 'gray.400' : 'gray.500',
fontSize: 'xs',
})}
>
</Select.Icon>
</div>
<div
className={css({
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.500',
marginTop: '0.5',
lineHeight: 'tight',
display: { base: 'none', sm: 'block' },
})}
>
{selectedMode?.description}
</div>
</div>
</Select.Trigger>
<Select.Portal>
<Select.Content className={contentStyles} position="popper" sideOffset={5}>
<Select.Viewport>
{GAME_MODE_OPTIONS.map((option) => (
<Select.Item key={option.value} value={option.value} className={itemStyles}>
<span className={css({ fontSize: '2xl' })}>{option.emoji}</span>
<div className={css({ flex: 1 })}>
<Select.ItemText>
<span
className={css({
fontWeight: '600',
fontSize: 'md',
color: isDark ? 'gray.100' : 'gray.900',
})}
>
{option.label}
</span>
</Select.ItemText>
<div
className={css({
fontSize: 'sm',
color: isDark ? 'gray.400' : 'gray.500',
marginTop: '1',
})}
>
{option.description}
</div>
</div>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
{/* Assistance Level Selector */}
<Select.Root
value={state.assistanceLevel}
onValueChange={(value) =>
setAssistanceLevel(value as 'learning' | 'guided' | 'helpful' | 'standard' | 'none')
}
>
<Select.Trigger className={cardTriggerStyles}>
<span
className={css({
fontSize: { base: 'lg', sm: '2xl' },
flexShrink: 0,
})}
>
{selectedAssistance?.emoji || '💡'}
</span>
<div className={css({ flex: 1, minWidth: 0 })}>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: { base: '1', sm: '2' },
})}
>
<span
className={css({
fontWeight: '600',
fontSize: { base: 'sm', sm: 'md' },
color: isDark ? 'gray.100' : 'gray.800',
})}
>
{selectedAssistance?.label}
</span>
<Select.Icon
className={css({
color: isDark ? 'gray.400' : 'gray.500',
fontSize: 'xs',
})}
>
</Select.Icon>
</div>
<div
className={css({
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.500',
marginTop: '0.5',
lineHeight: 'tight',
display: { base: 'none', sm: 'block' },
})}
>
{selectedAssistance?.description}
</div>
{selectedAssistanceBadges.length > 0 && (
<div
className={css({
display: { base: 'none', sm: 'flex' },
gap: '1',
mt: '1.5',
flexWrap: 'wrap',
})}
>
{selectedAssistanceBadges.map((badge) => (
<span
key={badge.label}
className={css({
fontSize: '2xs',
padding: '0.5 1.5',
bg: isDark ? 'gray.600' : 'gray.300',
color: isDark ? 'gray.300' : 'gray.700',
rounded: 'full',
})}
>
{badge.icon} {badge.label}
</span>
))}
</div>
)}
</div>
</Select.Trigger>
<Select.Portal>
<Select.Content className={contentStyles} position="popper" sideOffset={5}>
<Select.Viewport>
{ASSISTANCE_LEVELS.map((level) => {
const badges = getFeatureBadges(level)
return (
<Select.Item key={level.id} value={level.id} className={itemStyles}>
<span className={css({ fontSize: '2xl' })}>{level.emoji}</span>
<div className={css({ flex: 1 })}>
<Select.ItemText>
<span
className={css({
fontWeight: '600',
fontSize: 'md',
color: isDark ? 'gray.100' : 'gray.900',
})}
>
{level.label}
</span>
</Select.ItemText>
<div
className={css({
fontSize: 'sm',
color: isDark ? 'gray.400' : 'gray.500',
marginTop: '1',
})}
>
{level.description}
</div>
{badges.length > 0 && (
<div
className={css({
display: 'flex',
gap: '1',
mt: '2',
flexWrap: 'wrap',
})}
>
{badges.map((badge) => (
<span
key={badge.label}
className={css({
fontSize: 'xs',
padding: '1 2',
bg: isDark ? 'gray.600' : 'gray.200',
color: isDark ? 'gray.300' : 'gray.600',
rounded: 'md',
})}
>
{badge.icon} {badge.label}
</span>
))}
</div>
)}
</div>
</Select.Item>
)
})}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
</div>
{/* BOTTOM-CENTER: Start Button - positioned prominently */}
<div
data-element="setup-start-container"
className={css({
position: 'absolute',
bottom: { base: '2', sm: '4' },
@ -181,10 +539,8 @@ export function SetupPhase() {
justifyContent: 'center',
gap: { base: '2', sm: '3' },
padding: { base: '2 4', sm: '3 5' },
width: { base: '100%', sm: 'auto' },
minWidth: { base: 'auto', sm: '220px' },
flex: { base: 'none', sm: 1 },
height: { base: '56px', sm: '88px' },
width: { base: '280px', sm: '320px' },
height: { base: '56px', sm: '80px' },
fontSize: { base: 'md', sm: 'lg' },
fontWeight: 'bold',
color: 'white',