fix: refactor worksheet config persistence to blacklist approach + Storybook stories
This commit addresses a critical bug where shared worksheets showed incorrect page counts,
and refactors the config extraction architecture to prevent future sharing bugs.
## Bug Fix: Shared Worksheet Page Count
**Problem:** When sharing a 100-page worksheet, opening the share link showed only 4 pages.
**Root Cause:**
- `extractConfigFields()` correctly excluded `total` (derived state)
- `validateWorksheetConfig()` incorrectly used `formState.total ?? 20` as fallback
- Result: Shared worksheets defaulted to `total: 20` instead of calculating from `pages × problemsPerPage`
**Solution:**
- Calculate `total = problemsPerPage × pages` from PRIMARY state
- Never use `formState.total` as source of truth - it's derived!
```typescript
// Before (bug)
const total = formState.total ?? 20
// After (fix)
const problemsPerPage = formState.problemsPerPage ?? 20
const pages = formState.pages ?? 1
const total = problemsPerPage * pages // 100 pages × 20 problems = 2000 ✓
```
## Architecture: Blacklist Refactor
**Problem:** Multiple incidents where new config fields broke shared worksheets because
we forgot to update the extraction whitelist.
**Old Approach (FRAGILE):**
```typescript
// Manually list every field - easy to forget new ones!
return {
problemsPerPage: formState.problemsPerPage,
cols: formState.cols,
// ... 30+ fields manually listed
}
```
**New Approach (ROBUST):**
```typescript
// Automatically include everything except derived/ephemeral fields
const { rows, total, date, ...persistedFields } = formState
return persistedFields
```
**Benefits:**
- ✅ New config fields automatically work in shared worksheets
- ✅ Only need to update if adding DERIVED fields (rare)
- ✅ Much harder to accidentally break sharing
## Documentation
Added comprehensive docs explaining PRIMARY vs DERIVED state:
**Architecture docs:**
- `.claude/WORKSHEET_CONFIG_PERSISTENCE.md` - Full architecture, examples, history
- `src/app/create/worksheets/README_CONFIG_PERSISTENCE.md` - Quick reference
**In-code docs:**
- `extractConfigFields.ts` - Blacklist approach, field categories, usage
- `validation.ts` - PRIMARY → DERIVED state calculation with examples
- `types.ts` - Field category markers (PRIMARY/DERIVED/EPHEMERAL)
## Storybook Stories
Added comprehensive Storybook stories for worksheet generator components:
**Component Stories:**
- `AdditionWorksheetClient.stories.tsx` - Full generator with virtual loading demos
- `ConfigSidebar.stories.tsx` - Configuration panel with all tabs
- `ResponsivePanelLayout.stories.tsx` - Desktop/mobile layout variations
- `WorksheetPreview.stories.tsx` - Preview component with different states
- `PagePlaceholder.stories.tsx` - Animated loading placeholders
**Config Panel Stories:**
- `OverallDifficultySlider.stories.tsx` - Difficulty slider variations
- `RuleDropdown.stories.tsx` - Display rule dropdown states
- `ToggleOption.stories.tsx` - Toggle option component
**Placeholder Enhancements:**
- Added fast-cycling animated problem variations (0.5-1s cycles)
- Bigger, bolder, higher-contrast math elements
- Removed redundant "Loading page..." overlay
- Added keyframes: wiggle, shimmer, fadeInScale, slideInRight, morphWidth, colorShift
**Storybook Config:**
- Renamed `preview.ts` → `preview.tsx` for JSX support
- Updated `main.ts` for Panda CSS integration
## Testing
Manual test checklist for sharing:
1. Create 100-page worksheet (2000 problems)
2. Click "Share"
3. Open share link in incognito
4. Verify: Shows 100 pages, 2000 total problems ✓
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
426
apps/web/.claude/WORKSHEET_CONFIG_PERSISTENCE.md
Normal file
426
apps/web/.claude/WORKSHEET_CONFIG_PERSISTENCE.md
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
# Worksheet Config Persistence Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document explains how worksheet configurations are persisted, shared, and restored across the application.
|
||||||
|
|
||||||
|
**Key Principle:** We separate **PRIMARY STATE** (what we save) from **DERIVED STATE** (what we calculate).
|
||||||
|
|
||||||
|
## Field Categories
|
||||||
|
|
||||||
|
### PRIMARY STATE (Persisted)
|
||||||
|
|
||||||
|
These fields define the worksheet configuration and MUST be saved:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
// Structure
|
||||||
|
problemsPerPage: number // How many problems per page (e.g., 20)
|
||||||
|
cols: number // Grid columns (e.g., 4)
|
||||||
|
pages: number // How many pages (e.g., 5)
|
||||||
|
orientation: 'portrait' | 'landscape'
|
||||||
|
|
||||||
|
// Problem Space
|
||||||
|
digitRange: { min: number, max: number } // 1-5 digits
|
||||||
|
operator: 'addition' | 'subtraction' | 'mixed'
|
||||||
|
|
||||||
|
// Regrouping Distribution
|
||||||
|
pAnyStart: number // Probability of any-column regrouping
|
||||||
|
pAllStart: number // Probability of all-column regrouping
|
||||||
|
interpolate: boolean // Gradual difficulty progression
|
||||||
|
|
||||||
|
// Display Mode (discriminated union)
|
||||||
|
mode: 'smart' | 'manual' | 'mastery'
|
||||||
|
|
||||||
|
// Smart Mode Fields
|
||||||
|
displayRules?: { // Conditional per-problem scaffolding
|
||||||
|
tenFrames: 'never' | 'sometimes' | 'always'
|
||||||
|
carryBoxes: 'never' | 'sometimes' | 'always'
|
||||||
|
placeValueColors: 'never' | 'sometimes' | 'always'
|
||||||
|
answerBoxes: 'never' | 'sometimes' | 'always'
|
||||||
|
problemNumbers: 'never' | 'sometimes' | 'always'
|
||||||
|
cellBorders: 'never' | 'sometimes' | 'always'
|
||||||
|
borrowNotation: 'never' | 'sometimes' | 'always'
|
||||||
|
borrowingHints: 'never' | 'sometimes' | 'always'
|
||||||
|
}
|
||||||
|
difficultyProfile?: string // Smart mode preset (e.g., 'earlyLearner')
|
||||||
|
|
||||||
|
// Manual Mode Fields
|
||||||
|
showCarryBoxes?: boolean
|
||||||
|
showAnswerBoxes?: boolean
|
||||||
|
showPlaceValueColors?: boolean
|
||||||
|
showProblemNumbers?: boolean
|
||||||
|
showCellBorder?: boolean
|
||||||
|
showTenFrames?: boolean
|
||||||
|
showTenFramesForAll?: boolean
|
||||||
|
showBorrowNotation?: boolean
|
||||||
|
showBorrowingHints?: boolean
|
||||||
|
manualPreset?: string // Manual mode preset
|
||||||
|
|
||||||
|
// Mastery Mode Fields
|
||||||
|
currentStepId?: string
|
||||||
|
currentAdditionSkillId?: string
|
||||||
|
currentSubtractionSkillId?: string
|
||||||
|
|
||||||
|
// Personalization
|
||||||
|
name: string // Student name
|
||||||
|
fontSize: number // Font size in points
|
||||||
|
|
||||||
|
// Reproducibility (CRITICAL for sharing!)
|
||||||
|
seed: number // Random seed
|
||||||
|
prngAlgorithm: string // PRNG algorithm (e.g., 'mulberry32')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DERIVED STATE (Calculated)
|
||||||
|
|
||||||
|
These fields are calculated from primary state and should NOT be saved:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
total: number // = problemsPerPage × pages
|
||||||
|
rows: number // = Math.ceil(problemsPerPage / cols)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why exclude these?**
|
||||||
|
- They're redundant (can be recalculated)
|
||||||
|
- Including them creates risk of inconsistency (e.g., `total: 20` but `pages: 100`)
|
||||||
|
- Primary state is the source of truth
|
||||||
|
|
||||||
|
### EPHEMERAL STATE (Not Persisted)
|
||||||
|
|
||||||
|
These fields are generated fresh at runtime and should NOT be saved:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
date: string // Current date (e.g., "January 15, 2025")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why exclude?**
|
||||||
|
- Date should reflect when the worksheet is actually generated/printed
|
||||||
|
- User may generate worksheet days/weeks after creating the config
|
||||||
|
|
||||||
|
## Architecture: Blacklist Approach
|
||||||
|
|
||||||
|
### File: `src/app/create/worksheets/utils/extractConfigFields.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function extractConfigFields(formState: WorksheetFormState) {
|
||||||
|
// Blacklist approach: Exclude only derived/ephemeral fields
|
||||||
|
const { rows, total, date, ...persistedFields } = formState
|
||||||
|
|
||||||
|
return {
|
||||||
|
...persistedFields,
|
||||||
|
prngAlgorithm: persistedFields.prngAlgorithm ?? 'mulberry32',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why Blacklist Instead of Whitelist?
|
||||||
|
|
||||||
|
**Old Approach (FRAGILE):**
|
||||||
|
```typescript
|
||||||
|
// Manually list every field - easy to forget new fields!
|
||||||
|
return {
|
||||||
|
problemsPerPage: formState.problemsPerPage,
|
||||||
|
cols: formState.cols,
|
||||||
|
pages: formState.pages,
|
||||||
|
// ... 30+ fields ...
|
||||||
|
// Oops, forgot to add the new field! Shared worksheets break!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**New Approach (ROBUST):**
|
||||||
|
```typescript
|
||||||
|
// Automatically include everything except derived fields
|
||||||
|
const { rows, total, date, ...persistedFields } = formState
|
||||||
|
return persistedFields
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- ✅ New config fields automatically work in shared worksheets
|
||||||
|
- ✅ Only need to update if adding new DERIVED fields (rare)
|
||||||
|
- ✅ Much harder to accidentally break sharing
|
||||||
|
- ✅ Less maintenance burden
|
||||||
|
|
||||||
|
## Persistence Locations
|
||||||
|
|
||||||
|
### 1. localStorage (Auto-Save)
|
||||||
|
|
||||||
|
**Hook:** `src/hooks/useWorksheetAutoSave.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const config = extractConfigFields(formState)
|
||||||
|
localStorage.setItem('worksheet-addition-config', JSON.stringify(config))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:** Restore user's work when they return to the page
|
||||||
|
|
||||||
|
**Restoration:**
|
||||||
|
```typescript
|
||||||
|
const saved = localStorage.getItem('worksheet-addition-config')
|
||||||
|
const config = saved ? JSON.parse(saved) : defaultConfig
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Database (Share Links)
|
||||||
|
|
||||||
|
**API Route:** `POST /api/worksheets/share`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const config = extractConfigFields(formState)
|
||||||
|
await db.insert(worksheetShares).values({
|
||||||
|
id: shareId,
|
||||||
|
worksheetType: 'addition',
|
||||||
|
config: JSON.stringify(config),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:** Allow users to share exact worksheet configurations via URL
|
||||||
|
|
||||||
|
**Restoration:**
|
||||||
|
```typescript
|
||||||
|
const share = await db.query.worksheetShares.findFirst({
|
||||||
|
where: eq(worksheetShares.id, shareId)
|
||||||
|
})
|
||||||
|
const config = JSON.parse(share.config)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. API Settings (User Preferences)
|
||||||
|
|
||||||
|
**API Route:** `POST /api/worksheets/settings`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const config = extractConfigFields(formState)
|
||||||
|
await db.insert(worksheetSettings).values({
|
||||||
|
userId: session.userId,
|
||||||
|
type: 'addition',
|
||||||
|
config: JSON.stringify(config),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:** Save user's preferred defaults (future feature)
|
||||||
|
|
||||||
|
## State Reconstruction Flow
|
||||||
|
|
||||||
|
### When Loading a Shared Worksheet
|
||||||
|
|
||||||
|
1. **Fetch share data:**
|
||||||
|
```typescript
|
||||||
|
const response = await fetch(`/api/worksheets/share/${shareId}`)
|
||||||
|
const { config } = await response.json()
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Pass to validation:**
|
||||||
|
```typescript
|
||||||
|
const validation = validateWorksheetConfig(config)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Validation calculates derived state:**
|
||||||
|
```typescript
|
||||||
|
// In validation.ts
|
||||||
|
const problemsPerPage = formState.problemsPerPage ?? 20
|
||||||
|
const pages = formState.pages ?? 1
|
||||||
|
const total = problemsPerPage * pages // DERIVED!
|
||||||
|
const rows = Math.ceil(total / cols) // DERIVED!
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Return validated config with derived state:**
|
||||||
|
```typescript
|
||||||
|
return {
|
||||||
|
...persistedFields,
|
||||||
|
total, // Calculated
|
||||||
|
rows, // Calculated
|
||||||
|
date: getDefaultDate(), // Fresh!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Bugs and Solutions
|
||||||
|
|
||||||
|
### Bug: Shared worksheets show wrong page count
|
||||||
|
|
||||||
|
**Cause:** Using `formState.total` as source of truth instead of calculating from `problemsPerPage × pages`
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
```typescript
|
||||||
|
// ❌ WRONG - uses fallback when total is missing
|
||||||
|
const total = formState.total ?? 20
|
||||||
|
|
||||||
|
// ✅ CORRECT - calculate from primary state
|
||||||
|
const problemsPerPage = formState.problemsPerPage ?? 20
|
||||||
|
const pages = formState.pages ?? 1
|
||||||
|
const total = problemsPerPage * pages
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bug: New config field doesn't persist
|
||||||
|
|
||||||
|
**Cause (Old):** Forgot to add field to `extractConfigFields` whitelist
|
||||||
|
|
||||||
|
**Solution:** Use blacklist approach - new fields automatically work!
|
||||||
|
|
||||||
|
### Bug: Shared worksheet generates different problems
|
||||||
|
|
||||||
|
**Cause:** Missing `seed` or `prngAlgorithm` in persisted config
|
||||||
|
|
||||||
|
**Solution:** `extractConfigFields` always includes these fields:
|
||||||
|
```typescript
|
||||||
|
const config = {
|
||||||
|
...persistedFields,
|
||||||
|
prngAlgorithm: persistedFields.prngAlgorithm ?? 'mulberry32',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding New Config Fields
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
When adding a new config field:
|
||||||
|
|
||||||
|
1. **Determine field category:**
|
||||||
|
- PRIMARY STATE? → No special handling needed! Blacklist approach handles it automatically
|
||||||
|
- DERIVED STATE? → Add to blacklist in `extractConfigFields.ts`
|
||||||
|
- EPHEMERAL STATE? → Add to blacklist in `extractConfigFields.ts`
|
||||||
|
|
||||||
|
2. **Add to type definitions:**
|
||||||
|
```typescript
|
||||||
|
// In config-schemas.ts
|
||||||
|
export const additionConfigV4Schema = z.object({
|
||||||
|
// ... existing fields ...
|
||||||
|
myNewField: z.string().optional(), // Add new field
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Update validation defaults (if needed):**
|
||||||
|
```typescript
|
||||||
|
// In validation.ts
|
||||||
|
const myNewField = formState.myNewField ?? 'defaultValue'
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Test the flow:**
|
||||||
|
- Create worksheet with new field
|
||||||
|
- Save to localStorage
|
||||||
|
- Share the worksheet
|
||||||
|
- Open share link
|
||||||
|
- Verify new field is preserved
|
||||||
|
|
||||||
|
### Example: Adding a New Primary Field
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Update schema (config-schemas.ts)
|
||||||
|
export const additionConfigV4Schema = z.object({
|
||||||
|
// ... existing fields ...
|
||||||
|
headerText: z.string().optional(), // New field!
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Update validation defaults (validation.ts)
|
||||||
|
const sharedFields = {
|
||||||
|
// ... existing fields ...
|
||||||
|
headerText: formState.headerText ?? 'Math Practice',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Done! extractConfigFields automatically includes it
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Adding a New Derived Field
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Update schema (config-schemas.ts)
|
||||||
|
// (Derived fields don't go in the persisted schema)
|
||||||
|
|
||||||
|
// 2. Calculate in validation (validation.ts)
|
||||||
|
const averageProblemsPerRow = Math.ceil(problemsPerPage / rows)
|
||||||
|
|
||||||
|
// 3. Add to blacklist (extractConfigFields.ts)
|
||||||
|
const { rows, total, date, averageProblemsPerRow, ...persistedFields } = formState
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual Test: Share Link Preservation
|
||||||
|
|
||||||
|
1. Create a worksheet with specific config:
|
||||||
|
- 100 pages
|
||||||
|
- 20 problems per page
|
||||||
|
- 3-4 digit problems
|
||||||
|
- Smart mode with specific display rules
|
||||||
|
|
||||||
|
2. Click "Share" to create share link
|
||||||
|
|
||||||
|
3. Open share link in new incognito window
|
||||||
|
|
||||||
|
4. Verify ALL config matches:
|
||||||
|
- ✅ Total shows 2000 problems (100 × 20)
|
||||||
|
- ✅ Page count shows 100
|
||||||
|
- ✅ Digit range shows 3-4
|
||||||
|
- ✅ Display rules match original
|
||||||
|
- ✅ Problems are identical (same seed)
|
||||||
|
|
||||||
|
### Automated Test (TODO)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('extractConfigFields', () => {
|
||||||
|
it('excludes derived state', () => {
|
||||||
|
const formState = {
|
||||||
|
problemsPerPage: 20,
|
||||||
|
pages: 5,
|
||||||
|
total: 100, // Should be excluded
|
||||||
|
rows: 5, // Should be excluded
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = extractConfigFields(formState)
|
||||||
|
|
||||||
|
expect(config.problemsPerPage).toBe(20)
|
||||||
|
expect(config.pages).toBe(5)
|
||||||
|
expect(config.total).toBeUndefined()
|
||||||
|
expect(config.rows).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes seed and prngAlgorithm', () => {
|
||||||
|
const formState = {
|
||||||
|
seed: 12345,
|
||||||
|
prngAlgorithm: 'mulberry32',
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = extractConfigFields(formState)
|
||||||
|
|
||||||
|
expect(config.seed).toBe(12345)
|
||||||
|
expect(config.prngAlgorithm).toBe('mulberry32')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
- **`src/app/create/worksheets/utils/extractConfigFields.ts`** - Config extraction logic
|
||||||
|
- **`src/app/create/worksheets/validation.ts`** - Config validation and derived state calculation
|
||||||
|
- **`src/app/create/worksheets/types.ts`** - Type definitions (PRIMARY vs DERIVED)
|
||||||
|
- **`src/app/create/worksheets/config-schemas.ts`** - Zod schemas for validation
|
||||||
|
- **`src/hooks/useWorksheetAutoSave.ts`** - Auto-save to localStorage
|
||||||
|
- **`src/app/api/worksheets/share/route.ts`** - Share link creation API
|
||||||
|
- **`src/app/worksheets/shared/[id]/page.tsx`** - Shared worksheet viewer
|
||||||
|
|
||||||
|
## History
|
||||||
|
|
||||||
|
### 2025-01: Blacklist Refactor
|
||||||
|
|
||||||
|
**Problem:** Multiple incidents where new config fields weren't shared correctly because we forgot to update the extraction whitelist.
|
||||||
|
|
||||||
|
**Solution:** Refactored `extractConfigFields` to use blacklist approach (exclude derived fields) instead of whitelist (manually include everything).
|
||||||
|
|
||||||
|
**Result:** New config fields now automatically work in shared worksheets without touching extraction code.
|
||||||
|
|
||||||
|
### 2025-01: Total Calculation Bug
|
||||||
|
|
||||||
|
**Problem:** Shared 100-page worksheets displayed as 4 pages because validation defaulted `total` to 20 instead of calculating from `problemsPerPage × pages`.
|
||||||
|
|
||||||
|
**Solution:** Calculate `total` from primary state instead of using fallback:
|
||||||
|
```typescript
|
||||||
|
// Before (bug)
|
||||||
|
const total = formState.total ?? 20
|
||||||
|
|
||||||
|
// After (fix)
|
||||||
|
const total = problemsPerPage * pages
|
||||||
|
```
|
||||||
|
|
||||||
|
**Root Cause:** `extractConfigFields` didn't save `total` (correctly, as it's derived), but validation incorrectly treated it as primary state.
|
||||||
@@ -30,7 +30,12 @@ const config: StorybookConfig = {
|
|||||||
if (config.resolve) {
|
if (config.resolve) {
|
||||||
config.resolve.alias = {
|
config.resolve.alias = {
|
||||||
...config.resolve.alias,
|
...config.resolve.alias,
|
||||||
// Map styled-system imports to the actual directory
|
// Map @styled/* imports (from tsconfig paths)
|
||||||
|
'@styled/css': join(__dirname, '../styled-system/css/index.mjs'),
|
||||||
|
'@styled/patterns': join(__dirname, '../styled-system/patterns/index.mjs'),
|
||||||
|
'@styled/jsx': join(__dirname, '../styled-system/jsx/index.mjs'),
|
||||||
|
'@styled/recipes': join(__dirname, '../styled-system/recipes/index.mjs'),
|
||||||
|
// Map relative styled-system imports
|
||||||
'../../styled-system/css': join(__dirname, '../styled-system/css/index.mjs'),
|
'../../styled-system/css': join(__dirname, '../styled-system/css/index.mjs'),
|
||||||
'../../styled-system/patterns': join(__dirname, '../styled-system/patterns/index.mjs'),
|
'../../styled-system/patterns': join(__dirname, '../styled-system/patterns/index.mjs'),
|
||||||
'../styled-system/css': join(__dirname, '../styled-system/css/index.mjs'),
|
'../styled-system/css': join(__dirname, '../styled-system/css/index.mjs'),
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { Preview } from '@storybook/nextjs'
|
import type { Preview } from '@storybook/nextjs'
|
||||||
|
import React from 'react'
|
||||||
|
import { ThemeProvider } from '../src/contexts/ThemeContext'
|
||||||
import '../styled-system/styles.css'
|
import '../styled-system/styles.css'
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
@@ -10,6 +12,13 @@ const preview: Preview = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<ThemeProvider>
|
||||||
|
<Story />
|
||||||
|
</ThemeProvider>
|
||||||
|
),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export default preview
|
export default preview
|
||||||
@@ -228,6 +228,39 @@ export default defineConfig({
|
|||||||
boxShadow: '0 0 20px rgba(255, 255, 255, 0.8), 0 0 30px rgba(255, 255, 255, 0.6)',
|
boxShadow: '0 0 20px rgba(255, 255, 255, 0.8), 0 0 30px rgba(255, 255, 255, 0.6)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Wiggle - playful rotation oscillation
|
||||||
|
wiggle: {
|
||||||
|
'0%, 100%': { transform: 'rotate(0deg)' },
|
||||||
|
'25%': { transform: 'rotate(-3deg)' },
|
||||||
|
'75%': { transform: 'rotate(3deg)' },
|
||||||
|
},
|
||||||
|
// Shimmer - opacity wave for loading states
|
||||||
|
shimmer: {
|
||||||
|
'0%, 100%': { opacity: '0.7' },
|
||||||
|
'50%': { opacity: '0.4' },
|
||||||
|
},
|
||||||
|
// Fade in with scale - entrance animation
|
||||||
|
fadeInScale: {
|
||||||
|
'0%': { opacity: '0', transform: 'scale(0.9)' },
|
||||||
|
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||||
|
},
|
||||||
|
// Slide in from right - entrance animation
|
||||||
|
slideInRight: {
|
||||||
|
'0%': { transform: 'translateX(10px)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateX(0)', opacity: '1' },
|
||||||
|
},
|
||||||
|
// Morph width - bars dramatically expand and contract (simulating numbers changing)
|
||||||
|
morphWidth: {
|
||||||
|
'0%, 100%': { transform: 'scaleX(1)', opacity: '0.7' },
|
||||||
|
'25%': { transform: 'scaleX(0.5)', opacity: '0.4' },
|
||||||
|
'50%': { transform: 'scaleX(1.3)', opacity: '0.9' },
|
||||||
|
'75%': { transform: 'scaleX(0.7)', opacity: '0.5' },
|
||||||
|
},
|
||||||
|
// Color shift - operators cycle through hues
|
||||||
|
colorShift: {
|
||||||
|
'0%, 100%': { filter: 'hue-rotate(0deg)' },
|
||||||
|
'50%': { filter: 'hue-rotate(20deg)' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
177
apps/web/src/app/create/worksheets/README_CONFIG_PERSISTENCE.md
Normal file
177
apps/web/src/app/create/worksheets/README_CONFIG_PERSISTENCE.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Worksheet Config Persistence - Quick Reference
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
**When adding a new config field:**
|
||||||
|
- ✅ **Do nothing special!** The blacklist approach auto-includes new fields
|
||||||
|
- ✅ Only update if adding a **DERIVED** field (exclude it in `extractConfigFields.ts`)
|
||||||
|
- ✅ Add defaults in `validation.ts` if needed
|
||||||
|
- ✅ Test: Create → Share → Open share link → Verify field persists
|
||||||
|
|
||||||
|
## Field Categories
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// PRIMARY STATE (auto-persisted)
|
||||||
|
problemsPerPage: 20
|
||||||
|
pages: 5
|
||||||
|
cols: 4
|
||||||
|
// ... all other config fields
|
||||||
|
|
||||||
|
// DERIVED STATE (excluded from persistence)
|
||||||
|
total: 100 // = problemsPerPage × pages
|
||||||
|
rows: 5 // = Math.ceil(problemsPerPage / cols)
|
||||||
|
|
||||||
|
// EPHEMERAL STATE (excluded from persistence)
|
||||||
|
date: "Jan 15" // Generated fresh at render time
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Files
|
||||||
|
|
||||||
|
```
|
||||||
|
src/app/create/worksheets/
|
||||||
|
├── utils/
|
||||||
|
│ └── extractConfigFields.ts ← Blacklist: excludes rows, total, date
|
||||||
|
├── validation.ts ← Calculates derived state from primary
|
||||||
|
├── types.ts ← Documents field categories
|
||||||
|
└── README_CONFIG_PERSISTENCE.md ← This file
|
||||||
|
|
||||||
|
.claude/
|
||||||
|
└── WORKSHEET_CONFIG_PERSISTENCE.md ← Full architecture doc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Functions
|
||||||
|
|
||||||
|
### `extractConfigFields(formState)`
|
||||||
|
**What it does:** Prepares config for saving to localStorage/database
|
||||||
|
**How it works:** Excludes only `rows`, `total`, `date` (blacklist approach)
|
||||||
|
**Returns:** Config object with all primary state fields
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Usage in ShareModal
|
||||||
|
const config = extractConfigFields(formState)
|
||||||
|
await fetch('/api/worksheets/share', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ worksheetType: 'addition', config })
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### `validateWorksheetConfig(formState)`
|
||||||
|
**What it does:** Validates config and calculates derived state
|
||||||
|
**How it works:** Calculates `total = problemsPerPage × pages`, `rows = Math.ceil(problemsPerPage / cols)`
|
||||||
|
**Returns:** Validated config with both primary AND derived state
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Usage when loading shared worksheets
|
||||||
|
const validation = validateWorksheetConfig(loadedConfig)
|
||||||
|
if (!validation.isValid) {
|
||||||
|
console.error('Invalid config:', validation.errors)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Pattern 1: Adding a New Field
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Update schema (config-schemas.ts)
|
||||||
|
export const additionConfigV4Schema = z.object({
|
||||||
|
// ... existing fields
|
||||||
|
myNewField: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Update validation defaults (validation.ts)
|
||||||
|
const sharedFields = {
|
||||||
|
// ... existing fields
|
||||||
|
myNewField: formState.myNewField ?? 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Done! extractConfigFields auto-includes it
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Adding a Derived Field
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Calculate in validation.ts
|
||||||
|
const myDerivedValue = problemsPerPage / cols
|
||||||
|
|
||||||
|
// 2. Add to blacklist (extractConfigFields.ts)
|
||||||
|
const { rows, total, date, myDerivedValue, ...persistedFields } = formState
|
||||||
|
|
||||||
|
// 3. Document in types.ts
|
||||||
|
export type WorksheetFormState = /* ... */ & {
|
||||||
|
/** Derived: myDerivedValue = problemsPerPage / cols */
|
||||||
|
myDerivedValue?: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Bugs
|
||||||
|
|
||||||
|
### Bug: "My new field doesn't persist when shared"
|
||||||
|
**Old Cause:** Forgot to add field to whitelist
|
||||||
|
**Current:** Should auto-work with blacklist approach!
|
||||||
|
**Check:** Is the field derived/ephemeral? If yes, should it be excluded?
|
||||||
|
|
||||||
|
### Bug: "Shared worksheets show wrong page count"
|
||||||
|
**Cause:** Using `formState.total` instead of calculating from primary state
|
||||||
|
**Fix:** Always calculate: `total = problemsPerPage × pages`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ WRONG
|
||||||
|
const total = formState.total ?? 20
|
||||||
|
|
||||||
|
// ✅ CORRECT
|
||||||
|
const problemsPerPage = formState.problemsPerPage ?? 20
|
||||||
|
const pages = formState.pages ?? 1
|
||||||
|
const total = problemsPerPage * pages
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
When adding/modifying config fields:
|
||||||
|
|
||||||
|
- [ ] Create worksheet with new field
|
||||||
|
- [ ] Save config (auto-save triggers)
|
||||||
|
- [ ] Refresh page
|
||||||
|
- [ ] Verify field restored from localStorage
|
||||||
|
- [ ] Click "Share" to create share link
|
||||||
|
- [ ] Open share link in incognito window
|
||||||
|
- [ ] Verify field persists in shared worksheet
|
||||||
|
- [ ] Check console for extraction logs
|
||||||
|
|
||||||
|
## Debug Logs
|
||||||
|
|
||||||
|
Enable these console logs to debug config persistence:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In extractConfigFields.ts
|
||||||
|
console.log('[extractConfigFields] Extracted config:', {
|
||||||
|
fieldCount: Object.keys(config).length,
|
||||||
|
seed: config.seed,
|
||||||
|
pages: config.pages,
|
||||||
|
problemsPerPage: config.problemsPerPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
// In validation.ts
|
||||||
|
console.log('[validateWorksheetConfig] PRIMARY → DERIVED state:', {
|
||||||
|
problemsPerPage,
|
||||||
|
pages,
|
||||||
|
total,
|
||||||
|
hadTotal: formState.total !== undefined,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- **Full architecture:** `.claude/WORKSHEET_CONFIG_PERSISTENCE.md`
|
||||||
|
- **Inline docs:** `extractConfigFields.ts`, `validation.ts`, `types.ts`
|
||||||
|
- **Share creation:** `src/app/api/worksheets/share/route.ts`
|
||||||
|
- **Share loading:** `src/app/worksheets/shared/[id]/page.tsx`
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
If you encounter config persistence issues:
|
||||||
|
|
||||||
|
1. Check console logs for extraction/validation
|
||||||
|
2. Verify field category (PRIMARY vs DERIVED vs EPHEMERAL)
|
||||||
|
3. Read full architecture doc: `.claude/WORKSHEET_CONFIG_PERSISTENCE.md`
|
||||||
|
4. Check git history for `extractConfigFields.ts` - look for similar fixes
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { ConfigSidebar } from './ConfigSidebar'
|
||||||
|
import { ResponsivePanelLayout } from './ResponsivePanelLayout'
|
||||||
|
import { WorksheetConfigProvider } from './WorksheetConfigContext'
|
||||||
|
import { PagePlaceholder } from './PagePlaceholder'
|
||||||
|
import type { WorksheetFormState } from '../types'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Worksheets/Complete/Full Worksheet Generator',
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
},
|
||||||
|
tags: ['autodocs'],
|
||||||
|
} satisfies Meta
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
// Mock preview component that simulates virtual loading without API calls
|
||||||
|
function MockPreviewWithVirtualLoading({
|
||||||
|
pages = 5,
|
||||||
|
formState,
|
||||||
|
}: {
|
||||||
|
pages?: number
|
||||||
|
formState: WorksheetFormState
|
||||||
|
}) {
|
||||||
|
const [loadedPages, setLoadedPages] = useState(new Set([0]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'auto',
|
||||||
|
background: '#e5e7eb',
|
||||||
|
padding: '20px',
|
||||||
|
}}
|
||||||
|
onScroll={(e) => {
|
||||||
|
const container = e.currentTarget
|
||||||
|
const scrollTop = container.scrollTop
|
||||||
|
const clientHeight = container.clientHeight
|
||||||
|
|
||||||
|
// Simulate loading pages as they come into view
|
||||||
|
const pageHeight = 1104 // 1056px + 48px gap
|
||||||
|
const visiblePageStart = Math.floor(scrollTop / pageHeight)
|
||||||
|
const visiblePageEnd = Math.ceil((scrollTop + clientHeight) / pageHeight)
|
||||||
|
|
||||||
|
const newLoadedPages = new Set(loadedPages)
|
||||||
|
for (let i = visiblePageStart; i <= Math.min(visiblePageEnd, pages - 1); i++) {
|
||||||
|
if (!newLoadedPages.has(i)) {
|
||||||
|
newLoadedPages.add(i)
|
||||||
|
// Simulate async loading delay
|
||||||
|
setTimeout(() => {
|
||||||
|
setLoadedPages((prev) => new Set([...prev, i]))
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '48px', alignItems: 'center' }}>
|
||||||
|
{Array.from({ length: pages }).map((_, index) => {
|
||||||
|
const orientation = formState.orientation || 'portrait'
|
||||||
|
const maxWidth = orientation === 'portrait' ? '816px' : '1056px'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} style={{ width: '100%', maxWidth }}>
|
||||||
|
<PagePlaceholder
|
||||||
|
pageNumber={index + 1}
|
||||||
|
orientation={orientation}
|
||||||
|
rows={Math.ceil((formState.problemsPerPage || 20) / (formState.cols || 4))}
|
||||||
|
cols={formState.cols || 4}
|
||||||
|
loading={!loadedPages.has(index)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockFormState: WorksheetFormState = {
|
||||||
|
operator: 'addition',
|
||||||
|
mode: 'manual',
|
||||||
|
digitRange: { min: 2, max: 3 },
|
||||||
|
problemsPerPage: 20,
|
||||||
|
pages: 5,
|
||||||
|
cols: 4,
|
||||||
|
orientation: 'portrait',
|
||||||
|
name: 'Student Name',
|
||||||
|
displayRules: {
|
||||||
|
tenFrames: 'sometimes',
|
||||||
|
carryBoxes: 'sometimes',
|
||||||
|
placeValueColors: 'sometimes',
|
||||||
|
answerBoxes: 'always',
|
||||||
|
problemNumbers: 'always',
|
||||||
|
cellBorders: 'always',
|
||||||
|
borrowNotation: 'never',
|
||||||
|
borrowingHints: 'never',
|
||||||
|
},
|
||||||
|
pAnyStart: 0.3,
|
||||||
|
pAllStart: 0.1,
|
||||||
|
interpolate: false,
|
||||||
|
seed: 12345,
|
||||||
|
}
|
||||||
|
|
||||||
|
function FullWorksheetGenerator({ initialState }: { initialState?: Partial<WorksheetFormState> }) {
|
||||||
|
const [formState, setFormState] = useState<WorksheetFormState>({
|
||||||
|
...mockFormState,
|
||||||
|
...initialState,
|
||||||
|
})
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [lastSaved, setLastSaved] = useState<Date | null>(null)
|
||||||
|
|
||||||
|
const updateFormState = (updates: Partial<WorksheetFormState>) => {
|
||||||
|
setFormState((prev) => ({ ...prev, ...updates }))
|
||||||
|
|
||||||
|
// Simulate auto-save
|
||||||
|
setIsSaving(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsSaving(false)
|
||||||
|
setLastSaved(new Date())
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorksheetConfigProvider formState={formState} updateFormState={updateFormState}>
|
||||||
|
<div style={{ height: '100vh', width: '100vw' }}>
|
||||||
|
<ResponsivePanelLayout
|
||||||
|
config={formState}
|
||||||
|
sidebarContent={<ConfigSidebar isSaving={isSaving} lastSaved={lastSaved} />}
|
||||||
|
previewContent={
|
||||||
|
<MockPreviewWithVirtualLoading formState={formState} pages={formState.pages} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</WorksheetConfigProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CompleteGenerator: Story = {
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', width: '100vw' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '16px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 1000,
|
||||||
|
background: '#eff6ff',
|
||||||
|
padding: '12px 20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||||
|
maxWidth: '800px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>🎨 Complete Worksheet Studio:</strong> This is the full worksheet generator
|
||||||
|
interface with all features. Try adjusting settings in the sidebar and watch the preview
|
||||||
|
update. Resize the panels by dragging the divider. Scroll through pages to see virtual
|
||||||
|
loading in action.
|
||||||
|
</div>
|
||||||
|
<FullWorksheetGenerator />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VirtualLoadingDemo: Story = {
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', width: '100vw' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '16px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 1000,
|
||||||
|
background: '#fef3c7',
|
||||||
|
padding: '12px 20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||||
|
maxWidth: '800px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>⚡ Virtual Loading:</strong> This worksheet has 20 pages (400 problems). Notice
|
||||||
|
how only the first 3 pages load initially. As you scroll down in the preview panel, pages
|
||||||
|
load on-demand with a loading spinner, then display content. This keeps the app fast even
|
||||||
|
with hundreds of problems!
|
||||||
|
</div>
|
||||||
|
<FullWorksheetGenerator initialState={{ pages: 20 }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExtremeScaleTest: Story = {
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', width: '100vw' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '16px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 1000,
|
||||||
|
background: '#fee2e2',
|
||||||
|
padding: '12px 20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||||
|
maxWidth: '800px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>🚀 Extreme Scale:</strong> 50 pages × 20 problems = 1,000 problems! Virtual
|
||||||
|
loading + React Query caching make this performant. Only visible pages are loaded and
|
||||||
|
rendered. Scrolling remains smooth throughout.
|
||||||
|
</div>
|
||||||
|
<FullWorksheetGenerator initialState={{ pages: 50 }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdditionWorksheet: Story = {
|
||||||
|
render: () => (
|
||||||
|
<FullWorksheetGenerator
|
||||||
|
initialState={{
|
||||||
|
operator: 'addition',
|
||||||
|
mode: 'manual',
|
||||||
|
digitRange: { min: 2, max: 3 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SubtractionWorksheet: Story = {
|
||||||
|
render: () => (
|
||||||
|
<FullWorksheetGenerator
|
||||||
|
initialState={{
|
||||||
|
operator: 'subtraction',
|
||||||
|
mode: 'manual',
|
||||||
|
digitRange: { min: 2, max: 3 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MixedOperations: Story = {
|
||||||
|
render: () => (
|
||||||
|
<FullWorksheetGenerator
|
||||||
|
initialState={{
|
||||||
|
operator: 'mixed',
|
||||||
|
mode: 'manual',
|
||||||
|
digitRange: { min: 2, max: 3 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmartModeEarlyLearner: Story = {
|
||||||
|
render: () => (
|
||||||
|
<FullWorksheetGenerator
|
||||||
|
initialState={{
|
||||||
|
mode: 'smart',
|
||||||
|
difficultyProfile: 'earlyLearner',
|
||||||
|
pages: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmartModeAdvanced: Story = {
|
||||||
|
render: () => (
|
||||||
|
<FullWorksheetGenerator
|
||||||
|
initialState={{
|
||||||
|
mode: 'smart',
|
||||||
|
difficultyProfile: 'advanced',
|
||||||
|
pages: 5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LandscapeLayout: Story = {
|
||||||
|
render: () => (
|
||||||
|
<FullWorksheetGenerator
|
||||||
|
initialState={{
|
||||||
|
orientation: 'landscape',
|
||||||
|
cols: 5,
|
||||||
|
problemsPerPage: 25,
|
||||||
|
pages: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DenseLayout: Story = {
|
||||||
|
render: () => (
|
||||||
|
<FullWorksheetGenerator
|
||||||
|
initialState={{
|
||||||
|
problemsPerPage: 40,
|
||||||
|
cols: 5,
|
||||||
|
orientation: 'landscape',
|
||||||
|
pages: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SparseLayout: Story = {
|
||||||
|
render: () => (
|
||||||
|
<FullWorksheetGenerator
|
||||||
|
initialState={{
|
||||||
|
problemsPerPage: 10,
|
||||||
|
cols: 2,
|
||||||
|
pages: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SinglePageWorksheet: Story = {
|
||||||
|
render: () => (
|
||||||
|
<FullWorksheetGenerator
|
||||||
|
initialState={{
|
||||||
|
pages: 1,
|
||||||
|
problemsPerPage: 20,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResponsiveDemo: Story = {
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', width: '100vw' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '16px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 1000,
|
||||||
|
background: '#eff6ff',
|
||||||
|
padding: '12px 20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||||
|
maxWidth: '800px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>📱 Responsive Layout:</strong> On desktop, you see resizable panels (drag the
|
||||||
|
divider!). On mobile, the config panel becomes a drawer accessible via a floating button.
|
||||||
|
Try resizing your browser window to see the layout adapt.
|
||||||
|
</div>
|
||||||
|
<FullWorksheetGenerator />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AutoSaveDemo: Story = {
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', width: '100vw' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '16px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 1000,
|
||||||
|
background: '#d1fae5',
|
||||||
|
padding: '12px 20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||||
|
maxWidth: '800px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>💾 Auto-Save:</strong> Watch the top-right corner of the sidebar as you change
|
||||||
|
settings. You'll see "Saving..." appear briefly, then "✓ Saved" when complete. Settings
|
||||||
|
are automatically saved to localStorage so you can return to your work later.
|
||||||
|
</div>
|
||||||
|
<FullWorksheetGenerator />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ArchitectureOverview: Story = {
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: '20px',
|
||||||
|
gap: '20px',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#f3f4f6',
|
||||||
|
padding: '24px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
maxWidth: '1000px',
|
||||||
|
margin: '0 auto',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: '0 0 20px 0', fontSize: '24px', fontWeight: 600 }}>
|
||||||
|
Worksheet Generator Architecture
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: '20px',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 12px 0', fontSize: '16px', fontWeight: 600 }}>
|
||||||
|
🏗️ Component Hierarchy
|
||||||
|
</h3>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '20px', color: '#6b7280' }}>
|
||||||
|
<li>AdditionWorksheetClient (top-level)</li>
|
||||||
|
<li style={{ marginLeft: '20px' }}>ResponsivePanelLayout</li>
|
||||||
|
<li style={{ marginLeft: '40px' }}>ConfigSidebar</li>
|
||||||
|
<li style={{ marginLeft: '60px' }}>TabNavigation</li>
|
||||||
|
<li style={{ marginLeft: '60px' }}>ContentTab / LayoutTab / etc.</li>
|
||||||
|
<li style={{ marginLeft: '40px' }}>PreviewCenter</li>
|
||||||
|
<li style={{ marginLeft: '60px' }}>WorksheetPreview</li>
|
||||||
|
<li style={{ marginLeft: '80px' }}>PagePlaceholder (virtual)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 12px 0', fontSize: '16px', fontWeight: 600 }}>
|
||||||
|
⚡ Key Technologies
|
||||||
|
</h3>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '20px', color: '#6b7280' }}>
|
||||||
|
<li>React Query (caching, pagination)</li>
|
||||||
|
<li>Intersection Observer (virtualization)</li>
|
||||||
|
<li>react-resizable-panels (desktop layout)</li>
|
||||||
|
<li>Context API (form state management)</li>
|
||||||
|
<li>localStorage (auto-save persistence)</li>
|
||||||
|
<li>sessionStorage (tab selection)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 12px 0', fontSize: '16px', fontWeight: 600 }}>
|
||||||
|
📦 Virtual Loading System
|
||||||
|
</h3>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '20px', color: '#6b7280' }}>
|
||||||
|
<li>Initial batch: 3 pages</li>
|
||||||
|
<li>Triggers at 50% viewport distance</li>
|
||||||
|
<li>Preloads adjacent pages (±1)</li>
|
||||||
|
<li>Groups consecutive pages into batch requests</li>
|
||||||
|
<li>React Query deduplication</li>
|
||||||
|
<li>Handles 50+ pages smoothly</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 12px 0', fontSize: '16px', fontWeight: 600 }}>
|
||||||
|
💾 State Management
|
||||||
|
</h3>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '20px', color: '#6b7280' }}>
|
||||||
|
<li>formState (immediate updates)</li>
|
||||||
|
<li>debouncedFormState (preview updates)</li>
|
||||||
|
<li>Auto-save to localStorage (2s debounce)</li>
|
||||||
|
<li>Query cache invalidation on config change</li>
|
||||||
|
<li>Optimistic UI updates</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '20px',
|
||||||
|
padding: '16px',
|
||||||
|
background: '#eff6ff',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>🎯 Performance Optimization:</strong>
|
||||||
|
<p style={{ margin: '8px 0 0 0', color: '#3b82f6' }}>
|
||||||
|
Virtual loading + React Query caching + debounced updates + Intersection Observer =
|
||||||
|
Smooth UX even with 1,000+ problems. Only visible pages are rendered, adjacent pages
|
||||||
|
are preloaded, and fetched data is cached for instant scrolling back.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: '600px',
|
||||||
|
maxWidth: '1000px',
|
||||||
|
width: '100%',
|
||||||
|
margin: '0 auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FullWorksheetGenerator initialState={{ pages: 15 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { ConfigSidebar } from './ConfigSidebar'
|
||||||
|
import { WorksheetConfigProvider } from './WorksheetConfigContext'
|
||||||
|
import type { WorksheetFormState } from '../types'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Worksheets/Config Panel/ConfigSidebar',
|
||||||
|
component: ConfigSidebar,
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
},
|
||||||
|
tags: ['autodocs'],
|
||||||
|
} satisfies Meta<typeof ConfigSidebar>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
const mockFormState: WorksheetFormState = {
|
||||||
|
operator: 'addition',
|
||||||
|
mode: 'manual',
|
||||||
|
digitRange: { min: 2, max: 3 },
|
||||||
|
problemsPerPage: 20,
|
||||||
|
pages: 5,
|
||||||
|
cols: 4,
|
||||||
|
orientation: 'portrait',
|
||||||
|
name: 'Student Name',
|
||||||
|
displayRules: {
|
||||||
|
tenFrames: 'sometimes',
|
||||||
|
carryBoxes: 'sometimes',
|
||||||
|
placeValueColors: 'sometimes',
|
||||||
|
answerBoxes: 'always',
|
||||||
|
problemNumbers: 'always',
|
||||||
|
cellBorders: 'always',
|
||||||
|
borrowNotation: 'never',
|
||||||
|
borrowingHints: 'never',
|
||||||
|
},
|
||||||
|
pAnyStart: 0.3,
|
||||||
|
pAllStart: 0.1,
|
||||||
|
interpolate: false,
|
||||||
|
seed: 12345,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapper to provide context
|
||||||
|
function SidebarWrapper({
|
||||||
|
initialState,
|
||||||
|
isSaving = false,
|
||||||
|
lastSaved = null,
|
||||||
|
isReadOnly = false,
|
||||||
|
}: {
|
||||||
|
initialState?: Partial<WorksheetFormState>
|
||||||
|
isSaving?: boolean
|
||||||
|
lastSaved?: Date | null
|
||||||
|
isReadOnly?: boolean
|
||||||
|
}) {
|
||||||
|
const [formState, setFormState] = useState<WorksheetFormState>({
|
||||||
|
...mockFormState,
|
||||||
|
...initialState,
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateFormState = (updates: Partial<WorksheetFormState>) => {
|
||||||
|
setFormState((prev) => ({ ...prev, ...updates }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', width: '400px', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<WorksheetConfigProvider formState={formState} updateFormState={updateFormState}>
|
||||||
|
<ConfigSidebar isSaving={isSaving} lastSaved={lastSaved} isReadOnly={isReadOnly} />
|
||||||
|
</WorksheetConfigProvider>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DefaultState: Story = {
|
||||||
|
render: () => <SidebarWrapper />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Saving: Story = {
|
||||||
|
render: () => <SidebarWrapper isSaving={true} />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Saved: Story = {
|
||||||
|
render: () => <SidebarWrapper lastSaved={new Date()} />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReadOnly: Story = {
|
||||||
|
render: () => <SidebarWrapper isReadOnly={true} />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdditionMode: Story = {
|
||||||
|
render: () => <SidebarWrapper initialState={{ operator: 'addition' }} />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SubtractionMode: Story = {
|
||||||
|
render: () => <SidebarWrapper initialState={{ operator: 'subtraction' }} />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MixedMode: Story = {
|
||||||
|
render: () => <SidebarWrapper initialState={{ operator: 'mixed' }} />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmartMode: Story = {
|
||||||
|
render: () => (
|
||||||
|
<SidebarWrapper
|
||||||
|
initialState={{
|
||||||
|
mode: 'smart',
|
||||||
|
difficultyProfile: 'earlyLearner',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MasteryMode: Story = {
|
||||||
|
render: () => (
|
||||||
|
<SidebarWrapper
|
||||||
|
initialState={{
|
||||||
|
mode: 'mastery',
|
||||||
|
currentAdditionSkillId: 'add-2digit-no-regroup',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LandscapeLayout: Story = {
|
||||||
|
render: () => (
|
||||||
|
<SidebarWrapper
|
||||||
|
initialState={{
|
||||||
|
orientation: 'landscape',
|
||||||
|
cols: 5,
|
||||||
|
problemsPerPage: 25,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InteractiveTabs: Story = {
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', display: 'flex', gap: '20px', padding: '20px' }}>
|
||||||
|
<div style={{ width: '400px' }}>
|
||||||
|
<SidebarWrapper />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: '#f3f4f6',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '20px',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: 600 }}>
|
||||||
|
Tab Navigation
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', fontSize: '14px' }}>
|
||||||
|
<div>
|
||||||
|
<strong>📝 Content Tab:</strong>
|
||||||
|
<p style={{ margin: '4px 0 0 0', color: '#6b7280' }}>
|
||||||
|
Choose operator (Addition, Subtraction, or Mixed) and select mode (Manual, Smart, or
|
||||||
|
Mastery).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>📐 Layout Tab:</strong>
|
||||||
|
<p style={{ margin: '4px 0 0 0', color: '#6b7280' }}>
|
||||||
|
Configure page orientation, problems per page, number of pages, and column layout.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>🎯 Scaffolding Tab:</strong>
|
||||||
|
<p style={{ margin: '4px 0 0 0', color: '#6b7280' }}>
|
||||||
|
Control when visual aids appear (ten-frames, carry boxes, place value colors, etc.).
|
||||||
|
Set rules like "always", "never", or "when regrouping".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>⚡ Difficulty Tab:</strong>
|
||||||
|
<p style={{ margin: '4px 0 0 0', color: '#6b7280' }}>
|
||||||
|
Adjust regrouping frequency, difficulty presets, and progressive difficulty
|
||||||
|
settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '16px',
|
||||||
|
padding: '12px',
|
||||||
|
background: '#eff6ff',
|
||||||
|
borderRadius: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>💡 Pro Tip:</strong>
|
||||||
|
<p style={{ margin: '4px 0 0 0', color: '#3b82f6', fontSize: '13px' }}>
|
||||||
|
Active tab is saved to sessionStorage. When you return, the same tab will be
|
||||||
|
selected.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllTabsShowcase: Story = {
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100vh',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||||
|
gap: '20px',
|
||||||
|
padding: '20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{['operator', 'layout', 'scaffolding', 'difficulty'].map((tab) => {
|
||||||
|
// Force tab by manipulating sessionStorage before mount
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
sessionStorage.setItem('worksheet-config-active-tab', tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={tab} style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#1f2937',
|
||||||
|
color: 'white',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '6px 6px 0 0',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab} Tab
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, border: '2px solid #1f2937', borderTop: 'none' }}>
|
||||||
|
<SidebarWrapper />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ComparisonReadOnlyVsEditable: Story = {
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', display: 'flex', gap: '20px', padding: '20px' }}>
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#3b82f6',
|
||||||
|
color: 'white',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✏️ Editable
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, border: '2px solid #3b82f6', borderTop: 'none' }}>
|
||||||
|
<SidebarWrapper isReadOnly={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#6b7280',
|
||||||
|
color: 'white',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
👁️ Read-Only
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, border: '2px solid #6b7280', borderTop: 'none' }}>
|
||||||
|
<SidebarWrapper isReadOnly={true} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DenseProblemsLayout: Story = {
|
||||||
|
render: () => (
|
||||||
|
<SidebarWrapper
|
||||||
|
initialState={{
|
||||||
|
problemsPerPage: 40,
|
||||||
|
cols: 5,
|
||||||
|
orientation: 'landscape',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MinimalProblems: Story = {
|
||||||
|
render: () => (
|
||||||
|
<SidebarWrapper
|
||||||
|
initialState={{
|
||||||
|
problemsPerPage: 6,
|
||||||
|
cols: 2,
|
||||||
|
pages: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithStudentName: Story = {
|
||||||
|
render: () => <SidebarWrapper initialState={{ name: 'Alex Martinez' }} />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmptyStudentName: Story = {
|
||||||
|
render: () => <SidebarWrapper initialState={{ name: '' }} />,
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { PagePlaceholder } from './PagePlaceholder'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Worksheets/Virtual Loading/PagePlaceholder',
|
||||||
|
component: PagePlaceholder,
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
tags: ['autodocs'],
|
||||||
|
} satisfies Meta<typeof PagePlaceholder>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Portrait: Story = {
|
||||||
|
args: {
|
||||||
|
pageNumber: 1,
|
||||||
|
orientation: 'portrait',
|
||||||
|
rows: 5,
|
||||||
|
cols: 4,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Landscape: Story = {
|
||||||
|
args: {
|
||||||
|
pageNumber: 1,
|
||||||
|
orientation: 'landscape',
|
||||||
|
rows: 4,
|
||||||
|
cols: 5,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoadingState: Story = {
|
||||||
|
args: {
|
||||||
|
pageNumber: 5,
|
||||||
|
orientation: 'portrait',
|
||||||
|
rows: 5,
|
||||||
|
cols: 4,
|
||||||
|
loading: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoadingLandscape: Story = {
|
||||||
|
args: {
|
||||||
|
pageNumber: 8,
|
||||||
|
orientation: 'landscape',
|
||||||
|
rows: 4,
|
||||||
|
cols: 5,
|
||||||
|
loading: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TwoColumns: Story = {
|
||||||
|
args: {
|
||||||
|
pageNumber: 2,
|
||||||
|
orientation: 'portrait',
|
||||||
|
rows: 10,
|
||||||
|
cols: 2,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FiveColumns: Story = {
|
||||||
|
args: {
|
||||||
|
pageNumber: 3,
|
||||||
|
orientation: 'landscape',
|
||||||
|
rows: 3,
|
||||||
|
cols: 5,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VirtualScrollSimulation: Story = {
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '48px',
|
||||||
|
padding: '20px',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ marginBottom: '16px', fontSize: '16px', fontWeight: 600 }}>
|
||||||
|
Virtual Scroll: Multi-Page Worksheet Loading
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
style={{ marginBottom: '24px', fontSize: '13px', color: '#6b7280', maxWidth: '600px' }}
|
||||||
|
>
|
||||||
|
This demonstrates how placeholder pages appear during virtual scrolling. Pages load
|
||||||
|
on-demand as they become visible in the viewport, showing a loading state while the SVG
|
||||||
|
is being generated and fetched.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page 1 - Already loaded */}
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '8px', fontSize: '12px', fontWeight: 600, color: '#059669' }}>
|
||||||
|
✓ Loaded
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '2px solid #10b981',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px',
|
||||||
|
background: '#f0fdf4',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PagePlaceholder
|
||||||
|
pageNumber={1}
|
||||||
|
orientation="portrait"
|
||||||
|
rows={5}
|
||||||
|
cols={4}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page 2 - Loading */}
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '8px', fontSize: '12px', fontWeight: 600, color: '#f59e0b' }}>
|
||||||
|
⏳ Loading...
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '2px solid #f59e0b',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px',
|
||||||
|
background: '#fffbeb',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PagePlaceholder
|
||||||
|
pageNumber={2}
|
||||||
|
orientation="portrait"
|
||||||
|
rows={5}
|
||||||
|
cols={4}
|
||||||
|
loading={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page 3 - Not yet visible */}
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '8px', fontSize: '12px', fontWeight: 600, color: '#6b7280' }}>
|
||||||
|
⏸ Not yet loaded
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '2px solid #d1d5db',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px',
|
||||||
|
background: '#f9fafb',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PagePlaceholder
|
||||||
|
pageNumber={3}
|
||||||
|
orientation="portrait"
|
||||||
|
rows={5}
|
||||||
|
cols={4}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DenseLayout: Story = {
|
||||||
|
args: {
|
||||||
|
pageNumber: 1,
|
||||||
|
orientation: 'portrait',
|
||||||
|
rows: 8,
|
||||||
|
cols: 3,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SparseLayout: Story = {
|
||||||
|
args: {
|
||||||
|
pageNumber: 1,
|
||||||
|
orientation: 'landscape',
|
||||||
|
rows: 2,
|
||||||
|
cols: 4,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ComparisonView: Story = {
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '48px', flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<h4 style={{ marginBottom: '12px', fontSize: '14px', fontWeight: 600 }}>Portrait</h4>
|
||||||
|
<p style={{ marginBottom: '16px', fontSize: '12px', color: '#6b7280' }}>8.5" × 11"</p>
|
||||||
|
<PagePlaceholder
|
||||||
|
pageNumber={1}
|
||||||
|
orientation="portrait"
|
||||||
|
rows={5}
|
||||||
|
cols={4}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<h4 style={{ marginBottom: '12px', fontSize: '14px', fontWeight: 600 }}>Landscape</h4>
|
||||||
|
<p style={{ marginBottom: '16px', fontSize: '12px', color: '#6b7280' }}>11" × 8.5"</p>
|
||||||
|
<PagePlaceholder
|
||||||
|
pageNumber={1}
|
||||||
|
orientation="landscape"
|
||||||
|
rows={4}
|
||||||
|
cols={5}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoadingAnimation: Story = {
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '16px',
|
||||||
|
padding: '12px',
|
||||||
|
background: '#eff6ff',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '13px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>ℹ️ Loading Behavior:</strong>
|
||||||
|
<p style={{ margin: '8px 0 0 0' }}>
|
||||||
|
Notice the spinning hourglass emoji and "Loading page X..." text. The entire placeholder
|
||||||
|
pulses to indicate active loading. This appears when pages are being fetched from the
|
||||||
|
API.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<PagePlaceholder pageNumber={7} orientation="portrait" rows={5} cols={4} loading={true} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { css } from '@styled/css'
|
import { css } from '@styled/css'
|
||||||
import { useTheme } from '@/contexts/ThemeContext'
|
import { useTheme } from '@/contexts/ThemeContext'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
interface PagePlaceholderProps {
|
interface PagePlaceholderProps {
|
||||||
pageNumber: number
|
pageNumber: number
|
||||||
@@ -11,6 +12,293 @@ interface PagePlaceholderProps {
|
|||||||
loading?: boolean
|
loading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProblemVariation {
|
||||||
|
topWidths: [number, number]
|
||||||
|
bottomWidths: [number, number]
|
||||||
|
operator: '+' | '−'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnimatedProblemCellProps {
|
||||||
|
seed: number
|
||||||
|
isDark: boolean
|
||||||
|
animationDelay: string
|
||||||
|
wiggleDelay: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnimatedProblemCell({
|
||||||
|
seed,
|
||||||
|
isDark,
|
||||||
|
animationDelay,
|
||||||
|
wiggleDelay,
|
||||||
|
}: AnimatedProblemCellProps) {
|
||||||
|
// Generate multiple problem variations to cycle through
|
||||||
|
const problems: ProblemVariation[] = [
|
||||||
|
{
|
||||||
|
topWidths: [55 + ((seed * 7) % 25), 50 + ((seed * 11) % 30)],
|
||||||
|
bottomWidths: [55 + ((seed * 13) % 25), 50 + ((seed * 19) % 30)],
|
||||||
|
operator: '+',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topWidths: [45 + ((seed * 17) % 30), 60 + ((seed * 23) % 20)],
|
||||||
|
bottomWidths: [40 + ((seed * 29) % 35), 55 + ((seed * 31) % 25)],
|
||||||
|
operator: '−',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topWidths: [65 + ((seed * 37) % 15), 45 + ((seed * 41) % 30)],
|
||||||
|
bottomWidths: [50 + ((seed * 43) % 30), 60 + ((seed * 47) % 20)],
|
||||||
|
operator: '+',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const showCarryBoxes = seed % 4 === 0
|
||||||
|
|
||||||
|
// State to track which problem variation is currently shown
|
||||||
|
const [currentProblemIndex, setCurrentProblemIndex] = useState(0)
|
||||||
|
|
||||||
|
// Cycle through problems with staggered timing based on seed
|
||||||
|
useEffect(() => {
|
||||||
|
// Each cell cycles at a slightly different rate (0.5-1 seconds) - super fast!
|
||||||
|
const cycleInterval = 500 + (seed % 10) * 50
|
||||||
|
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
setCurrentProblemIndex((prev) => (prev + 1) % problems.length)
|
||||||
|
}, cycleInterval)
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId)
|
||||||
|
}, [seed, problems.length])
|
||||||
|
|
||||||
|
const currentProblem = problems[currentProblemIndex]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
animationDelay: animationDelay,
|
||||||
|
}}
|
||||||
|
className={css({
|
||||||
|
flex: 1,
|
||||||
|
bg: isDark ? 'gray.700' : 'gray.200',
|
||||||
|
border: '3px dashed',
|
||||||
|
borderColor: isDark ? 'gray.600' : 'gray.400',
|
||||||
|
rounded: 'lg',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: '4',
|
||||||
|
gap: '3',
|
||||||
|
position: 'relative',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
animation: 'fadeInScale 0.6s ease-out',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
_hover: {
|
||||||
|
transform: 'scale(1.02)',
|
||||||
|
borderColor: isDark ? 'gray.500' : 'gray.500',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* Top section: problem number + optional carry boxes */}
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
minHeight: '16px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* Problem number - bouncy circle */}
|
||||||
|
<div
|
||||||
|
style={{ animationDelay: wiggleDelay }}
|
||||||
|
className={css({
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
bg: isDark ? 'blue.500' : 'blue.500',
|
||||||
|
rounded: 'full',
|
||||||
|
opacity: 0.85,
|
||||||
|
animation: 'bounce 2s ease-in-out infinite',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Optional carry boxes - wiggle animation */}
|
||||||
|
{showCarryBoxes && (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: '3px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ animationDelay: wiggleDelay }}
|
||||||
|
className={css({
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
border: '3px dashed',
|
||||||
|
borderColor: isDark ? 'purple.400' : 'purple.500',
|
||||||
|
rounded: 'sm',
|
||||||
|
opacity: 0.75,
|
||||||
|
animation: 'wiggle 1.5s ease-in-out infinite',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{ animationDelay: `${parseFloat(wiggleDelay) + 0.2}s` }}
|
||||||
|
className={css({
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
border: '3px dashed',
|
||||||
|
borderColor: isDark ? 'purple.400' : 'purple.500',
|
||||||
|
rounded: 'sm',
|
||||||
|
opacity: 0.75,
|
||||||
|
animation: 'wiggle 1.5s ease-in-out infinite',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Middle section: cartoonish number representations */}
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '4',
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'opacity 0.3s ease-in-out',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* Top operand - bars with widths from current problem */}
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '4px',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
key={`top1-${currentProblemIndex}`}
|
||||||
|
style={{ animationDelay: animationDelay }}
|
||||||
|
className={css({
|
||||||
|
width: `${currentProblem.topWidths[0]}%`,
|
||||||
|
height: '14px',
|
||||||
|
bg: isDark ? 'cyan.400' : 'cyan.500',
|
||||||
|
rounded: 'full',
|
||||||
|
opacity: 0.9,
|
||||||
|
transition: 'width 0.4s ease-in-out',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
key={`top2-${currentProblemIndex}`}
|
||||||
|
style={{ animationDelay: `${parseFloat(animationDelay) + 0.2}s` }}
|
||||||
|
className={css({
|
||||||
|
width: `${currentProblem.topWidths[1]}%`,
|
||||||
|
height: '14px',
|
||||||
|
bg: isDark ? 'cyan.400' : 'cyan.500',
|
||||||
|
rounded: 'full',
|
||||||
|
opacity: 0.9,
|
||||||
|
transition: 'width 0.4s ease-in-out',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Operator + bottom operand - playful layout */}
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '2',
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
width: '75%',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* Operator symbol - cycles between + and − */}
|
||||||
|
<div
|
||||||
|
key={`op-${currentProblemIndex}`}
|
||||||
|
style={{ animationDelay: `${parseFloat(wiggleDelay) + 0.1}s` }}
|
||||||
|
className={css({
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 'black',
|
||||||
|
color: isDark ? 'orange.400' : 'orange.600',
|
||||||
|
opacity: 0.95,
|
||||||
|
animation: 'colorShift 2s ease-in-out infinite',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minWidth: '20px',
|
||||||
|
transition: 'opacity 0.3s ease-in-out',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{currentProblem.operator}
|
||||||
|
</div>
|
||||||
|
{/* Bottom operand bars - widths from current problem */}
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '4px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
key={`bottom1-${currentProblemIndex}`}
|
||||||
|
style={{ animationDelay: `${parseFloat(animationDelay) + 0.3}s` }}
|
||||||
|
className={css({
|
||||||
|
width: `${currentProblem.bottomWidths[0]}%`,
|
||||||
|
height: '14px',
|
||||||
|
bg: isDark ? 'green.400' : 'green.500',
|
||||||
|
rounded: 'full',
|
||||||
|
opacity: 0.9,
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
transition: 'width 0.4s ease-in-out',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
key={`bottom2-${currentProblemIndex}`}
|
||||||
|
style={{ animationDelay: `${parseFloat(animationDelay) + 0.4}s` }}
|
||||||
|
className={css({
|
||||||
|
width: `${currentProblem.bottomWidths[1]}%`,
|
||||||
|
height: '14px',
|
||||||
|
bg: isDark ? 'green.400' : 'green.500',
|
||||||
|
rounded: 'full',
|
||||||
|
opacity: 0.9,
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
transition: 'width 0.4s ease-in-out',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Horizontal rule - animated wave */}
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
width: '90%',
|
||||||
|
height: '4px',
|
||||||
|
bg: isDark ? 'gray.500' : 'gray.600',
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
my: '2',
|
||||||
|
opacity: 0.85,
|
||||||
|
rounded: 'full',
|
||||||
|
animation: 'slideInRight 0.8s ease-out',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Answer area - glowing box */}
|
||||||
|
<div
|
||||||
|
style={{ animationDelay: `${parseFloat(animationDelay) + 0.5}s` }}
|
||||||
|
className={css({
|
||||||
|
width: '90%',
|
||||||
|
height: '20px',
|
||||||
|
bg: isDark ? 'gray.700' : 'white',
|
||||||
|
border: '3px dashed',
|
||||||
|
borderColor: isDark ? 'yellow.500' : 'yellow.500',
|
||||||
|
rounded: 'lg',
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
opacity: 0.85,
|
||||||
|
animation: 'glow 2.5s ease-in-out infinite',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function PagePlaceholder({
|
export function PagePlaceholder({
|
||||||
pageNumber,
|
pageNumber,
|
||||||
orientation = 'portrait',
|
orientation = 'portrait',
|
||||||
@@ -21,21 +309,21 @@ export function PagePlaceholder({
|
|||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
const isDark = resolvedTheme === 'dark'
|
const isDark = resolvedTheme === 'dark'
|
||||||
|
|
||||||
// Calculate exact pixel dimensions based on page size
|
// Calculate exact pixel dimensions and aspect ratios based on page size
|
||||||
// Portrait: 8.5" × 11" at 96 DPI = 816px × 1056px
|
// Portrait: 8.5" × 11" at 96 DPI = 816px × 1056px (aspect ratio: 8.5/11)
|
||||||
// Landscape: 11" × 8.5" at 96 DPI = 1056px × 816px
|
// Landscape: 11" × 8.5" at 96 DPI = 1056px × 816px (aspect ratio: 11/8.5)
|
||||||
// Scale down to fit typical viewport (maxWidth: 100%)
|
// Use max-width + aspect-ratio for responsive sizing that maintains proportions
|
||||||
const width = orientation === 'portrait' ? 816 : 1056
|
const maxWidth = orientation === 'portrait' ? 816 : 1056
|
||||||
const height = orientation === 'portrait' ? 1056 : 816
|
const aspectRatio = orientation === 'portrait' ? '8.5 / 11' : '11 / 8.5'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-component="page-placeholder"
|
data-component="page-placeholder"
|
||||||
data-page-number={pageNumber}
|
data-page-number={pageNumber}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
width: '100%',
|
||||||
width: `${width}px`,
|
maxWidth: `${maxWidth}px`,
|
||||||
height: `${height}px`,
|
aspectRatio: aspectRatio,
|
||||||
}}
|
}}
|
||||||
className={css({
|
className={css({
|
||||||
bg: isDark ? 'gray.800' : 'gray.100',
|
bg: isDark ? 'gray.800' : 'gray.100',
|
||||||
@@ -96,140 +384,25 @@ export function PagePlaceholder({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{Array.from({ length: cols }).map((_, colIndex) => (
|
{Array.from({ length: cols }).map((_, colIndex) => {
|
||||||
<div
|
// Generate pseudo-random but consistent properties for visual variety
|
||||||
key={colIndex}
|
const seed = rowIndex * cols + colIndex
|
||||||
className={css({
|
const animationDelay = `${(seed % 10) * 0.1}s`
|
||||||
flex: 1,
|
const wiggleDelay = `${(seed % 8) * 0.15}s`
|
||||||
bg: isDark ? 'gray.700' : 'gray.300',
|
|
||||||
border: '1px solid',
|
return (
|
||||||
borderColor: isDark ? 'gray.600' : 'gray.400',
|
<AnimatedProblemCell
|
||||||
rounded: 'md',
|
key={colIndex}
|
||||||
display: 'flex',
|
seed={seed}
|
||||||
flexDirection: 'column',
|
isDark={isDark}
|
||||||
padding: '2',
|
animationDelay={animationDelay}
|
||||||
gap: '1',
|
wiggleDelay={wiggleDelay}
|
||||||
})}
|
|
||||||
>
|
|
||||||
{/* Problem number */}
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
width: '20%',
|
|
||||||
height: '2',
|
|
||||||
bg: isDark ? 'gray.600' : 'gray.500',
|
|
||||||
rounded: 'xs',
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
{/* Top operand */}
|
)
|
||||||
<div
|
})}
|
||||||
className={css({
|
|
||||||
width: '60%',
|
|
||||||
height: '3',
|
|
||||||
bg: isDark ? 'gray.600' : 'gray.500',
|
|
||||||
rounded: 'xs',
|
|
||||||
alignSelf: 'flex-end',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
{/* Bottom operand */}
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
width: '60%',
|
|
||||||
height: '3',
|
|
||||||
bg: isDark ? 'gray.600' : 'gray.500',
|
|
||||||
rounded: 'xs',
|
|
||||||
alignSelf: 'flex-end',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
{/* Answer line */}
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
width: '60%',
|
|
||||||
height: '1px',
|
|
||||||
bg: isDark ? 'gray.600' : 'gray.500',
|
|
||||||
alignSelf: 'flex-end',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page info overlay */}
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
position: 'absolute',
|
|
||||||
top: '50%',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '2',
|
|
||||||
zIndex: 1,
|
|
||||||
bg: isDark ? 'rgba(31, 41, 55, 0.95)' : 'rgba(243, 244, 246, 0.95)',
|
|
||||||
px: '6',
|
|
||||||
py: '4',
|
|
||||||
rounded: 'lg',
|
|
||||||
border: '2px solid',
|
|
||||||
borderColor: isDark ? 'gray.600' : 'gray.400',
|
|
||||||
backdropFilter: 'blur(4px)',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
fontSize: '3xl',
|
|
||||||
color: isDark ? 'gray.500' : 'gray.400',
|
|
||||||
animation: 'spin',
|
|
||||||
animationDuration: '1s',
|
|
||||||
animationTimingFunction: 'linear',
|
|
||||||
animationIterationCount: 'infinite',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
⏳
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
fontSize: 'lg',
|
|
||||||
fontWeight: 'semibold',
|
|
||||||
color: isDark ? 'gray.300' : 'gray.700',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
Loading page {pageNumber}...
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
fontSize: '3xl',
|
|
||||||
color: isDark ? 'gray.500' : 'gray.400',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
📄
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
fontSize: 'lg',
|
|
||||||
fontWeight: 'semibold',
|
|
||||||
color: isDark ? 'gray.300' : 'gray.700',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
Page {pageNumber}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
fontSize: 'sm',
|
|
||||||
color: isDark ? 'gray.400' : 'gray.600',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
Scroll to load
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { ResponsivePanelLayout } from './ResponsivePanelLayout'
|
||||||
|
import { PagePlaceholder } from './PagePlaceholder'
|
||||||
|
import type { WorksheetFormState } from '../types'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Worksheets/Layout/ResponsivePanelLayout',
|
||||||
|
component: ResponsivePanelLayout,
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
},
|
||||||
|
tags: ['autodocs'],
|
||||||
|
} satisfies Meta<typeof ResponsivePanelLayout>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
// Mock sidebar content
|
||||||
|
function MockSidebar() {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px', background: '#f9fafb', height: '100%' }}>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '14px', fontWeight: 600 }}>
|
||||||
|
Worksheet Settings
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{ fontSize: '12px', fontWeight: 500, display: 'block', marginBottom: '4px' }}
|
||||||
|
>
|
||||||
|
Operator
|
||||||
|
</label>
|
||||||
|
<select style={{ width: '100%', padding: '6px', fontSize: '12px' }}>
|
||||||
|
<option>Addition (+)</option>
|
||||||
|
<option>Subtraction (−)</option>
|
||||||
|
<option>Mixed (±)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{ fontSize: '12px', fontWeight: 500, display: 'block', marginBottom: '4px' }}
|
||||||
|
>
|
||||||
|
Digit Range
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={2}
|
||||||
|
style={{ width: '60px', padding: '6px', fontSize: '12px' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '12px' }}>to</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={3}
|
||||||
|
style={{ width: '60px', padding: '6px', fontSize: '12px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{ fontSize: '12px', fontWeight: 500, display: 'block', marginBottom: '4px' }}
|
||||||
|
>
|
||||||
|
Problems Per Page
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={20}
|
||||||
|
style={{ width: '100%', padding: '6px', fontSize: '12px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{ fontSize: '12px', fontWeight: 500, display: 'block', marginBottom: '4px' }}
|
||||||
|
>
|
||||||
|
Pages
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={5}
|
||||||
|
style={{ width: '100%', padding: '6px', fontSize: '12px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock preview content with virtual loading demonstration
|
||||||
|
function MockPreviewWithVirtualLoading({ pages = 5 }: { pages?: number }) {
|
||||||
|
const [loadedPages, setLoadedPages] = useState(new Set([0]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'auto',
|
||||||
|
background: '#e5e7eb',
|
||||||
|
padding: '20px',
|
||||||
|
}}
|
||||||
|
onScroll={(e) => {
|
||||||
|
const container = e.currentTarget
|
||||||
|
const scrollTop = container.scrollTop
|
||||||
|
const clientHeight = container.clientHeight
|
||||||
|
|
||||||
|
// Simulate loading pages as they come into view
|
||||||
|
// Each page is about 1056px + 48px gap = 1104px
|
||||||
|
const pageHeight = 1104
|
||||||
|
const visiblePageStart = Math.floor(scrollTop / pageHeight)
|
||||||
|
const visiblePageEnd = Math.ceil((scrollTop + clientHeight) / pageHeight)
|
||||||
|
|
||||||
|
const newLoadedPages = new Set(loadedPages)
|
||||||
|
for (let i = visiblePageStart; i <= Math.min(visiblePageEnd, pages - 1); i++) {
|
||||||
|
if (!newLoadedPages.has(i)) {
|
||||||
|
newLoadedPages.add(i)
|
||||||
|
// Simulate async loading delay
|
||||||
|
setTimeout(() => {
|
||||||
|
setLoadedPages((prev) => new Set([...prev, i]))
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '48px', alignItems: 'center' }}>
|
||||||
|
{Array.from({ length: pages }).map((_, index) => (
|
||||||
|
<div key={index} style={{ width: '100%', maxWidth: '816px' }}>
|
||||||
|
<PagePlaceholder
|
||||||
|
pageNumber={index + 1}
|
||||||
|
orientation="portrait"
|
||||||
|
rows={5}
|
||||||
|
cols={4}
|
||||||
|
loading={!loadedPages.has(index)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockConfig: Partial<WorksheetFormState> = {
|
||||||
|
operator: 'addition',
|
||||||
|
digitRange: { min: 2, max: 3 },
|
||||||
|
problemsPerPage: 20,
|
||||||
|
pages: 5,
|
||||||
|
cols: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DesktopLayout: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ height: '100vh', width: '100vw' }}>
|
||||||
|
<ResponsivePanelLayout
|
||||||
|
config={mockConfig}
|
||||||
|
sidebarContent={<MockSidebar />}
|
||||||
|
previewContent={<MockPreviewWithVirtualLoading pages={5} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithVirtualLoading: Story = {
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', width: '100vw' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '16px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 1000,
|
||||||
|
background: '#eff6ff',
|
||||||
|
padding: '12px 20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||||
|
maxWidth: '600px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>🎯 Virtual Loading Demo:</strong> Scroll down in the preview panel to see pages
|
||||||
|
load on-demand. Pages show a loading state (spinning hourglass) while being fetched, then
|
||||||
|
display the placeholder once loaded.
|
||||||
|
</div>
|
||||||
|
<ResponsivePanelLayout
|
||||||
|
config={mockConfig}
|
||||||
|
sidebarContent={<MockSidebar />}
|
||||||
|
previewContent={<MockPreviewWithVirtualLoading pages={20} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SinglePage: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ height: '100vh', width: '100vw' }}>
|
||||||
|
<ResponsivePanelLayout
|
||||||
|
config={{ ...mockConfig, pages: 1 }}
|
||||||
|
sidebarContent={<MockSidebar />}
|
||||||
|
previewContent={<MockPreviewWithVirtualLoading pages={1} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ManyPages: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ height: '100vh', width: '100vw' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '16px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 1000,
|
||||||
|
background: '#fef3c7',
|
||||||
|
padding: '12px 20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>⚡ Performance:</strong> 50 pages × 20 problems = 1,000 problems. Virtual loading
|
||||||
|
keeps it smooth!
|
||||||
|
</div>
|
||||||
|
<ResponsivePanelLayout
|
||||||
|
config={{ ...mockConfig, pages: 50 }}
|
||||||
|
sidebarContent={<MockSidebar />}
|
||||||
|
previewContent={<MockPreviewWithVirtualLoading pages={50} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LandscapeOrientation: Story = {
|
||||||
|
render: () => {
|
||||||
|
function LandscapePreview() {
|
||||||
|
const [loadedPages, setLoadedPages] = useState(new Set([0]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'auto',
|
||||||
|
background: '#e5e7eb',
|
||||||
|
padding: '20px',
|
||||||
|
}}
|
||||||
|
onScroll={(e) => {
|
||||||
|
const container = e.currentTarget
|
||||||
|
const scrollTop = container.scrollTop
|
||||||
|
const clientHeight = container.clientHeight
|
||||||
|
const pageHeight = 864 // 816px + 48px gap
|
||||||
|
const visiblePageStart = Math.floor(scrollTop / pageHeight)
|
||||||
|
const visiblePageEnd = Math.ceil((scrollTop + clientHeight) / pageHeight)
|
||||||
|
|
||||||
|
const newLoadedPages = new Set(loadedPages)
|
||||||
|
for (let i = visiblePageStart; i <= Math.min(visiblePageEnd, 4); i++) {
|
||||||
|
if (!newLoadedPages.has(i)) {
|
||||||
|
newLoadedPages.add(i)
|
||||||
|
setTimeout(() => {
|
||||||
|
setLoadedPages((prev) => new Set([...prev, i]))
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '48px', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<div key={index} style={{ width: '100%', maxWidth: '1056px' }}>
|
||||||
|
<PagePlaceholder
|
||||||
|
pageNumber={index + 1}
|
||||||
|
orientation="landscape"
|
||||||
|
rows={4}
|
||||||
|
cols={5}
|
||||||
|
loading={!loadedPages.has(index)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', width: '100vw' }}>
|
||||||
|
<ResponsivePanelLayout
|
||||||
|
config={{ ...mockConfig, orientation: 'landscape' }}
|
||||||
|
sidebarContent={<MockSidebar />}
|
||||||
|
previewContent={<LandscapePreview />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResizablePanels: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ height: '100vh', width: '100vw' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '16px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 1000,
|
||||||
|
background: '#eff6ff',
|
||||||
|
padding: '12px 20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>↔️ Resizable:</strong> Drag the divider between panels to adjust the layout. Your
|
||||||
|
preference is saved automatically!
|
||||||
|
</div>
|
||||||
|
<ResponsivePanelLayout
|
||||||
|
config={mockConfig}
|
||||||
|
sidebarContent={<MockSidebar />}
|
||||||
|
previewContent={<MockPreviewWithVirtualLoading pages={5} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -38,12 +38,19 @@ export function ShareModal({
|
|||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const extractedConfig = extractConfigFields(config)
|
||||||
|
console.log('[ShareModal] Creating share with config:', {
|
||||||
|
pages: extractedConfig.pages,
|
||||||
|
problemsPerPage: extractedConfig.problemsPerPage,
|
||||||
|
totalProblems: (extractedConfig.pages || 0) * (extractedConfig.problemsPerPage || 0),
|
||||||
|
})
|
||||||
|
|
||||||
const response = await fetch('/api/worksheets/share', {
|
const response = await fetch('/api/worksheets/share', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
worksheetType,
|
worksheetType,
|
||||||
config: extractConfigFields(config),
|
config: extractedConfig,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { PagePlaceholder } from './PagePlaceholder'
|
||||||
|
import type { WorksheetFormState } from '../types'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Worksheets/Preview/WorksheetPreview',
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
},
|
||||||
|
tags: ['autodocs'],
|
||||||
|
} satisfies Meta
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
// Mock preview component that simulates virtual loading without API calls
|
||||||
|
function MockPreviewWithVirtualLoading({
|
||||||
|
pages = 5,
|
||||||
|
formState,
|
||||||
|
}: {
|
||||||
|
pages?: number
|
||||||
|
formState: WorksheetFormState
|
||||||
|
}) {
|
||||||
|
const [loadedPages, setLoadedPages] = useState(new Set([0]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'auto',
|
||||||
|
background: '#e5e7eb',
|
||||||
|
padding: '20px',
|
||||||
|
}}
|
||||||
|
onScroll={(e) => {
|
||||||
|
const container = e.currentTarget
|
||||||
|
const scrollTop = container.scrollTop
|
||||||
|
const clientHeight = container.clientHeight
|
||||||
|
|
||||||
|
// Simulate loading pages as they come into view
|
||||||
|
// Each page is about 1056px + 48px gap = 1104px
|
||||||
|
const pageHeight = 1104
|
||||||
|
const visiblePageStart = Math.floor(scrollTop / pageHeight)
|
||||||
|
const visiblePageEnd = Math.ceil((scrollTop + clientHeight) / pageHeight)
|
||||||
|
|
||||||
|
const newLoadedPages = new Set(loadedPages)
|
||||||
|
for (let i = visiblePageStart; i <= Math.min(visiblePageEnd, pages - 1); i++) {
|
||||||
|
if (!newLoadedPages.has(i)) {
|
||||||
|
newLoadedPages.add(i)
|
||||||
|
// Simulate async loading delay
|
||||||
|
setTimeout(() => {
|
||||||
|
setLoadedPages((prev) => new Set([...prev, i]))
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '48px', alignItems: 'center' }}>
|
||||||
|
{Array.from({ length: pages }).map((_, index) => {
|
||||||
|
const orientation = formState.orientation || 'portrait'
|
||||||
|
const maxWidth = orientation === 'portrait' ? '816px' : '1056px'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} style={{ width: '100%', maxWidth }}>
|
||||||
|
<PagePlaceholder
|
||||||
|
pageNumber={index + 1}
|
||||||
|
orientation={orientation}
|
||||||
|
rows={Math.ceil((formState.problemsPerPage || 20) / (formState.cols || 4))}
|
||||||
|
cols={formState.cols || 4}
|
||||||
|
loading={!loadedPages.has(index)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapper component
|
||||||
|
function PreviewWrapper({ formState, pages }: { formState: WorksheetFormState; pages?: number }) {
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<MockPreviewWithVirtualLoading formState={formState} pages={pages} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockFormState: WorksheetFormState = {
|
||||||
|
operator: 'addition',
|
||||||
|
mode: 'manual',
|
||||||
|
digitRange: { min: 2, max: 3 },
|
||||||
|
problemsPerPage: 20,
|
||||||
|
pages: 5,
|
||||||
|
cols: 4,
|
||||||
|
orientation: 'portrait',
|
||||||
|
name: 'Student Name',
|
||||||
|
displayRules: {
|
||||||
|
tenFrames: 'sometimes',
|
||||||
|
carryBoxes: 'sometimes',
|
||||||
|
placeValueColors: 'sometimes',
|
||||||
|
answerBoxes: 'always',
|
||||||
|
problemNumbers: 'always',
|
||||||
|
cellBorders: 'always',
|
||||||
|
borrowNotation: 'never',
|
||||||
|
borrowingHints: 'never',
|
||||||
|
},
|
||||||
|
pAnyStart: 0.3,
|
||||||
|
pAllStart: 0.1,
|
||||||
|
interpolate: false,
|
||||||
|
seed: 12345,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SinglePage: Story = {
|
||||||
|
render: () => <PreviewWrapper formState={{ ...mockFormState, pages: 1 }} pages={1} />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FivePages: Story = {
|
||||||
|
render: () => <PreviewWrapper formState={mockFormState} pages={5} />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ManyPages: Story = {
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', width: '100vw' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '16px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 1000,
|
||||||
|
background: '#fef3c7',
|
||||||
|
padding: '12px 20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||||
|
maxWidth: '700px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>⚡ Virtual Loading Demo:</strong> This worksheet has 20 pages (400 problems). Only
|
||||||
|
visible pages are loaded. Scroll down to see pages load on-demand with a loading state
|
||||||
|
(hourglass), then display content. Notice smooth scrolling even with hundreds of problems!
|
||||||
|
</div>
|
||||||
|
<PreviewWrapper formState={{ ...mockFormState, pages: 20 }} pages={20} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExtremeScale: Story = {
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', width: '100vw' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '16px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 1000,
|
||||||
|
background: '#fee2e2',
|
||||||
|
padding: '12px 20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||||
|
maxWidth: '700px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>🚀 Extreme Scale:</strong> 50 pages × 20 problems = 1,000 problems! Virtual
|
||||||
|
loading makes this performant. Only 3 pages load initially, rest load as you scroll. Try
|
||||||
|
scrolling through - it stays smooth!
|
||||||
|
</div>
|
||||||
|
<PreviewWrapper formState={{ ...mockFormState, pages: 50 }} pages={50} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LandscapeOrientation: Story = {
|
||||||
|
render: () => (
|
||||||
|
<PreviewWrapper
|
||||||
|
formState={{ ...mockFormState, orientation: 'landscape', cols: 5, pages: 5 }}
|
||||||
|
pages={5}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DenseLayout: Story = {
|
||||||
|
render: () => (
|
||||||
|
<PreviewWrapper
|
||||||
|
formState={{ ...mockFormState, problemsPerPage: 30, cols: 5, pages: 3 }}
|
||||||
|
pages={3}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SparseLayout: Story = {
|
||||||
|
render: () => (
|
||||||
|
<PreviewWrapper
|
||||||
|
formState={{ ...mockFormState, problemsPerPage: 10, cols: 2, pages: 5 }}
|
||||||
|
pages={5}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InitialLoadingState: Story = {
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', width: '100vw' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '16px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 1000,
|
||||||
|
background: '#eff6ff',
|
||||||
|
padding: '12px 20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>🔄 Loading Behavior:</strong> This demonstrates the initial loading state. The
|
||||||
|
first page loads immediately, others show placeholders with a loading spinner until you
|
||||||
|
scroll them into view.
|
||||||
|
</div>
|
||||||
|
<PreviewWrapper formState={{ ...mockFormState, pages: 10 }} pages={10} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VirtualizationExplained: Story = {
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: '20px',
|
||||||
|
gap: '20px',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#f3f4f6',
|
||||||
|
padding: '20px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
maxWidth: '800px',
|
||||||
|
margin: '0 auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: '0 0 16px 0', fontSize: '20px', fontWeight: 600 }}>
|
||||||
|
Virtual Loading System
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', fontSize: '14px' }}>
|
||||||
|
<div>
|
||||||
|
<strong>📦 Batch Loading:</strong>
|
||||||
|
<p style={{ margin: '4px 0 0 0', color: '#6b7280' }}>
|
||||||
|
Initial request loads first 3 pages. Additional pages load in batches as you scroll.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>👁️ Intersection Observer:</strong>
|
||||||
|
<p style={{ margin: '4px 0 0 0', color: '#6b7280' }}>
|
||||||
|
Tracks which pages are visible in the viewport. Triggers loading when pages come
|
||||||
|
into view.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>🎯 Smart Preloading:</strong>
|
||||||
|
<p style={{ margin: '4px 0 0 0', color: '#6b7280' }}>
|
||||||
|
When a page becomes visible, adjacent pages (above and below) are also loaded for
|
||||||
|
smooth scrolling.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>⚡ Performance:</strong>
|
||||||
|
<p style={{ margin: '4px 0 0 0', color: '#6b7280' }}>
|
||||||
|
Handles 50+ page worksheets (1,000+ problems) smoothly. Only renders what's needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>💾 React Query Caching:</strong>
|
||||||
|
<p style={{ margin: '4px 0 0 0', color: '#6b7280' }}>
|
||||||
|
Fetched pages are cached. Scrolling back doesn't re-fetch. Changes to settings
|
||||||
|
invalidate cache.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minHeight: '600px' }}>
|
||||||
|
<PreviewWrapper formState={{ ...mockFormState, pages: 15 }} pages={15} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -422,6 +422,11 @@ function PreviewContent({
|
|||||||
const isVisible = visiblePages.has(index)
|
const isVisible = visiblePages.has(index)
|
||||||
const page = loadedPages.get(index)
|
const page = loadedPages.get(index)
|
||||||
|
|
||||||
|
// Calculate dimensions for consistent sizing between placeholder and loaded content
|
||||||
|
const orientation = formState.orientation ?? 'portrait'
|
||||||
|
const maxWidth = orientation === 'portrait' ? 816 : 1056
|
||||||
|
const aspectRatio = orientation === 'portrait' ? '8.5 / 11' : '11 / 8.5'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
@@ -440,11 +445,15 @@ function PreviewContent({
|
|||||||
>
|
>
|
||||||
{isLoaded && page ? (
|
{isLoaded && page ? (
|
||||||
<div
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: `${maxWidth}px`,
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
}}
|
||||||
className={css({
|
className={css({
|
||||||
'& svg': {
|
'& svg': {
|
||||||
maxWidth: '100%',
|
width: '100%',
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
width: 'auto',
|
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
dangerouslySetInnerHTML={{ __html: page }}
|
dangerouslySetInnerHTML={{ __html: page }}
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { OverallDifficultySlider } from './OverallDifficultySlider'
|
||||||
|
import { DIFFICULTY_PROFILES } from '../../difficultyProfiles'
|
||||||
|
import type { DisplayRules } from '../../displayRules'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Worksheets/Config Panel/OverallDifficultySlider',
|
||||||
|
component: OverallDifficultySlider,
|
||||||
|
parameters: {
|
||||||
|
layout: 'padded',
|
||||||
|
},
|
||||||
|
tags: ['autodocs'],
|
||||||
|
} satisfies Meta<typeof OverallDifficultySlider>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
// Wrapper to handle state
|
||||||
|
function SliderWrapper(args: React.ComponentProps<typeof OverallDifficultySlider>) {
|
||||||
|
const [difficulty, setDifficulty] = useState(args.currentDifficulty)
|
||||||
|
const [config, setConfig] = useState({ pAnyStart: 0, pAllStart: 0 })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '500px' }}>
|
||||||
|
<OverallDifficultySlider
|
||||||
|
{...args}
|
||||||
|
currentDifficulty={difficulty}
|
||||||
|
onChange={(updates) => {
|
||||||
|
const newDifficulty =
|
||||||
|
(updates.pAnyStart + updates.pAllStart * 2) / 3 +
|
||||||
|
(1 - Object.values(updates.displayRules).filter((v) => v === 'always').length / 10)
|
||||||
|
setDifficulty(newDifficulty)
|
||||||
|
setConfig({ pAnyStart: updates.pAnyStart, pAllStart: updates.pAllStart })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '16px',
|
||||||
|
padding: '12px',
|
||||||
|
background: '#f3f4f6',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Current Configuration:</strong>
|
||||||
|
<div style={{ marginTop: '8px', fontSize: '11px', fontFamily: 'monospace' }}>
|
||||||
|
pAnyStart: {config.pAnyStart.toFixed(2)}
|
||||||
|
<br />
|
||||||
|
pAllStart: {config.pAllStart.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EarlyLearner: Story = {
|
||||||
|
render: (args) => <SliderWrapper {...args} />,
|
||||||
|
args: {
|
||||||
|
currentDifficulty: 2.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Intermediate: Story = {
|
||||||
|
render: (args) => <SliderWrapper {...args} />,
|
||||||
|
args: {
|
||||||
|
currentDifficulty: 5.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Expert: Story = {
|
||||||
|
render: (args) => <SliderWrapper {...args} />,
|
||||||
|
args: {
|
||||||
|
currentDifficulty: 8.5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MinimumDifficulty: Story = {
|
||||||
|
render: (args) => <SliderWrapper {...args} />,
|
||||||
|
args: {
|
||||||
|
currentDifficulty: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MaximumDifficulty: Story = {
|
||||||
|
render: (args) => <SliderWrapper {...args} />,
|
||||||
|
args: {
|
||||||
|
currentDifficulty: 10,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InteractiveWithPresets: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [difficulty, setDifficulty] = useState(5.0)
|
||||||
|
const [pAnyStart, setPAnyStart] = useState(0.5)
|
||||||
|
const [pAllStart, setPAllStart] = useState(0.1)
|
||||||
|
const [displayRules, setDisplayRules] = useState<DisplayRules>({
|
||||||
|
tenFrames: 'sometimes',
|
||||||
|
carryBoxes: 'sometimes',
|
||||||
|
placeValueColors: 'sometimes',
|
||||||
|
answerBoxes: 'always',
|
||||||
|
problemNumbers: 'always',
|
||||||
|
cellBorders: 'always',
|
||||||
|
borrowNotation: 'never',
|
||||||
|
borrowingHints: 'never',
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '600px' }}>
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<h3 style={{ margin: '0 0 8px 0', fontSize: '14px', fontWeight: 600 }}>
|
||||||
|
Difficulty Presets
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
|
{Object.entries(DIFFICULTY_PROFILES).map(([key, profile]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const calcDiff =
|
||||||
|
(profile.regrouping.pAnyStart + profile.regrouping.pAllStart * 2) / 3 +
|
||||||
|
(1 -
|
||||||
|
Object.values(profile.displayRules).filter((v) => v === 'always').length / 10)
|
||||||
|
setDifficulty(calcDiff)
|
||||||
|
setPAnyStart(profile.regrouping.pAnyStart)
|
||||||
|
setPAllStart(profile.regrouping.pAllStart)
|
||||||
|
setDisplayRules(profile.displayRules)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{profile.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OverallDifficultySlider
|
||||||
|
currentDifficulty={difficulty}
|
||||||
|
onChange={(updates) => {
|
||||||
|
const newDiff =
|
||||||
|
(updates.pAnyStart + updates.pAllStart * 2) / 3 +
|
||||||
|
(1 - Object.values(updates.displayRules).filter((v) => v === 'always').length / 10)
|
||||||
|
setDifficulty(newDiff)
|
||||||
|
setPAnyStart(updates.pAnyStart)
|
||||||
|
setPAllStart(updates.pAllStart)
|
||||||
|
setDisplayRules(updates.displayRules)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '16px',
|
||||||
|
padding: '12px',
|
||||||
|
background: '#f3f4f6',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Current State:</strong>
|
||||||
|
<div style={{ marginTop: '8px', fontSize: '11px', fontFamily: 'monospace' }}>
|
||||||
|
<div>Difficulty: {difficulty.toFixed(2)} / 10</div>
|
||||||
|
<div>pAnyStart: {pAnyStart.toFixed(2)}</div>
|
||||||
|
<div>pAllStart: {pAllStart.toFixed(2)}</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<strong>Display Rules:</strong>
|
||||||
|
<ul style={{ margin: '4px 0 0 16px', padding: 0 }}>
|
||||||
|
{Object.entries(displayRules).map(([key, value]) => (
|
||||||
|
<li key={key}>
|
||||||
|
{key}: {value}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SnappingBehavior: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [difficulty, setDifficulty] = useState(5.0)
|
||||||
|
const [snappedTo, setSnappedTo] = useState<string>('')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '500px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '16px',
|
||||||
|
padding: '12px',
|
||||||
|
background: '#eff6ff',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>ℹ️ Snapping Behavior:</strong>
|
||||||
|
<p style={{ margin: '8px 0 0 0' }}>
|
||||||
|
As you drag the slider, it will snap to the nearest valid configuration along the
|
||||||
|
pedagogical difficulty path. Watch how it snaps to combinations of regrouping intensity
|
||||||
|
and scaffolding level.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OverallDifficultySlider
|
||||||
|
currentDifficulty={difficulty}
|
||||||
|
onChange={(updates) => {
|
||||||
|
const newDiff =
|
||||||
|
(updates.pAnyStart + updates.pAllStart * 2) / 3 +
|
||||||
|
(1 - Object.values(updates.displayRules).filter((v) => v === 'always').length / 10)
|
||||||
|
setDifficulty(newDiff)
|
||||||
|
setSnappedTo(
|
||||||
|
updates.difficultyProfile
|
||||||
|
? DIFFICULTY_PROFILES[updates.difficultyProfile].label
|
||||||
|
: 'Custom'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{snappedTo && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '16px',
|
||||||
|
padding: '12px',
|
||||||
|
background: '#f3f4f6',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Snapped to:</strong> {snappedTo}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { RuleDropdown } from './RuleDropdown'
|
||||||
|
import type { RuleMode } from '../../displayRules'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Worksheets/Config Panel/RuleDropdown',
|
||||||
|
component: RuleDropdown,
|
||||||
|
parameters: {
|
||||||
|
layout: 'padded',
|
||||||
|
},
|
||||||
|
tags: ['autodocs'],
|
||||||
|
} satisfies Meta<typeof RuleDropdown>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
// Wrapper to handle state
|
||||||
|
function DropdownWrapper(args: React.ComponentProps<typeof RuleDropdown>) {
|
||||||
|
const [value, setValue] = useState<RuleMode>(args.value)
|
||||||
|
return <RuleDropdown {...args} value={value} onChange={setValue} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlwaysSelectedLight: Story = {
|
||||||
|
render: (args) => <DropdownWrapper {...args} />,
|
||||||
|
args: {
|
||||||
|
label: 'Ten-Frame Diagrams',
|
||||||
|
description: 'When should ten-frame visual aids be displayed?',
|
||||||
|
value: 'always',
|
||||||
|
isDark: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NeverSelectedLight: Story = {
|
||||||
|
render: (args) => <DropdownWrapper {...args} />,
|
||||||
|
args: {
|
||||||
|
label: 'Carry Notation Boxes',
|
||||||
|
description: 'When should carry notation be shown?',
|
||||||
|
value: 'never',
|
||||||
|
isDark: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WhenRegroupingLight: Story = {
|
||||||
|
render: (args) => <DropdownWrapper {...args} />,
|
||||||
|
args: {
|
||||||
|
label: 'Place Value Colors',
|
||||||
|
description: 'When should color-coding be applied?',
|
||||||
|
value: 'whenRegrouping',
|
||||||
|
isDark: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlwaysSelectedDark: Story = {
|
||||||
|
render: (args) => <DropdownWrapper {...args} />,
|
||||||
|
args: {
|
||||||
|
label: 'Ten-Frame Diagrams',
|
||||||
|
description: 'When should ten-frame visual aids be displayed?',
|
||||||
|
value: 'always',
|
||||||
|
isDark: true,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
backgrounds: { default: 'dark' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NeverSelectedDark: Story = {
|
||||||
|
render: (args) => <DropdownWrapper {...args} />,
|
||||||
|
args: {
|
||||||
|
label: 'Carry Notation Boxes',
|
||||||
|
description: 'When should carry notation be shown?',
|
||||||
|
value: 'never',
|
||||||
|
isDark: true,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
backgrounds: { default: 'dark' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MultipleDropdowns: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [rules, setRules] = useState({
|
||||||
|
tenFrames: 'always' as RuleMode,
|
||||||
|
carryBoxes: 'whenRegrouping' as RuleMode,
|
||||||
|
placeValueColors: 'never' as RuleMode,
|
||||||
|
answerBoxes: 'always' as RuleMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '400px' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>Display Rules</h3>
|
||||||
|
<RuleDropdown
|
||||||
|
label="Ten-Frame Diagrams"
|
||||||
|
description="Visual representations using ten-frames"
|
||||||
|
value={rules.tenFrames}
|
||||||
|
onChange={(value) => setRules({ ...rules, tenFrames: value })}
|
||||||
|
/>
|
||||||
|
<RuleDropdown
|
||||||
|
label="Carry Notation Boxes"
|
||||||
|
description="Small boxes above columns for carrying"
|
||||||
|
value={rules.carryBoxes}
|
||||||
|
onChange={(value) => setRules({ ...rules, carryBoxes: value })}
|
||||||
|
/>
|
||||||
|
<RuleDropdown
|
||||||
|
label="Place Value Colors"
|
||||||
|
description="Color-code digits by place value (ones, tens, hundreds)"
|
||||||
|
value={rules.placeValueColors}
|
||||||
|
onChange={(value) => setRules({ ...rules, placeValueColors: value })}
|
||||||
|
/>
|
||||||
|
<RuleDropdown
|
||||||
|
label="Answer Entry Boxes"
|
||||||
|
description="Boxes for students to write answers"
|
||||||
|
value={rules.answerBoxes}
|
||||||
|
onChange={(value) => setRules({ ...rules, answerBoxes: value })}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '8px',
|
||||||
|
padding: '12px',
|
||||||
|
background: '#f3f4f6',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Current Configuration:</strong>
|
||||||
|
<pre style={{ margin: '8px 0 0 0', fontSize: '10px', whiteSpace: 'pre-wrap' }}>
|
||||||
|
{JSON.stringify(rules, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllOptions: Story = {
|
||||||
|
render: () => {
|
||||||
|
const options: Array<{ value: RuleMode; label: string }> = [
|
||||||
|
{ value: 'always', label: 'Always' },
|
||||||
|
{ value: 'never', label: 'Never' },
|
||||||
|
{ value: 'whenRegrouping', label: 'When Regrouping' },
|
||||||
|
{ value: 'whenMultipleRegroups', label: 'Multiple Regroups' },
|
||||||
|
{ value: 'when3PlusDigits', label: '3+ Digits' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', maxWidth: '800px' }}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<DropdownWrapper
|
||||||
|
key={option.value}
|
||||||
|
label={`${option.label} Example`}
|
||||||
|
description={`This dropdown is set to "${option.label}"`}
|
||||||
|
value={option.value}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LongLabel: Story = {
|
||||||
|
render: (args) => <DropdownWrapper {...args} />,
|
||||||
|
args: {
|
||||||
|
label: 'Ten-Frame Diagrams for Visual Number Representation',
|
||||||
|
description:
|
||||||
|
'Determine when ten-frame visual aids should be displayed to help students understand number composition and place value. Ten-frames are particularly effective for students in early elementary grades.',
|
||||||
|
value: 'always',
|
||||||
|
isDark: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { ToggleOption } from './ToggleOption'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Worksheets/Config Panel/ToggleOption',
|
||||||
|
component: ToggleOption,
|
||||||
|
parameters: {
|
||||||
|
layout: 'padded',
|
||||||
|
},
|
||||||
|
tags: ['autodocs'],
|
||||||
|
} satisfies Meta<typeof ToggleOption>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
// Wrapper to handle state
|
||||||
|
function ToggleWrapper(args: React.ComponentProps<typeof ToggleOption>) {
|
||||||
|
const [checked, setChecked] = useState(args.checked)
|
||||||
|
return <ToggleOption {...args} checked={checked} onChange={setChecked} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UncheckedLight: Story = {
|
||||||
|
render: (args) => <ToggleWrapper {...args} />,
|
||||||
|
args: {
|
||||||
|
checked: false,
|
||||||
|
label: 'Progressive Difficulty',
|
||||||
|
description: 'Problems gradually increase in complexity throughout the worksheet',
|
||||||
|
isDark: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CheckedLight: Story = {
|
||||||
|
render: (args) => <ToggleWrapper {...args} />,
|
||||||
|
args: {
|
||||||
|
checked: true,
|
||||||
|
label: 'Progressive Difficulty',
|
||||||
|
description: 'Problems gradually increase in complexity throughout the worksheet',
|
||||||
|
isDark: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UncheckedDark: Story = {
|
||||||
|
render: (args) => <ToggleWrapper {...args} />,
|
||||||
|
args: {
|
||||||
|
checked: false,
|
||||||
|
label: 'Progressive Difficulty',
|
||||||
|
description: 'Problems gradually increase in complexity throughout the worksheet',
|
||||||
|
isDark: true,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
backgrounds: { default: 'dark' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CheckedDark: Story = {
|
||||||
|
render: (args) => <ToggleWrapper {...args} />,
|
||||||
|
args: {
|
||||||
|
checked: true,
|
||||||
|
label: 'Progressive Difficulty',
|
||||||
|
description: 'Problems gradually increase in complexity throughout the worksheet',
|
||||||
|
isDark: true,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
backgrounds: { default: 'dark' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithChildren: Story = {
|
||||||
|
render: (args) => (
|
||||||
|
<ToggleWrapper {...args}>
|
||||||
|
<div style={{ padding: '12px', borderTop: '1px solid #e5e7eb', background: '#f9fafb' }}>
|
||||||
|
<p style={{ margin: 0, fontSize: '11px', color: '#6b7280' }}>
|
||||||
|
Additional content can be displayed when this option is toggled on.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ToggleWrapper>
|
||||||
|
),
|
||||||
|
args: {
|
||||||
|
checked: true,
|
||||||
|
label: 'Show Answer Key',
|
||||||
|
description: 'Include an answer key at the end of the worksheet',
|
||||||
|
isDark: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LongDescription: Story = {
|
||||||
|
render: (args) => <ToggleWrapper {...args} />,
|
||||||
|
args: {
|
||||||
|
checked: false,
|
||||||
|
label: 'Include Ten-Frame Diagrams',
|
||||||
|
description:
|
||||||
|
'Add visual ten-frame representations to help students visualize numbers and develop number sense. This is particularly helpful for early learners who are still developing their understanding of place value and number composition.',
|
||||||
|
isDark: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InteractiveDemo: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [options, setOptions] = useState({
|
||||||
|
progressive: false,
|
||||||
|
answerKey: true,
|
||||||
|
tenFrames: false,
|
||||||
|
carryBoxes: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', maxWidth: '400px' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>Worksheet Options</h3>
|
||||||
|
<ToggleOption
|
||||||
|
checked={options.progressive}
|
||||||
|
onChange={(checked) => setOptions({ ...options, progressive: checked })}
|
||||||
|
label="Progressive Difficulty"
|
||||||
|
description="Problems gradually increase in complexity"
|
||||||
|
/>
|
||||||
|
<ToggleOption
|
||||||
|
checked={options.answerKey}
|
||||||
|
onChange={(checked) => setOptions({ ...options, answerKey: checked })}
|
||||||
|
label="Include Answer Key"
|
||||||
|
description="Add an answer key at the end"
|
||||||
|
/>
|
||||||
|
<ToggleOption
|
||||||
|
checked={options.tenFrames}
|
||||||
|
onChange={(checked) => setOptions({ ...options, tenFrames: checked })}
|
||||||
|
label="Ten-Frame Diagrams"
|
||||||
|
description="Show visual representations for numbers"
|
||||||
|
/>
|
||||||
|
<ToggleOption
|
||||||
|
checked={options.carryBoxes}
|
||||||
|
onChange={(checked) => setOptions({ ...options, carryBoxes: checked })}
|
||||||
|
label="Carry Notation Boxes"
|
||||||
|
description="Display boxes above columns for carrying"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '12px',
|
||||||
|
padding: '12px',
|
||||||
|
background: '#f3f4f6',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Selected:</strong>
|
||||||
|
<ul style={{ margin: '8px 0 0 0', paddingLeft: '20px' }}>
|
||||||
|
{Object.entries(options).map(
|
||||||
|
([key, value]) => value && <li key={key}>{key.replace(/([A-Z])/g, ' $1')}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -56,16 +56,54 @@ export type WorksheetConfig = AdditionConfigV4 & {
|
|||||||
*
|
*
|
||||||
* This type is intentionally permissive during form editing to allow fields from
|
* This type is intentionally permissive during form editing to allow fields from
|
||||||
* all modes to exist temporarily. Validation will enforce mode consistency.
|
* all modes to exist temporarily. Validation will enforce mode consistency.
|
||||||
|
*
|
||||||
|
* ## Field Categories (Config Persistence)
|
||||||
|
*
|
||||||
|
* **PRIMARY STATE** (persisted to localStorage/database):
|
||||||
|
* - Most fields from AdditionConfigV4 (problemsPerPage, pages, cols, etc.)
|
||||||
|
* - seed, prngAlgorithm (critical for reproducibility)
|
||||||
|
*
|
||||||
|
* **DERIVED STATE** (calculated, never persisted):
|
||||||
|
* - `total` = problemsPerPage × pages
|
||||||
|
* - `rows` = Math.ceil(problemsPerPage / cols)
|
||||||
|
*
|
||||||
|
* **EPHEMERAL STATE** (generated fresh, never persisted):
|
||||||
|
* - `date` = current date when worksheet is generated
|
||||||
|
*
|
||||||
|
* See `.claude/WORKSHEET_CONFIG_PERSISTENCE.md` for full architecture.
|
||||||
*/
|
*/
|
||||||
export type WorksheetFormState = Partial<Omit<AdditionConfigV4Smart, 'version'>> &
|
export type WorksheetFormState = Partial<Omit<AdditionConfigV4Smart, 'version'>> &
|
||||||
Partial<Omit<AdditionConfigV4Manual, 'version'>> &
|
Partial<Omit<AdditionConfigV4Manual, 'version'>> &
|
||||||
Partial<Omit<AdditionConfigV4Mastery, 'version'>> & {
|
Partial<Omit<AdditionConfigV4Mastery, 'version'>> & {
|
||||||
// DERIVED state (calculated from primary state)
|
// ========================================
|
||||||
|
// DERIVED STATE (never persisted)
|
||||||
|
// ========================================
|
||||||
|
// These are calculated from primary state and excluded from persistence.
|
||||||
|
// See extractConfigFields() blacklist for exclusion logic.
|
||||||
|
|
||||||
|
/** Derived: total = problemsPerPage × pages */
|
||||||
rows?: number
|
rows?: number
|
||||||
|
|
||||||
|
/** Derived: rows = Math.ceil(problemsPerPage / cols) */
|
||||||
total?: number
|
total?: number
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// EPHEMERAL STATE (never persisted)
|
||||||
|
// ========================================
|
||||||
|
// Generated fresh at render time
|
||||||
|
|
||||||
|
/** Ephemeral: Current date when worksheet is generated */
|
||||||
date?: string
|
date?: string
|
||||||
// Problem reproducibility (critical for sharing)
|
|
||||||
|
// ========================================
|
||||||
|
// PRIMARY STATE (persisted)
|
||||||
|
// ========================================
|
||||||
|
// Critical for reproducibility when sharing worksheets
|
||||||
|
|
||||||
|
/** Primary: Random seed for reproducible problem generation */
|
||||||
seed?: number
|
seed?: number
|
||||||
|
|
||||||
|
/** Primary: PRNG algorithm (ensures same random sequence across systems) */
|
||||||
prngAlgorithm?: string
|
prngAlgorithm?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,58 +3,87 @@ import type { WorksheetFormState } from '../types'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract only the persisted config fields from formState
|
* Extract only the persisted config fields from formState
|
||||||
* Excludes derived state (rows, total, date)
|
|
||||||
* INCLUDES seed and prngAlgorithm to ensure exact problem reproduction when shared
|
|
||||||
*
|
*
|
||||||
* This ensures consistent field extraction across:
|
* ## Architecture: Blacklist Approach
|
||||||
* - Auto-save (useWorksheetAutoSave)
|
|
||||||
* - Share creation
|
|
||||||
* - Settings API
|
|
||||||
*
|
*
|
||||||
* @param formState - The current form state (may be partial)
|
* This function uses a **blacklist approach** to extract config fields:
|
||||||
* @returns Clean config object ready for serialization
|
* - Automatically includes ALL fields from formState
|
||||||
|
* - Only excludes specific derived/ephemeral fields: rows, total, date
|
||||||
|
*
|
||||||
|
* ### Why Blacklist Instead of Whitelist?
|
||||||
|
*
|
||||||
|
* **Previous approach (FRAGILE):**
|
||||||
|
* - Manually listed every field to include
|
||||||
|
* - Adding new config fields required updating this function
|
||||||
|
* - Forgetting to update caused shared worksheets to break
|
||||||
|
* - Multiple incidents where new fields weren't shared correctly
|
||||||
|
*
|
||||||
|
* **Current approach (ROBUST):**
|
||||||
|
* - New config fields automatically work in shared worksheets
|
||||||
|
* - Only need to update if adding new DERIVED fields
|
||||||
|
* - Much harder to accidentally break sharing
|
||||||
|
*
|
||||||
|
* ### Field Categories
|
||||||
|
*
|
||||||
|
* **PRIMARY STATE** (persisted):
|
||||||
|
* - problemsPerPage, cols, pages - define worksheet structure
|
||||||
|
* - digitRange, operator - define problem space
|
||||||
|
* - pAnyStart, pAllStart, interpolate - control regrouping distribution
|
||||||
|
* - mode, displayRules, difficultyProfile, etc. - control display behavior
|
||||||
|
* - seed, prngAlgorithm - ensure exact problem reproduction when shared
|
||||||
|
*
|
||||||
|
* **DERIVED STATE** (excluded):
|
||||||
|
* - `total` = problemsPerPage × pages (recalculated on load)
|
||||||
|
* - `rows` = Math.ceil((problemsPerPage / cols)) (recalculated on load)
|
||||||
|
*
|
||||||
|
* **EPHEMERAL STATE** (excluded):
|
||||||
|
* - `date` - generated fresh at render time, not persisted
|
||||||
|
*
|
||||||
|
* ### Usage
|
||||||
|
*
|
||||||
|
* This function ensures consistent extraction across:
|
||||||
|
* - Auto-save to localStorage (useWorksheetAutoSave hook)
|
||||||
|
* - Share link creation (POST /api/worksheets/share)
|
||||||
|
* - Settings persistence (POST /api/worksheets/settings)
|
||||||
|
*
|
||||||
|
* ### Critical for Sharing
|
||||||
|
*
|
||||||
|
* The `seed` and `prngAlgorithm` fields are CRITICAL - they ensure that
|
||||||
|
* shared worksheets generate the exact same problems when opened by others.
|
||||||
|
* Without these, each person would see different random problems.
|
||||||
|
*
|
||||||
|
* @param formState - The current form state (may be partial during editing)
|
||||||
|
* @returns Clean config object ready for serialization (JSON.stringify)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // In ShareModal component
|
||||||
|
* const config = extractConfigFields(formState)
|
||||||
|
* await fetch('/api/worksheets/share', {
|
||||||
|
* method: 'POST',
|
||||||
|
* body: JSON.stringify({ worksheetType: 'addition', config })
|
||||||
|
* })
|
||||||
*/
|
*/
|
||||||
export function extractConfigFields(
|
export function extractConfigFields(
|
||||||
formState: WorksheetFormState
|
formState: WorksheetFormState
|
||||||
): Omit<AdditionConfigV4, 'version'> & { seed?: number; prngAlgorithm?: string } {
|
): Omit<AdditionConfigV4, 'version'> & { seed?: number; prngAlgorithm?: string } {
|
||||||
const extracted = {
|
// Blacklist approach: Exclude only derived/ephemeral fields
|
||||||
problemsPerPage: formState.problemsPerPage!,
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
cols: formState.cols!,
|
const { rows, total, date, ...persistedFields } = formState
|
||||||
pages: formState.pages!,
|
|
||||||
orientation: formState.orientation!,
|
// Ensure prngAlgorithm has a default (critical for reproducibility)
|
||||||
name: formState.name!,
|
const config = {
|
||||||
digitRange: formState.digitRange!,
|
...persistedFields,
|
||||||
operator: formState.operator!,
|
prngAlgorithm: persistedFields.prngAlgorithm ?? 'mulberry32',
|
||||||
pAnyStart: formState.pAnyStart!,
|
|
||||||
pAllStart: formState.pAllStart!,
|
|
||||||
interpolate: formState.interpolate!,
|
|
||||||
showCarryBoxes: formState.showCarryBoxes,
|
|
||||||
showAnswerBoxes: formState.showAnswerBoxes,
|
|
||||||
showPlaceValueColors: formState.showPlaceValueColors,
|
|
||||||
showProblemNumbers: formState.showProblemNumbers,
|
|
||||||
showCellBorder: formState.showCellBorder,
|
|
||||||
showTenFrames: formState.showTenFrames,
|
|
||||||
showTenFramesForAll: formState.showTenFramesForAll,
|
|
||||||
showBorrowNotation: formState.showBorrowNotation,
|
|
||||||
showBorrowingHints: formState.showBorrowingHints,
|
|
||||||
fontSize: formState.fontSize,
|
|
||||||
mode: formState.mode!,
|
|
||||||
difficultyProfile: formState.difficultyProfile,
|
|
||||||
displayRules: formState.displayRules,
|
|
||||||
manualPreset: formState.manualPreset,
|
|
||||||
// Mastery mode fields (optional)
|
|
||||||
currentStepId: formState.currentStepId,
|
|
||||||
currentAdditionSkillId: formState.currentAdditionSkillId,
|
|
||||||
currentSubtractionSkillId: formState.currentSubtractionSkillId,
|
|
||||||
// CRITICAL: Include seed and algorithm to ensure exact same problems when sharing
|
|
||||||
seed: formState.seed,
|
|
||||||
prngAlgorithm: formState.prngAlgorithm ?? 'mulberry32',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[extractConfigFields] Extracted config:', {
|
console.log('[extractConfigFields] Extracted config:', {
|
||||||
seed: extracted.seed,
|
fieldCount: Object.keys(config).length,
|
||||||
prngAlgorithm: extracted.prngAlgorithm,
|
seed: config.seed,
|
||||||
|
prngAlgorithm: config.prngAlgorithm,
|
||||||
|
pages: config.pages,
|
||||||
|
problemsPerPage: config.problemsPerPage,
|
||||||
|
excludedFields: ['rows', 'total', 'date'],
|
||||||
})
|
})
|
||||||
|
|
||||||
return extracted
|
return config
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,20 +27,65 @@ function getDefaultDate(): string {
|
|||||||
export function validateWorksheetConfig(formState: WorksheetFormState): ValidationResult {
|
export function validateWorksheetConfig(formState: WorksheetFormState): ValidationResult {
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
|
|
||||||
// Validate total (must be positive, reasonable limit)
|
// Validate cols first (needed for rows calculation)
|
||||||
const total = formState.total ?? 20
|
|
||||||
if (total < 1 || total > WORKSHEET_LIMITS.MAX_TOTAL_PROBLEMS) {
|
|
||||||
errors.push(`Total problems must be between 1 and ${WORKSHEET_LIMITS.MAX_TOTAL_PROBLEMS}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate cols and auto-calculate rows
|
|
||||||
const cols = formState.cols ?? 4
|
const cols = formState.cols ?? 4
|
||||||
if (cols < 1 || cols > WORKSHEET_LIMITS.MAX_COLS) {
|
if (cols < 1 || cols > WORKSHEET_LIMITS.MAX_COLS) {
|
||||||
errors.push(`Columns must be between 1 and ${WORKSHEET_LIMITS.MAX_COLS}`)
|
errors.push(`Columns must be between 1 and ${WORKSHEET_LIMITS.MAX_COLS}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-calculate rows to fit all problems
|
// ========================================
|
||||||
const rows = Math.ceil(total / cols)
|
// PRIMARY STATE → DERIVED STATE
|
||||||
|
// ========================================
|
||||||
|
//
|
||||||
|
// This section demonstrates the core principle of our config persistence:
|
||||||
|
//
|
||||||
|
// PRIMARY STATE (saved, source of truth):
|
||||||
|
// - problemsPerPage: How many problems per page (e.g., 20)
|
||||||
|
// - pages: How many pages (e.g., 5)
|
||||||
|
//
|
||||||
|
// DERIVED STATE (calculated, never saved):
|
||||||
|
// - total = problemsPerPage × pages (e.g., 100)
|
||||||
|
// - rows = Math.ceil(problemsPerPage / cols) (e.g., 5)
|
||||||
|
//
|
||||||
|
// Why this matters:
|
||||||
|
// - When sharing worksheets, we only save PRIMARY state
|
||||||
|
// - When loading shared worksheets, we MUST calculate DERIVED state
|
||||||
|
// - Never use formState.total as fallback - it may be missing for shared worksheets!
|
||||||
|
// - See .claude/WORKSHEET_CONFIG_PERSISTENCE.md for full architecture
|
||||||
|
//
|
||||||
|
// Example bug that was fixed (2025-01):
|
||||||
|
// - Shared 100-page worksheet
|
||||||
|
// - formState.total was missing (correctly excluded from share)
|
||||||
|
// - Old code: total = formState.total ?? 20 (WRONG!)
|
||||||
|
// - Result: Generated only 20 problems → 1 page instead of 100
|
||||||
|
// - Fix: total = problemsPerPage × pages (from PRIMARY state)
|
||||||
|
//
|
||||||
|
|
||||||
|
// Get primary state values (source of truth for calculation)
|
||||||
|
const problemsPerPage = formState.problemsPerPage ?? (formState.total ?? 20)
|
||||||
|
const pages = formState.pages ?? 1
|
||||||
|
|
||||||
|
// Calculate derived state: total = problemsPerPage × pages
|
||||||
|
// DO NOT use formState.total as source of truth - it may be missing!
|
||||||
|
const total = problemsPerPage * pages
|
||||||
|
|
||||||
|
console.log('[validateWorksheetConfig] PRIMARY → DERIVED state:', {
|
||||||
|
// Primary (source of truth)
|
||||||
|
problemsPerPage,
|
||||||
|
pages,
|
||||||
|
// Derived (calculated)
|
||||||
|
total,
|
||||||
|
// Debug: check if formState had these values
|
||||||
|
hadTotal: formState.total !== undefined,
|
||||||
|
totalMatches: formState.total === total,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (total < 1 || total > WORKSHEET_LIMITS.MAX_TOTAL_PROBLEMS) {
|
||||||
|
errors.push(`Total problems must be between 1 and ${WORKSHEET_LIMITS.MAX_TOTAL_PROBLEMS}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate derived state: rows based on problemsPerPage and cols
|
||||||
|
const rows = Math.ceil(problemsPerPage / cols)
|
||||||
|
|
||||||
// Validate probabilities (0-1 range)
|
// Validate probabilities (0-1 range)
|
||||||
// CRITICAL: Must check for undefined/null explicitly, not use ?? operator
|
// CRITICAL: Must check for undefined/null explicitly, not use ?? operator
|
||||||
@@ -111,10 +156,6 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
|
|||||||
// Determine orientation based on columns (portrait = 2-3 cols, landscape = 4-5 cols)
|
// Determine orientation based on columns (portrait = 2-3 cols, landscape = 4-5 cols)
|
||||||
const orientation = formState.orientation || (cols <= 3 ? 'portrait' : 'landscape')
|
const orientation = formState.orientation || (cols <= 3 ? 'portrait' : 'landscape')
|
||||||
|
|
||||||
// Get primary state values
|
|
||||||
const problemsPerPage = formState.problemsPerPage ?? total
|
|
||||||
const pages = formState.pages ?? 1
|
|
||||||
|
|
||||||
// Determine mode (default to 'smart' if not specified)
|
// Determine mode (default to 'smart' if not specified)
|
||||||
const mode: 'smart' | 'manual' | 'mastery' = formState.mode ?? 'smart'
|
const mode: 'smart' | 'manual' | 'mastery' = formState.mode ?? 'smart'
|
||||||
|
|
||||||
|
|||||||
@@ -61,39 +61,53 @@ export default function SharedWorksheetPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
console.log('[SharedWorksheetPage] Loaded share data:', {
|
||||||
|
pages: data.config.pages,
|
||||||
|
problemsPerPage: data.config.problemsPerPage,
|
||||||
|
totalProblems: (data.config.pages || 0) * (data.config.problemsPerPage || 0),
|
||||||
|
fullConfig: data.config,
|
||||||
|
})
|
||||||
setShareData(data)
|
setShareData(data)
|
||||||
|
|
||||||
// Fetch preview from API
|
// Don't pre-fetch preview for large worksheets - let WorksheetPreview handle virtual loading
|
||||||
try {
|
// Only pre-fetch for small worksheets (≤5 pages) to improve initial load experience
|
||||||
const previewResponse = await fetch('/api/worksheets/preview', {
|
if (data.config.pages && data.config.pages <= 5) {
|
||||||
method: 'POST',
|
console.log('[SharedWorksheetPage] Pre-fetching preview for small worksheet...')
|
||||||
headers: { 'Content-Type': 'application/json' },
|
try {
|
||||||
body: JSON.stringify({ config: data.config }),
|
const previewResponse = await fetch('/api/worksheets/preview', {
|
||||||
})
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ config: data.config }),
|
||||||
|
})
|
||||||
|
|
||||||
if (previewResponse.ok) {
|
if (previewResponse.ok) {
|
||||||
const previewData = await previewResponse.json()
|
const previewData = await previewResponse.json()
|
||||||
if (previewData.success) {
|
if (previewData.success) {
|
||||||
setPreview(previewData.pages)
|
setPreview(previewData.pages)
|
||||||
|
} else {
|
||||||
|
// Preview generation failed - store error details
|
||||||
|
setPreviewError({
|
||||||
|
error: previewData.error || 'Failed to generate preview',
|
||||||
|
details: previewData.details,
|
||||||
|
})
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Preview generation failed - store error details
|
|
||||||
setPreviewError({
|
setPreviewError({
|
||||||
error: previewData.error || 'Failed to generate preview',
|
error: 'Preview generation failed',
|
||||||
details: previewData.details,
|
details: `HTTP ${previewResponse.status}: ${previewResponse.statusText}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} catch (err) {
|
||||||
|
console.error('Failed to generate preview:', err)
|
||||||
setPreviewError({
|
setPreviewError({
|
||||||
error: 'Preview generation failed',
|
error: 'Failed to generate preview',
|
||||||
details: `HTTP ${previewResponse.status}: ${previewResponse.statusText}`,
|
details: err instanceof Error ? err.message : String(err),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} else {
|
||||||
console.error('Failed to generate preview:', err)
|
console.log(
|
||||||
setPreviewError({
|
`[SharedWorksheetPage] Skipping pre-fetch for large worksheet (${data.config.pages} pages) - using virtual loading`
|
||||||
error: 'Failed to generate preview',
|
)
|
||||||
details: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching shared worksheet:', err)
|
console.error('Error fetching shared worksheet:', err)
|
||||||
|
|||||||
Reference in New Issue
Block a user