feat: add responsive mobile drawer with draggable settings button

Implement a mobile-friendly settings interface for the worksheet generator:

**Mobile Layout (< 768px):**
- Full-screen worksheet preview
- Floating draggable settings button showing current config summary
- Settings drawer slides in from left (90% width, max 400px)
- Swipe-left gesture, backdrop click, or Escape key to close

**Desktop Layout (>= 768px):**
- Keeps existing resizable panel layout with grab tab
- No changes to desktop UX

**Draggable Settings Button:**
- Drag anywhere on screen with safe 16px margins
- Never overlaps nav bar or action buttons (constrained to safe zone)
- Position persists in localStorage
- Visual feedback: grab/grabbing cursor, elevated shadow while dragging
- Smart click detection: only opens drawer on click, not after drag

**Settings Summary:**
- Shows human-readable config with icons (📄🎨🎯)
- Multi-line format: operator, layout, scaffolding, difficulty
- Updates live as settings change

**New Components:**
- useMediaQuery hook for responsive breakpoint detection
- MobileDrawer with backdrop and animations
- MobileSettingsButton with drag-and-drop
- ResponsivePanelLayout wrapper (conditionally renders mobile or desktop)
- generateSettingsSummary utility with icon system

**Integration:**
- AdditionWorksheetClient now uses ResponsivePanelLayout
- Single codebase handles both mobile and desktop seamlessly
- No breaking changes to existing desktop functionality

🤖 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-12 13:15:52 -06:00
parent a40fa809ef
commit fc1d7fcbd6
7 changed files with 794 additions and 133 deletions

View File

@ -1,14 +1,9 @@
{
"permissions": {
"allow": [
"WebFetch(domain:github.com)",
"WebFetch(domain:react-resizable-panels.vercel.app)"
],
"allow": ["WebFetch(domain:github.com)", "WebFetch(domain:react-resizable-panels.vercel.app)"],
"deny": [],
"ask": []
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"sqlite"
]
"enabledMcpjsonServers": ["sqlite"]
}

View File

@ -4,7 +4,6 @@ import { css } from '@styled/css'
import { useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { useEffect, useState } from 'react'
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'
import type { WorksheetFormState } from '@/app/create/worksheets/types'
import { PageWithNav } from '@/components/PageWithNav'
import { useTheme } from '@/contexts/ThemeContext'
@ -15,6 +14,7 @@ import { getDefaultDate } from '../utils/dateFormatting'
import { ConfigSidebar } from './ConfigSidebar'
import { GenerationErrorDisplay } from './GenerationErrorDisplay'
import { PreviewCenter } from './PreviewCenter'
import { ResponsivePanelLayout } from './ResponsivePanelLayout'
import { WorksheetConfigProvider } from './WorksheetConfigContext'
interface AdditionWorksheetClientProps {
@ -77,25 +77,6 @@ export function AdditionWorksheetClient({
})
}
// Resize handle - 36px wide but overlaps preview by 28px
const resizeHandleStyles = css({
width: '36px',
marginRight: '-28px',
height: '100%',
position: 'relative',
cursor: 'col-resize',
zIndex: 10,
})
// Visual appearance: thin divider + grab tab
const handleVisualStyles = css({
position: 'absolute',
inset: 0,
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
})
return (
<PageWithNav navTitle={t('navTitle')} navEmoji="📝">
<WorksheetConfigProvider formState={formState} updateFormState={updateFormState}>
@ -110,118 +91,19 @@ export function AdditionWorksheetClient({
flexDirection: 'column',
})}
>
{/* Resizable Panel Layout */}
<PanelGroup
direction="horizontal"
autoSaveId="worksheet-generator-layout"
className={css({ flex: '1', minHeight: '0' })}
>
{/* Left Panel: Config Sidebar */}
<Panel defaultSize={25} minSize={20} maxSize={40} collapsible>
<ConfigSidebar isSaving={isSaving} lastSaved={lastSaved} />
</Panel>
<PanelResizeHandle className={resizeHandleStyles}>
<div className={handleVisualStyles}>
{/* Thin divider (8px, full height) */}
<div
className={css({
width: '8px',
height: '100%',
bg: isDark ? 'gray.700' : 'gray.300',
position: 'relative',
transition: 'background-color 0.2s',
_groupHover: {
bg: isDark ? 'blue.600' : 'blue.300',
},
})}
>
{/* Grip dots on divider */}
<div
className={css({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
display: 'flex',
flexDirection: 'column',
gap: '4px',
})}
>
<div
className={css({
width: '3px',
height: '3px',
borderRadius: 'full',
bg: isDark ? 'gray.500' : 'gray.500',
})}
/>
<div
className={css({
width: '3px',
height: '3px',
borderRadius: 'full',
bg: isDark ? 'gray.500' : 'gray.500',
})}
/>
<div
className={css({
width: '3px',
height: '3px',
borderRadius: 'full',
bg: isDark ? 'gray.500' : 'gray.500',
})}
/>
</div>
</div>
{/* Grab tab (28px wide, 64px tall, centered vertically) */}
<div
className={css({
width: '28px',
height: '64px',
bg: isDark ? 'gray.600' : 'gray.400',
borderTopRightRadius: '8px',
borderBottomRightRadius: '8px',
position: 'relative',
transition: 'background-color 0.2s',
overflow: 'hidden',
_groupHover: {
bg: isDark ? 'blue.500' : 'blue.400',
},
})}
>
{/* Knurled texture (vertical ridges) - multiple visible lines */}
{[0, 4, 8, 12, 16, 20, 24].map((offset) => (
<div
key={offset}
className={css({
position: 'absolute',
left: `${offset}px`,
top: 0,
bottom: 0,
width: '2px',
bg: isDark ? 'rgba(255, 255, 255, 0.25)' : 'rgba(0, 0, 0, 0.25)',
boxShadow: isDark
? '1px 0 0 rgba(0, 0, 0, 0.3)'
: '1px 0 0 rgba(255, 255, 255, 0.3)',
})}
/>
))}
</div>
</div>
</PanelResizeHandle>
{/* Center Panel: Preview */}
<Panel defaultSize={75} minSize={60}>
{/* Responsive Panel Layout (desktop) or Drawer (mobile) */}
<ResponsivePanelLayout
config={formState}
sidebarContent={<ConfigSidebar isSaving={isSaving} lastSaved={lastSaved} />}
previewContent={
<PreviewCenter
formState={debouncedFormState}
initialPreview={initialPreview}
onGenerate={handleGenerate}
status={status}
/>
</Panel>
</PanelGroup>
}
/>
{/* Error Display */}
<GenerationErrorDisplay error={error} visible={status === 'error'} onRetry={reset} />

View File

@ -0,0 +1,181 @@
'use client'
import { css } from '@styled/css'
import { useEffect, useRef, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
interface MobileDrawerProps {
isOpen: boolean
onClose: () => void
children: React.ReactNode
}
export function MobileDrawer({ isOpen, onClose, children }: MobileDrawerProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const drawerRef = useRef<HTMLDivElement>(null)
const [touchStart, setTouchStart] = useState<number | null>(null)
const [touchCurrent, setTouchCurrent] = useState<number | null>(null)
// Handle escape key
useEffect(() => {
if (!isOpen) return
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
}
window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}, [isOpen, onClose])
// Prevent body scroll when drawer is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isOpen])
// Touch event handlers for swipe-to-close
const handleTouchStart = (e: React.TouchEvent) => {
setTouchStart(e.touches[0].clientX)
setTouchCurrent(e.touches[0].clientX)
}
const handleTouchMove = (e: React.TouchEvent) => {
if (touchStart === null) return
setTouchCurrent(e.touches[0].clientX)
}
const handleTouchEnd = () => {
if (touchStart === null || touchCurrent === null) {
setTouchStart(null)
setTouchCurrent(null)
return
}
const distance = touchCurrent - touchStart
const threshold = 100 // pixels
// Swipe left to close
if (distance < -threshold) {
onClose()
}
setTouchStart(null)
setTouchCurrent(null)
}
// Calculate transform for swipe animation
const getTransform = () => {
if (touchStart === null || touchCurrent === null) {
return isOpen ? 'translateX(0)' : 'translateX(-100%)'
}
const distance = touchCurrent - touchStart
// Only allow leftward swipes
if (distance < 0) {
return `translateX(${distance}px)`
}
return 'translateX(0)'
}
return (
<>
{/* Backdrop */}
<div
data-component="mobile-drawer-backdrop"
className={css({
position: 'fixed',
inset: 0,
bg: 'rgba(0, 0, 0, 0.5)',
zIndex: 40,
opacity: isOpen ? 1 : 0,
pointerEvents: isOpen ? 'auto' : 'none',
transition: 'opacity 0.3s ease-in-out',
})}
onClick={onClose}
/>
{/* Drawer */}
<div
ref={drawerRef}
data-component="mobile-drawer"
className={css({
position: 'fixed',
top: 0,
left: 0,
bottom: 0,
width: '90%',
maxWidth: '400px',
bg: isDark ? 'gray.800' : 'white',
zIndex: 50,
overflow: 'auto',
boxShadow: '2xl',
transition: touchStart === null ? 'transform 0.3s ease-in-out' : 'none',
})}
style={{
transform: getTransform(),
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Close button */}
<div
className={css({
position: 'sticky',
top: 0,
right: 0,
zIndex: 10,
p: 4,
display: 'flex',
justifyContent: 'flex-end',
bg: isDark ? 'gray.800' : 'white',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<button
data-action="close-mobile-drawer"
onClick={onClose}
className={css({
width: '40px',
height: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
rounded: 'full',
bg: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.300' : 'gray.700',
fontSize: 'xl',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
bg: isDark ? 'gray.600' : 'gray.300',
transform: 'scale(1.05)',
},
_active: {
transform: 'scale(0.95)',
},
})}
aria-label="Close settings"
>
</button>
</div>
{/* Content */}
<div className={css({ p: 4 })}>{children}</div>
</div>
</>
)
}

View File

@ -0,0 +1,226 @@
'use client'
import { css } from '@styled/css'
import { useEffect, useRef, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import type { WorksheetFormState } from '../types'
import { generateSettingsSummary } from '../utils/settingsSummary'
interface MobileSettingsButtonProps {
config: Partial<WorksheetFormState>
onClick: () => void
}
const MARGIN = 16 // Safe margin from viewport edges
const STORAGE_KEY = 'mobile-settings-button-position'
export function MobileSettingsButton({ config, onClick }: MobileSettingsButtonProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const { lines } = generateSettingsSummary(config)
const buttonRef = useRef<HTMLButtonElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [position, setPosition] = useState({ x: MARGIN, y: 0 })
const dragStartRef = useRef<{ x: number; y: number; startX: number; startY: number } | null>(null)
// Load saved position from localStorage
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
try {
const parsed = JSON.parse(saved)
setPosition(parsed)
} catch (e) {
// Ignore invalid JSON
}
} else {
// Default position: below nav and action button
const navHeight = 60 // Approximate --app-nav-height
const actionButtonHeight = 48 // Approximate height of download/action button
setPosition({ x: MARGIN, y: navHeight + actionButtonHeight + MARGIN })
}
}, [])
// Save position to localStorage
const savePosition = (pos: { x: number; y: number }) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(pos))
}
// Constrain position within viewport bounds
const constrainPosition = (x: number, y: number) => {
if (!buttonRef.current) return { x, y }
const rect = buttonRef.current.getBoundingClientRect()
// Calculate bounds
const navHeight = 60 // Approximate --app-nav-height
const actionButtonHeight = 48 // Approximate height of download/action button
const minY = navHeight + actionButtonHeight + MARGIN // Don't go above action button
const maxX = window.innerWidth - rect.width - MARGIN
const maxY = window.innerHeight - rect.height - MARGIN
return {
x: Math.max(MARGIN, Math.min(x, maxX)),
y: Math.max(minY, Math.min(y, maxY)),
}
}
// Handle drag start
const handlePointerDown = (e: React.PointerEvent) => {
// Only drag if clicking the button itself, not when opening
if ((e.target as HTMLElement).closest('[data-summary-line]')) {
return // Allow text selection
}
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
startX: position.x,
startY: position.y,
}
// Capture pointer to receive all events
if (buttonRef.current) {
buttonRef.current.setPointerCapture(e.pointerId)
}
}
// Handle drag move
const handlePointerMove = (e: React.PointerEvent) => {
if (!isDragging || !dragStartRef.current) return
e.preventDefault()
e.stopPropagation()
const deltaX = e.clientX - dragStartRef.current.x
const deltaY = e.clientY - dragStartRef.current.y
const newX = dragStartRef.current.startX + deltaX
const newY = dragStartRef.current.startY + deltaY
const constrained = constrainPosition(newX, newY)
setPosition(constrained)
}
// Handle drag end
const handlePointerUp = (e: React.PointerEvent) => {
if (!isDragging) return
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
dragStartRef.current = null
// Release pointer capture
if (buttonRef.current) {
buttonRef.current.releasePointerCapture(e.pointerId)
}
// Save final position
savePosition(position)
}
// Handle click (only if not dragged)
const handleClick = (e: React.MouseEvent) => {
if (isDragging) {
e.preventDefault()
e.stopPropagation()
return
}
onClick()
}
return (
<button
ref={buttonRef}
data-action="open-mobile-settings"
onClick={handleClick}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
style={{
left: `${position.x}px`,
top: `${position.y}px`,
touchAction: 'none', // Prevent default touch behaviors
}}
className={css({
position: 'fixed',
zIndex: 30,
bg: isDark ? 'gray.800' : 'white',
color: isDark ? 'gray.100' : 'gray.900',
rounded: 'xl',
p: '3',
boxShadow: isDragging
? '0 8px 24px rgba(0, 0, 0, 0.3)'
: '0 4px 12px rgba(0, 0, 0, 0.15)',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.300',
cursor: isDragging ? 'grabbing' : 'grab',
transition: isDragging ? 'none' : 'box-shadow 0.2s, background-color 0.2s',
maxWidth: 'calc(100vw - 32px)',
userSelect: 'none',
_hover: {
boxShadow: isDragging
? '0 8px 24px rgba(0, 0, 0, 0.3)'
: '0 6px 16px rgba(0, 0, 0, 0.2)',
bg: isDark ? 'gray.750' : 'gray.50',
},
})}
aria-label="Open worksheet settings (draggable)"
>
{/* Header with gear icon */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
mb: '2',
fontSize: 'sm',
fontWeight: 'bold',
color: isDark ? 'gray.300' : 'gray.600',
})}
>
<span className={css({ fontSize: 'lg' })}></span>
<span>Worksheet Settings</span>
</div>
{/* Summary lines */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1',
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.700',
})}
>
{lines.map((line, i) => (
<div key={i} className={css({ lineHeight: '1.4' })}>
{line}
</div>
))}
</div>
{/* Tap indicator */}
<div
className={css({
mt: '2',
pt: '2',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
fontSize: '2xs',
color: isDark ? 'gray.500' : 'gray.500',
textAlign: 'center',
})}
>
Tap to customize
</div>
</button>
)
}

View File

@ -0,0 +1,198 @@
'use client'
import { css } from '@styled/css'
import { useState } from 'react'
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'
import { useTheme } from '@/contexts/ThemeContext'
import { useIsMobile } from '@/hooks/useMediaQuery'
import type { WorksheetFormState } from '../types'
import { MobileDrawer } from './MobileDrawer'
import { MobileSettingsButton } from './MobileSettingsButton'
interface ResponsivePanelLayoutProps {
config: Partial<WorksheetFormState>
sidebarContent: React.ReactNode
previewContent: React.ReactNode
}
export function ResponsivePanelLayout({
config,
sidebarContent,
previewContent,
}: ResponsivePanelLayoutProps) {
const isMobile = useIsMobile()
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
// Mobile layout: Drawer + floating button
// eslint-disable-next-line react-hooks/rules-of-hooks -- All hooks are called before this conditional return
if (isMobile) {
return (
<>
{/* Full-screen preview */}
<div
data-component="mobile-preview-container"
className={css({
width: '100%',
height: '100%',
overflow: 'auto',
})}
>
{previewContent}
</div>
{/* Floating settings button */}
<MobileSettingsButton config={config} onClick={() => setIsDrawerOpen(true)} />
{/* Settings drawer */}
<MobileDrawer isOpen={isDrawerOpen} onClose={() => setIsDrawerOpen(false)}>
{sidebarContent}
</MobileDrawer>
</>
)
}
// Desktop layout: Resizable panels
const resizeHandleStyles = css({
width: '36px',
marginRight: '-28px',
height: '100%',
position: 'relative',
cursor: 'col-resize',
zIndex: 10,
})
const handleVisualStyles = css({
position: 'absolute',
inset: 0,
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
})
return (
<PanelGroup
direction="horizontal"
autoSaveId="worksheet-generator-layout"
className={css({ flex: '1', minHeight: '0' })}
>
{/* Left Panel: Config Sidebar */}
<Panel defaultSize={25} minSize={20} maxSize={40} collapsible>
<div
data-component="desktop-sidebar-container"
className={css({
h: 'full',
overflow: 'auto',
})}
>
{sidebarContent}
</div>
</Panel>
<PanelResizeHandle className={resizeHandleStyles}>
<div className={handleVisualStyles}>
{/* Thin divider (8px, full height) */}
<div
className={css({
width: '8px',
height: '100%',
bg: isDark ? 'gray.700' : 'gray.300',
position: 'relative',
transition: 'background-color 0.2s',
_groupHover: {
bg: isDark ? 'blue.600' : 'blue.300',
},
})}
>
{/* Grip dots on divider */}
<div
className={css({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
display: 'flex',
flexDirection: 'column',
gap: '4px',
})}
>
<div
className={css({
width: '3px',
height: '3px',
borderRadius: 'full',
bg: isDark ? 'gray.500' : 'gray.500',
})}
/>
<div
className={css({
width: '3px',
height: '3px',
borderRadius: 'full',
bg: isDark ? 'gray.500' : 'gray.500',
})}
/>
<div
className={css({
width: '3px',
height: '3px',
borderRadius: 'full',
bg: isDark ? 'gray.500' : 'gray.500',
})}
/>
</div>
</div>
{/* Grab tab (28px wide, 64px tall, centered vertically) */}
<div
className={css({
width: '28px',
height: '64px',
bg: isDark ? 'gray.600' : 'gray.400',
borderTopRightRadius: '8px',
borderBottomRightRadius: '8px',
position: 'relative',
transition: 'background-color 0.2s',
overflow: 'hidden',
_groupHover: {
bg: isDark ? 'blue.500' : 'blue.400',
},
})}
>
{/* Knurled texture (vertical ridges) - multiple visible lines */}
{[0, 4, 8, 12, 16, 20, 24].map((offset) => (
<div
key={offset}
className={css({
position: 'absolute',
left: `${offset}px`,
top: 0,
bottom: 0,
width: '2px',
bg: isDark ? 'rgba(255, 255, 255, 0.25)' : 'rgba(0, 0, 0, 0.25)',
boxShadow: isDark
? '1px 0 0 rgba(0, 0, 0, 0.3)'
: '1px 0 0 rgba(255, 255, 255, 0.3)',
})}
/>
))}
</div>
</div>
</PanelResizeHandle>
{/* Center Panel: Preview */}
<Panel defaultSize={75} minSize={60}>
<div
data-component="desktop-preview-container"
className={css({
h: 'full',
overflow: 'auto',
})}
>
{previewContent}
</div>
</Panel>
</PanelGroup>
)
}

View File

@ -0,0 +1,123 @@
import type { WorksheetFormState } from '../types'
/**
* Icons for different worksheet settings
*/
export const SETTING_ICONS = {
operator: {
addition: '',
subtraction: '',
multiplication: '✖️',
division: '➗',
mixed: '🔀',
},
difficulty: {
smart: '🎯',
manual: '🎚️',
},
scaffolding: {
tenFrames: '🎨',
carryBoxes: '📦',
placeValueColors: '🌈',
answerBoxes: '✏️',
},
layout: {
pages: '📄',
columns: '📊',
problems: '📝',
},
range: '🔢',
} as const
/**
* Generate a human-readable summary of worksheet settings
* for display on mobile settings button
*/
export function generateSettingsSummary(config: Partial<WorksheetFormState>): {
lines: string[]
icons: string[]
} {
const lines: string[] = []
const icons: string[] = []
// Line 1: Operator and digit range
if (config.operator) {
const operatorIcon = SETTING_ICONS.operator[config.operator]
const operatorName = config.operator.charAt(0).toUpperCase() + config.operator.slice(1)
const digitRange = config.digitRange
? config.digitRange.min === config.digitRange.max
? `${config.digitRange.min}-digit`
: `${config.digitRange.min}-${config.digitRange.max} digits`
: ''
lines.push(`${operatorIcon} ${operatorName}${digitRange ? `${digitRange}` : ''}`)
icons.push(operatorIcon)
}
// Line 2: Layout (problems per page, columns)
if (config.problemsPerPage && config.cols) {
const layoutLine = `📄 ${config.problemsPerPage} problems • ${config.cols} columns`
lines.push(layoutLine)
icons.push('📄')
}
// Line 3: Visual scaffolding (enabled features)
const scaffolds: string[] = []
if (config.displayRules) {
if (config.displayRules.tenFrames === 'always') {
scaffolds.push(`${SETTING_ICONS.scaffolding.tenFrames} Ten frames`)
icons.push(SETTING_ICONS.scaffolding.tenFrames)
}
if (config.displayRules.carryBoxes === 'always') {
scaffolds.push(`${SETTING_ICONS.scaffolding.carryBoxes} Carry boxes`)
icons.push(SETTING_ICONS.scaffolding.carryBoxes)
}
if (config.displayRules.placeValueColors === 'always') {
scaffolds.push(`${SETTING_ICONS.scaffolding.placeValueColors} Colors`)
icons.push(SETTING_ICONS.scaffolding.placeValueColors)
}
}
if (scaffolds.length > 0) {
lines.push(scaffolds.join(' • '))
}
// Line 4: Difficulty mode
if (config.mode) {
const diffIcon = SETTING_ICONS.difficulty[config.mode]
const modeName = config.mode === 'smart' ? 'Smart difficulty' : 'Manual mode'
const pStart =
config.mode === 'smart' && config.pAnyStart != null
? `${Math.round(config.pAnyStart * 100)}% starts`
: ''
lines.push(`${diffIcon} ${modeName}${pStart}`)
icons.push(diffIcon)
}
return { lines, icons }
}
/**
* Generate a compact one-line summary for small spaces
*/
export function generateCompactSummary(config: Partial<WorksheetFormState>): string {
const parts: string[] = []
if (config.operator) {
const icon = SETTING_ICONS.operator[config.operator]
const name = config.operator.charAt(0).toUpperCase() + config.operator.slice(1)
parts.push(`${icon} ${name}`)
}
if (config.digitRange) {
const range =
config.digitRange.min === config.digitRange.max
? `${config.digitRange.min}d`
: `${config.digitRange.min}-${config.digitRange.max}d`
parts.push(range)
}
if (config.problemsPerPage) {
parts.push(`${config.problemsPerPage}p`)
}
return parts.join(' • ')
}

View File

@ -0,0 +1,56 @@
'use client'
import { useEffect, useState } from 'react'
/**
* Hook to detect media query matches
* @param query - CSS media query string (e.g., '(min-width: 768px)')
* @returns boolean indicating if the query matches
*/
export function useMediaQuery(query: string): boolean {
// Initialize with false to avoid hydration mismatch
const [matches, setMatches] = useState(false)
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
}, [])
useEffect(() => {
if (!isClient) return
const mediaQuery = window.matchMedia(query)
// Set initial value
setMatches(mediaQuery.matches)
// Create event listener
const handler = (event: MediaQueryListEvent) => {
setMatches(event.matches)
}
// Modern browsers
mediaQuery.addEventListener('change', handler)
return () => {
mediaQuery.removeEventListener('change', handler)
}
}, [query, isClient])
return matches
}
/**
* Common breakpoint hooks
*/
export function useIsMobile(): boolean {
return useMediaQuery('(max-width: 767px)')
}
export function useIsTablet(): boolean {
return useMediaQuery('(min-width: 768px) and (max-width: 1023px)')
}
export function useIsDesktop(): boolean {
return useMediaQuery('(min-width: 1024px)')
}