feat: add visual warnings to page selector buttons
Implemented proactive duplicate risk warnings directly in the page selector UI to guide users toward valid configurations before they encounter issues. **Features:** - Color-coded page buttons based on duplicate risk - Green/brand: Safe (< 50% of space) - Yellow: Caution (50-80% of space) - Red: Danger (> 80% of space) - Warning indicator dots on risky page counts - Radix UI tooltips on hover explaining the risk - Live problem space calculation based on current config **Visual Indicators:** - Border colors change to yellow/red for risky page counts - Background tints match risk level when selected - Small dot in top-right corner for at-risk buttons - Hover shows detailed tooltip with: - Total problems requested vs available - Duplicate risk level - Actionable recommendations **Implementation:** - OrientationPanel.tsx: Added problem space validation logic - Passes digitRange, pAnyStart, operator, mode from form state - Uses estimateUniqueProblemSpace() for real-time calculation - Tooltip messages formatted with recommendations - Skips validation for mastery+mixed mode (consistent with banner) **Example:** 1-digit 100% regrouping with 15 problems/page: - Page 1: Green (15 of 45 available) - Page 2: Yellow warning (30 of 45) - Page 3: Red danger (45 of 45 - duplicates inevitable) Tooltip on page 3: "🚫 Too many duplicates: 45 problems requested, only ~45 unique available. Consider: • Reduce to 1 pages • Increase digit range • Lower regrouping %" **Next:** Problem space indicator in config panel showing live estimate 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -56,11 +56,15 @@
|
||||
"Bash(npm run format:check:*)",
|
||||
"Bash(npx biome:*)",
|
||||
"Bash(git restore:*)",
|
||||
"Bash(mcp__sqlite__describe_table:*)"
|
||||
"Bash(mcp__sqlite__describe_table:*)",
|
||||
"Bash(~45 unique available. Consider: • Reduce to 1 pages • Increase digit range)",
|
||||
"Bash(• Lower regrouping %\"\n\n**Next:** Problem space indicator in config panel showing live estimate\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": ["sqlite"]
|
||||
"enabledMcpjsonServers": [
|
||||
"sqlite"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { css } from '@styled/css'
|
||||
import { useMemo } from 'react'
|
||||
import { estimateUniqueProblemSpace } from '../utils/validateProblemSpace'
|
||||
import { getDefaultColsForProblemsPerPage } from '../utils/layoutCalculations'
|
||||
|
||||
interface OrientationPanelProps {
|
||||
@@ -17,6 +20,11 @@ interface OrientationPanelProps {
|
||||
onProblemsPerPageChange: (problemsPerPage: number, cols: number) => void
|
||||
onPagesChange: (pages: number) => void
|
||||
isDark?: boolean
|
||||
// Config for problem space validation
|
||||
digitRange?: { min: number; max: number }
|
||||
pAnyStart?: number
|
||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
||||
mode?: 'smart' | 'mastery'
|
||||
// Layout options
|
||||
problemNumbers?: 'always' | 'never'
|
||||
cellBorders?: 'always' | 'never'
|
||||
@@ -37,6 +45,10 @@ export function OrientationPanel({
|
||||
onProblemsPerPageChange,
|
||||
onPagesChange,
|
||||
isDark = false,
|
||||
digitRange = { min: 2, max: 2 },
|
||||
pAnyStart = 0,
|
||||
operator = 'addition',
|
||||
mode = 'smart',
|
||||
problemNumbers = 'always',
|
||||
cellBorders = 'always',
|
||||
onProblemNumbersChange,
|
||||
@@ -57,6 +69,43 @@ export function OrientationPanel({
|
||||
const problemsForOrientation =
|
||||
orientation === 'portrait' ? [6, 8, 10, 12, 15] : [8, 10, 12, 15, 16, 20]
|
||||
|
||||
// Calculate problem space and determine risk for each page count
|
||||
const estimatedSpace = useMemo(() => {
|
||||
// Skip validation for mastery + mixed mode (same logic as WorksheetPreviewContext)
|
||||
if (mode === 'mastery' && operator === 'mixed') {
|
||||
return Infinity // No validation
|
||||
}
|
||||
return estimateUniqueProblemSpace(digitRange, pAnyStart, operator)
|
||||
}, [digitRange, pAnyStart, operator, mode])
|
||||
|
||||
// Helper to get duplicate risk for a given page count
|
||||
const getDuplicateRisk = (pageCount: number): 'none' | 'caution' | 'danger' => {
|
||||
if (estimatedSpace === Infinity) return 'none'
|
||||
|
||||
const requestedProblems = problemsPerPage * pageCount
|
||||
const ratio = requestedProblems / estimatedSpace
|
||||
|
||||
if (ratio < 0.5) return 'none'
|
||||
if (ratio < 0.8) return 'caution'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
// Helper to get tooltip message for a page count
|
||||
const getTooltipMessage = (pageCount: number): string | null => {
|
||||
if (estimatedSpace === Infinity) return null
|
||||
|
||||
const requestedProblems = problemsPerPage * pageCount
|
||||
const ratio = requestedProblems / estimatedSpace
|
||||
|
||||
if (ratio < 0.5) return null // No warning needed
|
||||
|
||||
if (ratio < 0.8) {
|
||||
return `⚠️ Limited variety: ${requestedProblems} problems requested, ~${Math.floor(estimatedSpace)} unique available.\n\nSome duplicates may occur.`
|
||||
}
|
||||
|
||||
return `🚫 Too many duplicates: ${requestedProblems} problems requested, only ~${Math.floor(estimatedSpace)} unique available.\n\nConsider:\n• Reduce to ${Math.max(1, Math.floor((estimatedSpace * 0.5) / problemsPerPage))} pages\n• Increase digit range\n• Lower regrouping %`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-section="orientation-panel"
|
||||
@@ -399,7 +448,10 @@ export function OrientationPanel({
|
||||
{/* Quick select buttons for 1-3 pages */}
|
||||
{[1, 2, 3].map((pageCount) => {
|
||||
const isSelected = pages === pageCount
|
||||
return (
|
||||
const risk = getDuplicateRisk(pageCount)
|
||||
const tooltipMessage = getTooltipMessage(pageCount)
|
||||
|
||||
const button = (
|
||||
<button
|
||||
key={pageCount}
|
||||
type="button"
|
||||
@@ -409,11 +461,35 @@ export function OrientationPanel({
|
||||
w: '8',
|
||||
h: '8',
|
||||
border: '2px solid',
|
||||
borderColor: isSelected ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
|
||||
borderColor: isSelected
|
||||
? risk === 'danger'
|
||||
? 'red.500'
|
||||
: risk === 'caution'
|
||||
? 'yellow.500'
|
||||
: 'brand.500'
|
||||
: risk === 'danger'
|
||||
? isDark
|
||||
? 'red.700'
|
||||
: 'red.300'
|
||||
: risk === 'caution'
|
||||
? isDark
|
||||
? 'yellow.700'
|
||||
: 'yellow.300'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.300',
|
||||
bg: isSelected
|
||||
? isDark
|
||||
? 'brand.900'
|
||||
: 'brand.50'
|
||||
? risk === 'danger'
|
||||
? 'red.900'
|
||||
: risk === 'caution'
|
||||
? 'yellow.900'
|
||||
: 'brand.900'
|
||||
: risk === 'danger'
|
||||
? 'red.50'
|
||||
: risk === 'caution'
|
||||
? 'yellow.50'
|
||||
: 'brand.50'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'white',
|
||||
@@ -423,8 +499,16 @@ export function OrientationPanel({
|
||||
fontWeight: 'bold',
|
||||
color: isSelected
|
||||
? isDark
|
||||
? 'brand.200'
|
||||
: 'brand.700'
|
||||
? risk === 'danger'
|
||||
? 'red.200'
|
||||
: risk === 'caution'
|
||||
? 'yellow.200'
|
||||
: 'brand.200'
|
||||
: risk === 'danger'
|
||||
? 'red.700'
|
||||
: risk === 'caution'
|
||||
? 'yellow.700'
|
||||
: 'brand.700'
|
||||
: isDark
|
||||
? 'gray.300'
|
||||
: 'gray.600',
|
||||
@@ -433,8 +517,14 @@ export function OrientationPanel({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
_hover: {
|
||||
borderColor: 'brand.400',
|
||||
borderColor:
|
||||
risk === 'danger'
|
||||
? 'red.400'
|
||||
: risk === 'caution'
|
||||
? 'yellow.400'
|
||||
: 'brand.400',
|
||||
},
|
||||
'@media (max-width: 444px)': {
|
||||
w: '6',
|
||||
@@ -450,8 +540,62 @@ export function OrientationPanel({
|
||||
})}
|
||||
>
|
||||
{pageCount}
|
||||
{/* Warning indicator dot */}
|
||||
{risk !== 'none' && (
|
||||
<span
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '-1',
|
||||
right: '-1',
|
||||
w: '2',
|
||||
h: '2',
|
||||
bg: risk === 'danger' ? 'red.500' : 'yellow.500',
|
||||
rounded: 'full',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
// Wrap in tooltip if there's a warning message
|
||||
if (tooltipMessage) {
|
||||
return (
|
||||
<Tooltip.Provider key={pageCount}>
|
||||
<Tooltip.Root delayDuration={300}>
|
||||
<Tooltip.Trigger asChild>{button}</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className={css({
|
||||
bg: isDark ? 'gray.800' : 'white',
|
||||
color: isDark ? 'gray.100' : 'gray.900',
|
||||
px: '3',
|
||||
py: '2',
|
||||
rounded: 'lg',
|
||||
shadow: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
maxW: '64',
|
||||
fontSize: 'xs',
|
||||
lineHeight: '1.5',
|
||||
whiteSpace: 'pre-wrap',
|
||||
zIndex: 10000,
|
||||
})}
|
||||
sideOffset={5}
|
||||
>
|
||||
{tooltipMessage}
|
||||
<Tooltip.Arrow
|
||||
className={css({
|
||||
fill: isDark ? 'gray.800' : 'white',
|
||||
})}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
return button
|
||||
})}
|
||||
|
||||
{/* Dropdown for 4+ pages */}
|
||||
|
||||
@@ -65,6 +65,11 @@ export function LayoutTab() {
|
||||
onProblemsPerPageChange={handleProblemsPerPageChange}
|
||||
onPagesChange={handlePagesChange}
|
||||
isDark={isDark}
|
||||
// Pass config for problem space validation
|
||||
digitRange={formState.digitRange || { min: 2, max: 2 }}
|
||||
pAnyStart={formState.pAnyStart ?? 0}
|
||||
operator={formState.operator || 'addition'}
|
||||
mode={formState.mode || 'smart'}
|
||||
problemNumbers={
|
||||
((formState.displayRules ?? defaultAdditionConfig.displayRules).problemNumbers as
|
||||
| 'always'
|
||||
|
||||
Reference in New Issue
Block a user