feat(rithmomachia): improve guide UX and add persistence
UI/UX Improvements: - Bust-out button now closes the modal version when opening guide in new window - Guide button is hidden when guide is already open to avoid confusion - Replaced undock button with intuitive drag-to-undock gesture (drag title bar away from edge) - Guide now supports dragging even when docked, with 50px threshold before undocking - Improved cursor feedback (grab/grabbing) for docked guide Persistence: - Guide modal position and size are saved to localStorage - Docked state (docked/floating) is saved to localStorage - Dock side preference (left/right) is saved to localStorage - Guide reopens in the same state and position as when it was last closed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -39,8 +39,34 @@ export function PlayingGuideModal({
|
||||
const useNativeAbacusNumbers = abacusSettings?.nativeAbacusNumbers ?? false
|
||||
|
||||
const [activeSection, setActiveSection] = useState<Section>('overview')
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||
const [size, setSize] = useState({ width: 800, height: 600 })
|
||||
|
||||
// Load saved position and size from localStorage
|
||||
const [position, setPosition] = useState<{ x: number; y: number }>(() => {
|
||||
if (typeof window === 'undefined') return { x: 0, y: 0 }
|
||||
const saved = localStorage.getItem('rithmomachia-guide-position')
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved)
|
||||
} catch {
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
}
|
||||
return { x: 0, y: 0 }
|
||||
})
|
||||
|
||||
const [size, setSize] = useState<{ width: number; height: number }>(() => {
|
||||
if (typeof window === 'undefined') return { width: 800, height: 600 }
|
||||
const saved = localStorage.getItem('rithmomachia-guide-size')
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved)
|
||||
} catch {
|
||||
return { width: 800, height: 600 }
|
||||
}
|
||||
}
|
||||
return { width: 800, height: 600 }
|
||||
})
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [windowWidth, setWindowWidth] = useState(
|
||||
typeof window !== 'undefined' ? window.innerWidth : 800
|
||||
@@ -53,6 +79,20 @@ export function PlayingGuideModal({
|
||||
const [dockPreview, setDockPreview] = useState<'left' | 'right' | null>(null)
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Save position to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
if (!docked && !standalone) {
|
||||
localStorage.setItem('rithmomachia-guide-position', JSON.stringify(position))
|
||||
}
|
||||
}, [position, docked, standalone])
|
||||
|
||||
// Save size to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
if (!docked && !standalone) {
|
||||
localStorage.setItem('rithmomachia-guide-size', JSON.stringify(size))
|
||||
}
|
||||
}, [size, docked, standalone])
|
||||
|
||||
// Debug logging for props
|
||||
useEffect(() => {
|
||||
console.log('[PlayingGuideModal] Component rendered/props changed', {
|
||||
@@ -89,14 +129,24 @@ export function PlayingGuideModal({
|
||||
standalone,
|
||||
docked,
|
||||
hasOnDock: !!onDock,
|
||||
hasOnUndock: !!onUndock,
|
||||
})
|
||||
if (window.innerWidth < 768 || standalone) return // No dragging on mobile or standalone
|
||||
console.log('[PlayingGuideModal] Starting drag')
|
||||
setIsDragging(true)
|
||||
setDragStart({
|
||||
x: e.clientX - position.x,
|
||||
y: e.clientY - position.y,
|
||||
})
|
||||
|
||||
// When docked, we need to track the initial mouse position for undocking
|
||||
if (docked) {
|
||||
setDragStart({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
})
|
||||
} else {
|
||||
setDragStart({
|
||||
x: e.clientX - position.x,
|
||||
y: e.clientY - position.y,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle resize start
|
||||
@@ -115,29 +165,57 @@ export function PlayingGuideModal({
|
||||
const url = `${window.location.origin}/arcade/rithmomachia/guide`
|
||||
const features = 'width=600,height=800,menubar=no,toolbar=no,location=no,status=no'
|
||||
window.open(url, 'RithmomachiaGuide', features)
|
||||
onClose() // Close the modal version after opening in new window
|
||||
}
|
||||
|
||||
// Mouse move effect for dragging and resizing
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
setPosition({
|
||||
x: e.clientX - dragStart.x,
|
||||
y: e.clientY - dragStart.y,
|
||||
})
|
||||
// When docked, check if we've dragged far enough away to undock
|
||||
if (docked && onUndock) {
|
||||
const UNDOCK_THRESHOLD = 50 // pixels to drag before undocking
|
||||
const dragDistance = Math.sqrt(
|
||||
(e.clientX - dragStart.x) ** 2 + (e.clientY - dragStart.y) ** 2
|
||||
)
|
||||
|
||||
// Check if we're near edges for docking preview
|
||||
if (onDock && onDockPreview && !docked) {
|
||||
const DOCK_THRESHOLD = 100
|
||||
if (e.clientX < DOCK_THRESHOLD) {
|
||||
setDockPreview('left')
|
||||
onDockPreview('left')
|
||||
} else if (e.clientX > window.innerWidth - DOCK_THRESHOLD) {
|
||||
setDockPreview('right')
|
||||
onDockPreview('right')
|
||||
} else {
|
||||
setDockPreview(null)
|
||||
onDockPreview(null)
|
||||
if (dragDistance > UNDOCK_THRESHOLD) {
|
||||
console.log('[PlayingGuideModal] Undocking due to drag distance:', dragDistance)
|
||||
onUndock()
|
||||
// After undocking, set up position for continued dragging as floating modal
|
||||
// Center the modal at the current mouse position
|
||||
if (modalRef.current) {
|
||||
const rect = modalRef.current.getBoundingClientRect()
|
||||
setPosition({
|
||||
x: e.clientX - rect.width / 2,
|
||||
y: e.clientY - 20, // Offset slightly from cursor
|
||||
})
|
||||
setDragStart({
|
||||
x: e.clientX - (e.clientX - rect.width / 2),
|
||||
y: e.clientY - (e.clientY - 20),
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal floating modal dragging
|
||||
setPosition({
|
||||
x: e.clientX - dragStart.x,
|
||||
y: e.clientY - dragStart.y,
|
||||
})
|
||||
|
||||
// Check if we're near edges for docking preview
|
||||
if (onDock && onDockPreview && !docked) {
|
||||
const DOCK_THRESHOLD = 100
|
||||
if (e.clientX < DOCK_THRESHOLD) {
|
||||
setDockPreview('left')
|
||||
onDockPreview('left')
|
||||
} else if (e.clientX > window.innerWidth - DOCK_THRESHOLD) {
|
||||
setDockPreview('right')
|
||||
onDockPreview('right')
|
||||
} else {
|
||||
setDockPreview(null)
|
||||
onDockPreview(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (isResizing) {
|
||||
@@ -247,7 +325,17 @@ export function PlayingGuideModal({
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [isDragging, isResizing, dragStart, resizeDirection, resizeStart])
|
||||
}, [
|
||||
isDragging,
|
||||
isResizing,
|
||||
dragStart,
|
||||
resizeDirection,
|
||||
resizeStart,
|
||||
docked,
|
||||
onUndock,
|
||||
onDock,
|
||||
onDockPreview,
|
||||
])
|
||||
|
||||
if (!isOpen && !standalone && !docked) return null
|
||||
|
||||
@@ -444,14 +532,13 @@ export function PlayingGuideModal({
|
||||
})}
|
||||
style={{
|
||||
padding: isVeryNarrow ? '8px' : isNarrow ? '12px' : '24px',
|
||||
cursor:
|
||||
isDragging && !docked
|
||||
? 'grabbing'
|
||||
: !standalone && !docked && window.innerWidth >= 768
|
||||
? 'grab'
|
||||
: 'default',
|
||||
cursor: isDragging
|
||||
? 'grabbing'
|
||||
: !standalone && window.innerWidth >= 768
|
||||
? 'grab'
|
||||
: 'default',
|
||||
}}
|
||||
onMouseDown={docked ? undefined : handleMouseDown}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{/* Close and utility buttons - top right */}
|
||||
<div
|
||||
@@ -464,33 +551,6 @@ export function PlayingGuideModal({
|
||||
gap: isVeryNarrow ? '4px' : '8px',
|
||||
}}
|
||||
>
|
||||
{/* Undock button (only when docked) */}
|
||||
{docked && onUndock && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="undock-guide"
|
||||
onClick={onUndock}
|
||||
style={{
|
||||
background: '#e5e7eb',
|
||||
color: '#374151',
|
||||
border: 'none',
|
||||
borderRadius: isVeryNarrow ? '4px' : '6px',
|
||||
width: isVeryNarrow ? '24px' : '32px',
|
||||
height: isVeryNarrow ? '24px' : '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
fontSize: isVeryNarrow ? '12px' : '16px',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#d1d5db')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = '#e5e7eb')}
|
||||
title="Undock guide (return to floating mode)"
|
||||
>
|
||||
⤴️
|
||||
</button>
|
||||
)}
|
||||
{/* Bust-out button (only if not already standalone/docked and not very narrow) */}
|
||||
{!standalone && !docked && !isVeryNarrow && (
|
||||
<button
|
||||
|
||||
@@ -38,9 +38,21 @@ export function RithmomachiaGame() {
|
||||
const { setFullscreenElement } = useFullscreen()
|
||||
const gameRef = useRef<HTMLDivElement>(null)
|
||||
const rosterWarning = useRosterWarning(state.gamePhase === 'setup' ? 'setup' : 'playing')
|
||||
|
||||
// Load saved guide preferences from localStorage
|
||||
const [guideDocked, setGuideDocked] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
const saved = localStorage.getItem('rithmomachia-guide-docked')
|
||||
return saved === 'true'
|
||||
})
|
||||
|
||||
const [guideDockSide, setGuideDockSide] = useState<'left' | 'right'>(() => {
|
||||
if (typeof window === 'undefined') return 'right'
|
||||
const saved = localStorage.getItem('rithmomachia-guide-dock-side')
|
||||
return saved === 'left' || saved === 'right' ? saved : 'right'
|
||||
})
|
||||
|
||||
const [isGuideOpen, setIsGuideOpen] = useState(false)
|
||||
const [guideDocked, setGuideDocked] = useState(false)
|
||||
const [guideDockSide, setGuideDockSide] = useState<'left' | 'right'>('right')
|
||||
const [dockPreviewSide, setDockPreviewSide] = useState<'left' | 'right' | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,6 +62,16 @@ export function RithmomachiaGame() {
|
||||
}
|
||||
}, [setFullscreenElement])
|
||||
|
||||
// Save guide docked state to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('rithmomachia-guide-docked', String(guideDocked))
|
||||
}, [guideDocked])
|
||||
|
||||
// Save guide dock side to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('rithmomachia-guide-dock-side', guideDockSide)
|
||||
}, [guideDockSide])
|
||||
|
||||
// Debug logging for state changes
|
||||
useEffect(() => {
|
||||
console.log('[RithmomachiaGame] State changed', {
|
||||
@@ -97,9 +119,29 @@ export function RithmomachiaGame() {
|
||||
const handleOpenGuide = () => {
|
||||
console.log('[RithmomachiaGame] handleOpenGuide called')
|
||||
setIsGuideOpen(true)
|
||||
setGuideDocked(true) // Default to docked on right
|
||||
setGuideDockSide('right')
|
||||
console.log('[RithmomachiaGame] Guide opened in docked right position')
|
||||
|
||||
// Use saved preferences if available, otherwise default to docked on right
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedDocked = localStorage.getItem('rithmomachia-guide-docked')
|
||||
const savedSide = localStorage.getItem('rithmomachia-guide-dock-side')
|
||||
|
||||
if (savedDocked !== null) {
|
||||
const isDocked = savedDocked === 'true'
|
||||
setGuideDocked(isDocked)
|
||||
if (isDocked && savedSide) {
|
||||
setGuideDockSide(savedSide === 'left' || savedSide === 'right' ? savedSide : 'right')
|
||||
}
|
||||
console.log('[RithmomachiaGame] Guide opened with saved preferences', {
|
||||
docked: isDocked,
|
||||
side: savedSide,
|
||||
})
|
||||
} else {
|
||||
// First time opening - default to docked on right
|
||||
setGuideDocked(true)
|
||||
setGuideDockSide('right')
|
||||
console.log('[RithmomachiaGame] Guide opened in default docked right position')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDock = (side: 'left' | 'right') => {
|
||||
@@ -152,8 +194,12 @@ export function RithmomachiaGame() {
|
||||
height: '100%',
|
||||
})}
|
||||
>
|
||||
{state.gamePhase === 'setup' && <SetupPhase onOpenGuide={handleOpenGuide} />}
|
||||
{state.gamePhase === 'playing' && <PlayingPhase onOpenGuide={handleOpenGuide} />}
|
||||
{state.gamePhase === 'setup' && (
|
||||
<SetupPhase onOpenGuide={handleOpenGuide} isGuideOpen={isGuideOpen} />
|
||||
)}
|
||||
{state.gamePhase === 'playing' && (
|
||||
<PlayingPhase onOpenGuide={handleOpenGuide} isGuideOpen={isGuideOpen} />
|
||||
)}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,10 @@ import { BoardDisplay } from '../board/BoardDisplay'
|
||||
|
||||
export interface PlayingPhaseProps {
|
||||
onOpenGuide: () => void
|
||||
isGuideOpen: boolean
|
||||
}
|
||||
|
||||
export function PlayingPhase({ onOpenGuide }: PlayingPhaseProps) {
|
||||
export function PlayingPhase({ onOpenGuide, isGuideOpen }: PlayingPhaseProps) {
|
||||
const { state, isMyTurn, lastError, clearError, rosterStatus } = useRithmomachia()
|
||||
|
||||
// Get abacus settings for native abacus numbers
|
||||
@@ -77,33 +78,35 @@ export function PlayingPhase({ onOpenGuide }: PlayingPhaseProps) {
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', gap: '2', alignItems: 'center' })}>
|
||||
<button
|
||||
type="button"
|
||||
data-action="open-guide-playing"
|
||||
onClick={onOpenGuide}
|
||||
className={css({
|
||||
px: '3',
|
||||
py: '1',
|
||||
bg: 'linear-gradient(135deg, #7c2d12, #92400e)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(251, 191, 36, 0.6)',
|
||||
borderRadius: 'md',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
bg: 'linear-gradient(135deg, #92400e, #7c2d12)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>📖</span>
|
||||
<span>Guide</span>
|
||||
</button>
|
||||
{!isGuideOpen && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="open-guide-playing"
|
||||
onClick={onOpenGuide}
|
||||
className={css({
|
||||
px: '3',
|
||||
py: '1',
|
||||
bg: 'linear-gradient(135deg, #7c2d12, #92400e)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(251, 191, 36, 0.6)',
|
||||
borderRadius: 'md',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
bg: 'linear-gradient(135deg, #92400e, #7c2d12)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>📖</span>
|
||||
<span>Guide</span>
|
||||
</button>
|
||||
)}
|
||||
{isMyTurn && (
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -2,9 +2,10 @@ import { css } from '../../../../../styled-system/css'
|
||||
|
||||
export interface SetupHeaderProps {
|
||||
onOpenGuide: () => void
|
||||
isGuideOpen: boolean
|
||||
}
|
||||
|
||||
export function SetupHeader({ onOpenGuide }: SetupHeaderProps) {
|
||||
export function SetupHeader({ onOpenGuide, isGuideOpen }: SetupHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
data-element="title-section"
|
||||
@@ -118,36 +119,38 @@ export function SetupHeader({ onOpenGuide }: SetupHeaderProps) {
|
||||
>
|
||||
Win by forming mathematical progressions in enemy territory
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-action="open-guide"
|
||||
onClick={onOpenGuide}
|
||||
className={css({
|
||||
bg: 'linear-gradient(135deg, #7c2d12, #92400e)',
|
||||
color: 'white',
|
||||
border: '2px solid rgba(251, 191, 36, 0.6)',
|
||||
borderRadius: '0.8vh',
|
||||
px: '1.5vh',
|
||||
py: '0.8vh',
|
||||
fontSize: '1.3vh',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5vh',
|
||||
mx: 'auto',
|
||||
boxShadow: '0 0.3vh 0.8vh rgba(0, 0, 0, 0.3)',
|
||||
_hover: {
|
||||
bg: 'linear-gradient(135deg, #92400e, #7c2d12)',
|
||||
transform: 'translateY(-0.2vh)',
|
||||
boxShadow: '0 0.5vh 1.2vh rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>📖</span>
|
||||
<span>How to Play</span>
|
||||
</button>
|
||||
{!isGuideOpen && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="open-guide"
|
||||
onClick={onOpenGuide}
|
||||
className={css({
|
||||
bg: 'linear-gradient(135deg, #7c2d12, #92400e)',
|
||||
color: 'white',
|
||||
border: '2px solid rgba(251, 191, 36, 0.6)',
|
||||
borderRadius: '0.8vh',
|
||||
px: '1.5vh',
|
||||
py: '0.8vh',
|
||||
fontSize: '1.3vh',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5vh',
|
||||
mx: 'auto',
|
||||
boxShadow: '0 0.3vh 0.8vh rgba(0, 0, 0, 0.3)',
|
||||
_hover: {
|
||||
bg: 'linear-gradient(135deg, #92400e, #7c2d12)',
|
||||
transform: 'translateY(-0.2vh)',
|
||||
boxShadow: '0 0.5vh 1.2vh rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>📖</span>
|
||||
<span>How to Play</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@ import { StartButton } from './StartButton'
|
||||
|
||||
export interface SetupPhaseProps {
|
||||
onOpenGuide: () => void
|
||||
isGuideOpen: boolean
|
||||
}
|
||||
|
||||
export function SetupPhase({ onOpenGuide }: SetupPhaseProps) {
|
||||
export function SetupPhase({ onOpenGuide, isGuideOpen }: SetupPhaseProps) {
|
||||
const { state, startGame, setConfig, lastError, clearError, rosterStatus } = useRithmomachia()
|
||||
const { players: playerMap, activePlayers: activePlayerIds, addPlayer, setActive } = useGameMode()
|
||||
const startDisabled = rosterStatus.status !== 'ok'
|
||||
@@ -167,7 +168,7 @@ export function SetupPhase({ onOpenGuide }: SetupPhaseProps) {
|
||||
{/* Only show setup config when we have enough players */}
|
||||
{rosterStatus.status !== 'tooFew' && (
|
||||
<>
|
||||
<SetupHeader onOpenGuide={onOpenGuide} />
|
||||
<SetupHeader onOpenGuide={onOpenGuide} isGuideOpen={isGuideOpen} />
|
||||
|
||||
{/* Game Settings - Compact with flex: 1 to take remaining space */}
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user