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:
Thomas Hallock
2025-11-02 12:33:02 -06:00
parent 03571d1ddc
commit b314740697
5 changed files with 238 additions and 125 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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({

View File

@@ -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>
)
}

View File

@@ -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