feat: add dynamic layout preview component for orientation selection

Create LayoutPreview component:
- Visual preview showing page orientation and problem grid
- SVG-based rendering with proper aspect ratios
- Full theme support (light/dark mode)
- Can act as a button with onClick and isSelected props
- Shows actual layout (cols × rows) or default for unselected orientation

Update tab navigation:
- Use LayoutPreview for layout button icon
- Show actual worksheet layout in button preview
- Pass pages count to layout button subtitle

Update orientation selection:
- Replace text labels and SVG icons with LayoutPreview
- Buttons ARE the preview (no nested styling)
- Show current layout if selected, defaults if not
- Portrait: 3×5 (15 problems default), Landscape: 4×5 (20 problems default)
- Clean, visual interface without text labels

Benefits:
- Instantly see what each orientation looks like
- Preview matches actual worksheet layout
- Consistent visual language across UI
- More intuitive than text/emoji

🤖 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-18 09:58:54 -06:00
parent d470be2c3a
commit 8df62d6a45
3 changed files with 163 additions and 247 deletions

View File

@ -4,6 +4,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import * as Tooltip from '@radix-ui/react-tooltip'
import { css } from '@styled/css'
import { useMemo, useState } from 'react'
import { LayoutPreview } from './config-sidebar/LayoutPreview'
import { validateProblemSpace } from '../utils/validateProblemSpace'
import type { ProblemSpaceValidation } from '../utils/validateProblemSpace'
import { getDefaultColsForProblemsPerPage } from '../utils/layoutCalculations'
@ -25,7 +26,7 @@ interface OrientationPanelProps {
digitRange?: { min: number; max: number }
pAnyStart?: number
operator?: 'addition' | 'subtraction' | 'mixed'
mode?: 'smart' | 'mastery'
mode?: 'custom' | 'mastery'
// Layout options
problemNumbers?: 'always' | 'never'
cellBorders?: 'always' | 'never'
@ -49,7 +50,7 @@ export function OrientationPanel({
digitRange = { min: 2, max: 2 },
pAnyStart = 0,
operator = 'addition',
mode = 'smart',
mode = 'custom',
problemNumbers = 'always',
cellBorders = 'always',
onProblemNumbersChange,
@ -188,250 +189,36 @@ export function OrientationPanel({
},
})}
>
<button
type="button"
data-action="select-portrait"
<LayoutPreview
orientation="portrait"
cols={
orientation === 'portrait'
? cols
: getDefaultColsForProblemsPerPage(15, 'portrait')
}
rows={
orientation === 'portrait'
? Math.ceil(problemsPerPage / cols)
: Math.ceil(15 / getDefaultColsForProblemsPerPage(15, 'portrait'))
}
onClick={() => handleOrientationChange('portrait')}
className={css({
display: 'flex',
alignItems: 'center',
gap: '1.5',
flex: '1',
px: '2',
py: '1.5',
border: '2px solid',
borderColor:
orientation === 'portrait' ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
bg:
orientation === 'portrait'
? isDark
? 'brand.900'
: 'brand.50'
: isDark
? 'gray.700'
: 'white',
rounded: 'lg',
cursor: 'pointer',
transition: 'all 0.15s',
justifyContent: 'center',
minWidth: 0,
_hover: {
borderColor: 'brand.400',
},
'@media (max-width: 400px)': {
px: '1.5',
py: '1',
gap: '1',
},
'@media (max-width: 200px)': {
px: '1',
py: '0.5',
gap: '0.5',
},
})}
>
{/* Portrait page icon */}
<svg
width="16"
height="20"
viewBox="0 0 16 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={css({
flexShrink: 0,
'@media (max-width: 300px)': {
width: '12px',
height: '16px',
},
})}
>
<rect
x="1"
y="1"
width="14"
height="18"
rx="1"
stroke="currentColor"
strokeWidth="2"
fill="none"
isSelected={orientation === 'portrait'}
/>
<line
x1="3"
y1="4"
x2="13"
y2="4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
x1="3"
y1="7"
x2="13"
y2="7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
x1="3"
y1="10"
x2="10"
y2="10"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color:
orientation === 'portrait'
? isDark
? 'brand.200'
: 'brand.700'
: isDark
? 'gray.300'
: 'gray.600',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
'@media (max-width: 200px)': {
fontSize: '2xs',
},
'@media (max-width: 150px)': {
display: 'none',
},
})}
>
Portrait
</div>
</button>
<button
type="button"
data-action="select-landscape"
<LayoutPreview
orientation="landscape"
cols={
orientation === 'landscape'
? cols
: getDefaultColsForProblemsPerPage(20, 'landscape')
}
rows={
orientation === 'landscape'
? Math.ceil(problemsPerPage / cols)
: Math.ceil(20 / getDefaultColsForProblemsPerPage(20, 'landscape'))
}
onClick={() => handleOrientationChange('landscape')}
className={css({
display: 'flex',
alignItems: 'center',
gap: '1.5',
flex: '1',
px: '2',
py: '1.5',
border: '2px solid',
borderColor:
orientation === 'landscape' ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
bg:
orientation === 'landscape'
? isDark
? 'brand.900'
: 'brand.50'
: isDark
? 'gray.700'
: 'white',
rounded: 'lg',
cursor: 'pointer',
transition: 'all 0.15s',
justifyContent: 'center',
minWidth: 0,
_hover: {
borderColor: 'brand.400',
},
'@media (max-width: 400px)': {
px: '1.5',
py: '1',
gap: '1',
},
'@media (max-width: 200px)': {
px: '1',
py: '0.5',
gap: '0.5',
},
})}
>
{/* Landscape page icon */}
<svg
width="20"
height="16"
viewBox="0 0 20 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={css({
flexShrink: 0,
'@media (max-width: 300px)': {
width: '16px',
height: '12px',
},
})}
>
<rect
x="1"
y="1"
width="18"
height="14"
rx="1"
stroke="currentColor"
strokeWidth="2"
fill="none"
isSelected={orientation === 'landscape'}
/>
<line
x1="3"
y1="4"
x2="17"
y2="4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
x1="3"
y1="7"
x2="17"
y2="7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
x1="3"
y1="10"
x2="13"
y2="10"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color:
orientation === 'landscape'
? isDark
? 'brand.200'
: 'brand.700'
: isDark
? 'gray.300'
: 'gray.600',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
'@media (max-width: 200px)': {
fontSize: '2xs',
},
'@media (max-width: 150px)': {
display: 'none',
},
})}
>
Landscape
</div>
</button>
</div>
</div>

View File

@ -0,0 +1,121 @@
'use client'
import { css } from '@styled/css'
import { useTheme } from '@/contexts/ThemeContext'
interface LayoutPreviewProps {
orientation: 'portrait' | 'landscape'
cols: number
rows: number
className?: string
onClick?: () => void
isSelected?: boolean
}
/**
* Visual preview of worksheet layout showing page orientation and problem grid
* Can act as a button when onClick is provided
*/
export function LayoutPreview({
orientation,
cols,
rows,
className,
onClick,
isSelected = false,
}: LayoutPreviewProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Page dimensions (aspect ratios)
const pageAspect = orientation === 'portrait' ? 8.5 / 11 : 11 / 8.5
// Scale to fit in button (max dimensions)
const maxSize = 48
let pageWidth: number
let pageHeight: number
if (orientation === 'portrait') {
pageHeight = maxSize
pageWidth = pageHeight * pageAspect
} else {
pageWidth = maxSize
pageHeight = pageWidth / pageAspect
}
// Problem cell dimensions with padding
const padding = 3
const cellWidth = (pageWidth - padding * 2) / cols
const cellHeight = (pageHeight - padding * 2) / rows
const cellPadding = 1
const svgContent = (
<svg
width={pageWidth}
height={pageHeight}
viewBox={`0 0 ${pageWidth} ${pageHeight}`}
className={css({
rounded: 'sm',
})}
style={{
backgroundColor: isDark ? '#1f2937' : 'white',
}}
>
{/* Problem grid */}
{Array.from({ length: rows }).map((_, rowIdx) =>
Array.from({ length: cols }).map((_, colIdx) => (
<rect
key={`${rowIdx}-${colIdx}`}
x={padding + colIdx * cellWidth + cellPadding}
y={padding + rowIdx * cellHeight + cellPadding}
width={cellWidth - cellPadding * 2}
height={cellHeight - cellPadding * 2}
fill={isDark ? '#6b7280' : '#d1d5db'}
rx={0.5}
/>
))
)}
</svg>
)
// If used as a button, wrap in button element with styling
if (onClick) {
return (
<button
type="button"
onClick={onClick}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '2px solid',
borderColor: isSelected ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
bg: isSelected ? (isDark ? 'brand.900' : 'brand.50') : isDark ? 'gray.700' : 'white',
rounded: 'lg',
cursor: 'pointer',
transition: 'all 0.15s',
p: '2',
_hover: {
borderColor: 'brand.400',
},
})}
>
{svgContent}
</button>
)
}
// Otherwise just return the SVG with border
return (
<div
className={css({
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
rounded: 'sm',
display: 'inline-block',
})}
>
{svgContent}
</div>
)
}

View File

@ -3,6 +3,7 @@
import { css } from '@styled/css'
import { useTheme } from '@/contexts/ThemeContext'
import type { DisplayRules } from '../../displayRules'
import { LayoutPreview } from './LayoutPreview'
import { ProblemPreview } from './ProblemPreview'
export interface Tab {
@ -36,7 +37,7 @@ export const TABS: Tab[] = [
{
id: 'layout',
label: 'Layout',
icon: '📐',
icon: 'preview',
subtitle: ({ orientation, problemsPerPage, cols, pages }) => {
if (!orientation || !problemsPerPage || !cols || !pages) return null
const orientationLabel = orientation === 'portrait' ? 'Portrait' : 'Landscape'
@ -182,7 +183,8 @@ export function TabNavigation({
{TABS.map((tab) => {
const icon = getTabIcon(tab)
const subtitle = getTabSubtitle(tab)
const showPreviewIcon = tab.icon === 'preview' && displayRules
const showLayoutPreview = tab.id === 'layout' && orientation && problemsPerPage && cols
const showScaffoldingPreview = tab.id === 'scaffolding' && displayRules
// All operator symbols (+, , ±) are ASCII characters that need larger font size
const isOperatorSymbol = tab.id === 'operator'
@ -240,7 +242,13 @@ export function TabNavigation({
flexShrink: 0,
})}
>
{showPreviewIcon ? (
{showLayoutPreview ? (
<LayoutPreview
orientation={orientation!}
cols={cols!}
rows={Math.ceil(problemsPerPage! / cols!)}
/>
) : showScaffoldingPreview ? (
<ProblemPreview
displayRules={displayRules!}
resolvedDisplayRules={resolvedDisplayRules}