feat(rithmomachia): make guide modal ultra-responsive down to 150px width

The guide modal is now usable at extremely narrow widths for better screen
real estate management while playing.

Responsive breakpoints:
- Very narrow (<250px): Vertical icon-only navigation, minimal header, tiny padding
- Narrow (250-400px): Horizontal icon + first word, compact layout, scroll indicators
- Medium (400-600px): Full labels, normal spacing
- Wide (>600px): Full layout with all features

Key changes:
- Minimum width reduced from 450px to 150px
- Minimum height reduced from 600px to 300px
- Navigation switches to vertical icon-only layout when very narrow
- Header collapses: hide title, smaller buttons, minimal padding
- Bust-out button hidden when very narrow
- Content padding and font sizes scale with width
- Added fade indicators on tab scroll edges
- All sizing now responds to modal width (or window width if standalone)

Layout modes:
- Very narrow: Icon-only vertical tabs, no header text, 8px padding
- Narrow: Abbreviated labels, 12px padding, scroll indicators
- Medium: Full labels, 24px padding, standard layout

This allows the guide to be positioned as a narrow sidebar while still
being fully functional.

🤖 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 07:24:23 -06:00
parent 497a96d8ea
commit 04741971b2
1 changed files with 160 additions and 82 deletions

View File

@ -28,6 +28,7 @@ export function PlayingGuideModal({ isOpen, onClose, standalone = false }: Playi
const [position, setPosition] = useState({ x: 0, y: 0 })
const [size, setSize] = useState({ width: 800, height: 600 })
const [isDragging, setIsDragging] = useState(false)
const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 800)
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
const [isResizing, setIsResizing] = useState(false)
const [resizeDirection, setResizeDirection] = useState<string>('')
@ -35,6 +36,13 @@ export function PlayingGuideModal({ isOpen, onClose, standalone = false }: Playi
const [isHovered, setIsHovered] = useState(false)
const modalRef = useRef<HTMLDivElement>(null)
// Track window width for responsive behavior
useEffect(() => {
const handleResize = () => setWindowWidth(window.innerWidth)
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
// Center modal on mount (not in standalone mode)
useEffect(() => {
if (isOpen && modalRef.current && !standalone) {
@ -93,21 +101,25 @@ export function PlayingGuideModal({ isOpen, onClose, standalone = false }: Playi
let newY = resizeStart.y
// Handle different resize directions - calculate from initial state
// Ultra-flexible minimum width for narrow layouts
const minWidth = 150
const minHeight = 300
if (resizeDirection.includes('e')) {
newWidth = Math.max(450, Math.min(window.innerWidth * 0.9, resizeStart.width + deltaX))
newWidth = Math.max(minWidth, Math.min(window.innerWidth * 0.9, resizeStart.width + deltaX))
}
if (resizeDirection.includes('w')) {
const desiredWidth = resizeStart.width - deltaX
newWidth = Math.max(450, Math.min(window.innerWidth * 0.9, desiredWidth))
newWidth = Math.max(minWidth, Math.min(window.innerWidth * 0.9, desiredWidth))
// Move left edge by the amount we actually changed width
newX = resizeStart.x + (resizeStart.width - newWidth)
}
if (resizeDirection.includes('s')) {
newHeight = Math.max(600, Math.min(window.innerHeight * 0.8, resizeStart.height + deltaY))
newHeight = Math.max(minHeight, Math.min(window.innerHeight * 0.9, resizeStart.height + deltaY))
}
if (resizeDirection.includes('n')) {
const desiredHeight = resizeStart.height - deltaY
newHeight = Math.max(600, Math.min(window.innerHeight * 0.8, desiredHeight))
newHeight = Math.max(minHeight, Math.min(window.innerHeight * 0.9, desiredHeight))
// Move top edge by the amount we actually changed height
newY = resizeStart.y + (resizeStart.height - newHeight)
}
@ -144,6 +156,12 @@ export function PlayingGuideModal({ isOpen, onClose, standalone = false }: Playi
{ id: 'victory', label: t('sections.victory'), icon: '👑' },
]
// Determine layout mode based on modal width (or window width if standalone)
const effectiveWidth = standalone ? windowWidth : size.width
const isVeryNarrow = effectiveWidth < 250
const isNarrow = effectiveWidth < 400
const isMedium = effectiveWidth < 600
const renderResizeHandles = () => {
if (!isHovered || window.innerWidth < 768 || standalone) return null
@ -276,17 +294,15 @@ export function PlayingGuideModal({ isOpen, onClose, standalone = false }: Playi
<div
ref={modalRef}
data-component="playing-guide-modal"
className={css({
style={{
position: 'fixed',
bg: 'white',
borderRadius: standalone ? 0 : '12px',
background: 'white',
borderRadius: standalone ? 0 : isVeryNarrow ? '8px' : '12px',
boxShadow: standalone ? 'none' : '0 20px 60px rgba(0, 0, 0, 0.3)',
border: standalone ? 'none' : '1px solid #e5e7eb',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
})}
style={{
...(standalone
? { top: 0, left: 0, width: '100vw', height: '100vh', zIndex: 1 }
: {
@ -311,52 +327,53 @@ export function PlayingGuideModal({ isOpen, onClose, standalone = false }: Playi
className={css({
bg: '#f9fafb',
borderBottom: '1px solid #e5e7eb',
p: '24px',
userSelect: 'none',
flexShrink: 0,
position: 'relative',
})}
onMouseDown={handleMouseDown}
style={{
padding: isVeryNarrow ? '8px' : isNarrow ? '12px' : '24px',
cursor: isDragging
? 'grabbing'
: !standalone && window.innerWidth >= 768
? 'grab'
: 'default',
}}
onMouseDown={handleMouseDown}
>
{/* Close and utility buttons - top right */}
<div
className={css({
style={{
position: 'absolute',
top: '16px',
right: '16px',
top: isVeryNarrow ? '4px' : '8px',
right: isVeryNarrow ? '4px' : '8px',
display: 'flex',
alignItems: 'center',
gap: '8px',
})}
gap: isVeryNarrow ? '4px' : '8px',
}}
>
{/* Bust-out button (only if not already standalone) */}
{!standalone && (
{/* Bust-out button (only if not already standalone and not very narrow) */}
{!standalone && !isVeryNarrow && (
<button
type="button"
data-action="bust-out-guide"
onClick={handleBustOut}
className={css({
bg: '#e5e7eb',
style={{
background: '#e5e7eb',
color: '#374151',
border: 'none',
borderRadius: '6px',
width: '32px',
height: '32px',
borderRadius: isVeryNarrow ? '4px' : '6px',
width: isVeryNarrow ? '24px' : '32px',
height: isVeryNarrow ? '24px' : '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: '16px',
fontSize: isVeryNarrow ? '12px' : '16px',
transition: 'background 0.2s',
_hover: { bg: '#d1d5db' },
})}
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#d1d5db')}
onMouseLeave={(e) => (e.currentTarget.style.background = '#e5e7eb')}
title={t('bustOut')}
>
@ -368,103 +385,164 @@ export function PlayingGuideModal({ isOpen, onClose, standalone = false }: Playi
type="button"
data-action="close-guide"
onClick={onClose}
className={css({
bg: '#e5e7eb',
style={{
background: '#e5e7eb',
color: '#374151',
border: 'none',
borderRadius: '6px',
width: '32px',
height: '32px',
borderRadius: isVeryNarrow ? '4px' : '6px',
width: isVeryNarrow ? '24px' : '32px',
height: isVeryNarrow ? '24px' : '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: '18px',
fontSize: isVeryNarrow ? '14px' : '18px',
transition: 'background 0.2s',
_hover: { bg: '#d1d5db' },
})}
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#d1d5db')}
onMouseLeave={(e) => (e.currentTarget.style.background = '#e5e7eb')}
>
</button>
</div>
{/* Centered title and subtitle */}
<div className={css({ textAlign: 'center' })}>
<h1
className={css({
fontSize: { base: '24px', md: '28px' },
fontWeight: 'bold',
color: '#111827',
mb: '8px',
})}
>
{t('title')}
</h1>
<p
className={css({
fontSize: { base: '14px', md: '16px' },
color: '#6b7280',
mb: '16px',
})}
>
{t('subtitle')}
</p>
</div>
{/* Centered title and subtitle - hide when very narrow */}
{!isVeryNarrow && (
<div style={{ textAlign: 'center' }}>
<h1
style={{
fontSize: isNarrow ? '16px' : isMedium ? '20px' : '28px',
fontWeight: 'bold',
color: '#111827',
marginBottom: isNarrow ? '4px' : '8px',
lineHeight: 1.2,
}}
>
{t('title')}
</h1>
{!isNarrow && (
<p
style={{
fontSize: isMedium ? '12px' : '16px',
color: '#6b7280',
marginBottom: isMedium ? '8px' : '16px',
lineHeight: 1.3,
}}
>
{t('subtitle')}
</p>
)}
</div>
)}
</div>
{/* Navigation Tabs */}
{/* Navigation Tabs - ultra responsive with scroll indicators */}
<div
data-element="guide-nav"
className={css({
display: 'flex',
borderBottom: '2px solid #e5e7eb',
bg: '#f9fafb',
overflow: 'auto',
style={{
position: 'relative',
flexShrink: 0,
})}
}}
>
<div
data-element="guide-nav"
style={{
display: 'flex',
flexDirection: isVeryNarrow ? 'column' : 'row',
borderBottom: isVeryNarrow ? 'none' : '2px solid #e5e7eb',
borderRight: isVeryNarrow ? '2px solid #e5e7eb' : 'none',
background: '#f9fafb',
overflow: 'auto',
flexShrink: 0,
// Show scrollbar on narrow widths to indicate more content
scrollbarWidth: isNarrow && !isVeryNarrow ? 'thin' : 'auto',
}}
>
{sections.map((section) => (
<button
key={section.id}
type="button"
data-action={`navigate-${section.id}`}
onClick={() => setActiveSection(section.id)}
className={css({
flex: 1,
minWidth: 'fit-content',
p: { base: '12px 16px', md: '14px 20px' },
fontSize: { base: '13px', md: '14px' },
style={{
flex: isVeryNarrow ? 'none' : 1,
minWidth: isVeryNarrow ? 'auto' : 'fit-content',
padding: isVeryNarrow ? '8px 4px' : isNarrow ? '10px 8px' : '14px 20px',
fontSize: isVeryNarrow ? '18px' : isNarrow ? '12px' : '14px',
fontWeight: activeSection === section.id ? 'bold' : '500',
color: activeSection === section.id ? '#7c2d12' : '#6b7280',
bg: activeSection === section.id ? 'white' : 'transparent',
borderBottom: '3px solid',
background: activeSection === section.id ? 'white' : 'transparent',
borderBottom: isVeryNarrow ? 'none' : '3px solid',
borderRight: isVeryNarrow ? '3px solid' : 'none',
borderBottomColor: activeSection === section.id ? '#7c2d12' : 'transparent',
borderRightColor: activeSection === section.id ? '#7c2d12' : 'transparent',
cursor: 'pointer',
transition: 'all 0.2s',
border: 'none',
borderBottom: isVeryNarrow ? 'none' : `3px solid ${activeSection === section.id ? '#7c2d12' : 'transparent'}`,
borderRight: isVeryNarrow ? `3px solid ${activeSection === section.id ? '#7c2d12' : 'transparent'}` : 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
_hover: {
bg: activeSection === section.id ? 'white' : '#f3f4f6',
},
})}
justifyContent: isVeryNarrow ? 'center' : 'center',
gap: isVeryNarrow ? '0' : isNarrow ? '4px' : '6px',
lineHeight: 1,
}}
onMouseEnter={(e) => {
if (activeSection !== section.id) {
e.currentTarget.style.background = '#f3f4f6'
}
}}
onMouseLeave={(e) => {
if (activeSection !== section.id) {
e.currentTarget.style.background = 'transparent'
}
}}
title={isVeryNarrow ? section.label : undefined}
>
<span>{section.icon}</span>
<span>{section.label}</span>
<span style={{ fontSize: isVeryNarrow ? '20px' : 'inherit' }}>{section.icon}</span>
{!isVeryNarrow && <span>{isNarrow ? section.label.split(' ')[0] : section.label}</span>}
</button>
))}
</div>
{/* Fade indicators for horizontal scroll (when not very narrow) */}
{!isVeryNarrow && isNarrow && (
<>
<div
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '20px',
background: 'linear-gradient(to right, rgba(249, 250, 251, 0.95), transparent)',
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: '20px',
background: 'linear-gradient(to left, rgba(249, 250, 251, 0.95), transparent)',
pointerEvents: 'none',
}}
/>
</>
)}
</div>
{/* Content */}
<div
data-element="guide-content"
className={css({
style={{
flex: 1,
overflow: 'auto',
p: '24px',
})}
padding: isVeryNarrow ? '8px' : isNarrow ? '12px' : '24px',
fontSize: isVeryNarrow ? '12px' : isNarrow ? '13px' : '14px',
lineHeight: 1.5,
}}
>
{activeSection === 'overview' && (
<OverviewSection useNativeAbacusNumbers={useNativeAbacusNumbers} />