feat: add skill configuration system with interactive 2D difficulty plot

Implement comprehensive skill customization system allowing teachers to:
- Configure existing default skills with custom difficulty settings
- Create entirely new custom skills from scratch
- Visualize skills in mastery progression context with directional edges
- Interact with difficulty space using 2D plot with hover tooltips

Database Schema:
- custom_skills table: Stores user-created skills
- skill_customizations table: Stores modifications to default skills
- Both tables track regrouping config, display rules, and metadata

API Endpoints:
- POST /api/worksheets/skills/custom - Create custom skill
- GET /api/worksheets/skills/custom - List custom skills
- PUT /api/worksheets/skills/custom/[id] - Update custom skill
- DELETE /api/worksheets/skills/custom/[id] - Delete custom skill
- POST /api/worksheets/skills/[skillId]/customize - Save customization
- GET /api/worksheets/skills/customizations - List customizations

Components:
- DifficultyPlot2D: Interactive 2D visualization of difficulty space
  - Regrouping Intensity (x-axis) × Scaffolding Level (y-axis)
  - Dual mode: Default presets vs Mastery progression skills
  - Directional edges showing skill progression sequence
  - Hover tooltips with skill details
  - Click to select configuration
  - Visual legend explaining elements

- SkillConfigurationModal: Modal for skill configuration
  - Name and description fields
  - Digit range slider
  - 2D difficulty plot integration
  - Shows mastery progression context when editing
  - Real-time configuration summary

- MasteryModePanel Integration:
  - "Configure Skill" button for existing skills
  - "Create Custom Skill" button for new skills
  - Passes mastery progression to modal for context

Visual Design:
- Purple theme (#9333ea) for mastery progression skills
- Green theme (#10b981) for current configuration
- Dashed arrows with triangular arrow heads
- Numbered skill circles with hover tooltips
- Compact legend in top-right corner

Technical Features:
- PlotPoint interface for custom skill plotting
- Conditional snapping to either presets or custom points
- Vector math for arrow head calculations
- Z-ordering: edges before points
- Event propagation control for hover interactions
- Storybook examples for both components

Bug Fixes:
- Fix page indicator stuck on page 1 in WorksheetPreview
  - Changed from threshold-based to most-visible-page tracking
  - Works correctly for both scroll directions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-11 15:04:19 -06:00
parent bad25d24a2
commit 7fbc743c4c
13 changed files with 1913 additions and 95 deletions

View File

@ -52,13 +52,12 @@
"Bash(pnpm install)",
"Bash(npx @biomejs/biome check:*)",
"Bash(node -e:*)",
"Bash(sqlite3:*)"
"Bash(sqlite3:*)",
"Bash(npm run format:check:*)"
],
"deny": [],
"ask": []
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"sqlite"
]
"enabledMcpjsonServers": ["sqlite"]
}

View File

@ -116,13 +116,9 @@
"abacus_settings_user_id_users_id_fk": {
"name": "abacus_settings_user_id_users_id_fk",
"tableFrom": "abacus_settings",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -240,9 +236,7 @@
"indexes": {
"arcade_rooms_code_unique": {
"name": "arcade_rooms_code_unique",
"columns": [
"code"
],
"columns": ["code"],
"isUnique": true
}
},
@ -339,26 +333,18 @@
"arcade_sessions_room_id_arcade_rooms_id_fk": {
"name": "arcade_sessions_room_id_arcade_rooms_id_fk",
"tableFrom": "arcade_sessions",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"arcade_sessions_user_id_users_id_fk": {
"name": "arcade_sessions_user_id_users_id_fk",
"tableFrom": "arcade_sessions",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -424,9 +410,7 @@
"indexes": {
"players_user_id_idx": {
"name": "players_user_id_idx",
"columns": [
"user_id"
],
"columns": ["user_id"],
"isUnique": false
}
},
@ -434,13 +418,9 @@
"players_user_id_users_id_fk": {
"name": "players_user_id_users_id_fk",
"tableFrom": "players",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -514,9 +494,7 @@
"indexes": {
"idx_room_members_user_id_unique": {
"name": "idx_room_members_user_id_unique",
"columns": [
"user_id"
],
"columns": ["user_id"],
"isUnique": true
}
},
@ -524,13 +502,9 @@
"room_members_room_id_arcade_rooms_id_fk": {
"name": "room_members_room_id_arcade_rooms_id_fk",
"tableFrom": "room_members",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -605,13 +579,9 @@
"room_member_history_room_id_arcade_rooms_id_fk": {
"name": "room_member_history_room_id_arcade_rooms_id_fk",
"tableFrom": "room_member_history",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -713,10 +683,7 @@
"indexes": {
"idx_room_invitations_user_room": {
"name": "idx_room_invitations_user_room",
"columns": [
"user_id",
"room_id"
],
"columns": ["user_id", "room_id"],
"isUnique": true
}
},
@ -724,13 +691,9 @@
"room_invitations_room_id_arcade_rooms_id_fk": {
"name": "room_invitations_room_id_arcade_rooms_id_fk",
"tableFrom": "room_invitations",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -833,13 +796,9 @@
"room_reports_room_id_arcade_rooms_id_fk": {
"name": "room_reports_room_id_arcade_rooms_id_fk",
"tableFrom": "room_reports",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -918,10 +877,7 @@
"indexes": {
"idx_room_bans_user_room": {
"name": "idx_room_bans_user_room",
"columns": [
"user_id",
"room_id"
],
"columns": ["user_id", "room_id"],
"isUnique": true
}
},
@ -929,13 +885,9 @@
"room_bans_room_id_arcade_rooms_id_fk": {
"name": "room_bans_room_id_arcade_rooms_id_fk",
"tableFrom": "room_bans",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -998,13 +950,9 @@
"user_stats_user_id_users_id_fk": {
"name": "user_stats_user_id_users_id_fk",
"tableFrom": "user_stats",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -1062,16 +1010,12 @@
"indexes": {
"users_guest_id_unique": {
"name": "users_guest_id_unique",
"columns": [
"guest_id"
],
"columns": ["guest_id"],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"columns": ["email"],
"isUnique": true
}
},
@ -1091,4 +1035,4 @@
"internal": {
"indexes": {}
}
}
}

View File

@ -164,4 +164,4 @@
"breakpoints": true
}
]
}
}

View File

@ -0,0 +1,154 @@
import { eq, and } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db } from '@/db'
import { skillCustomizations } from '@/db/schema'
import { getViewerId } from '@/lib/viewer'
/**
* POST /api/worksheets/skills/[skillId]/customize
*
* Save a customization of a default skill
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ skillId: string }> }
) {
try {
const viewerId = await getViewerId()
const { skillId } = await params
const body = await request.json()
const { operator, digitRange, regroupingConfig, displayRules } = body
// Validate required fields
if (!operator || !digitRange || !regroupingConfig || !displayRules) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
// Validate operator
if (operator !== 'addition' && operator !== 'subtraction') {
return NextResponse.json({ error: 'Invalid operator' }, { status: 400 })
}
const now = new Date().toISOString()
// Check if customization already exists
const existing = await db.query.skillCustomizations.findFirst({
where: and(
eq(skillCustomizations.userId, viewerId),
eq(skillCustomizations.skillId, skillId),
eq(skillCustomizations.operator, operator)
),
})
if (existing) {
// Update existing customization
await db
.update(skillCustomizations)
.set({
digitRange: JSON.stringify(digitRange),
regroupingConfig: JSON.stringify(regroupingConfig),
displayRules: JSON.stringify(displayRules),
updatedAt: now,
})
.where(
and(
eq(skillCustomizations.userId, viewerId),
eq(skillCustomizations.skillId, skillId),
eq(skillCustomizations.operator, operator)
)
)
} else {
// Insert new customization
await db.insert(skillCustomizations).values({
userId: viewerId,
skillId,
operator,
digitRange: JSON.stringify(digitRange),
regroupingConfig: JSON.stringify(regroupingConfig),
displayRules: JSON.stringify(displayRules),
updatedAt: now,
})
}
// Fetch the updated/created customization
const customization = await db.query.skillCustomizations.findFirst({
where: and(
eq(skillCustomizations.userId, viewerId),
eq(skillCustomizations.skillId, skillId),
eq(skillCustomizations.operator, operator)
),
})
if (!customization) {
return NextResponse.json({ error: 'Failed to fetch customization' }, { status: 500 })
}
// Return parsed customization
return NextResponse.json({
customization: {
...customization,
digitRange: JSON.parse(customization.digitRange),
regroupingConfig: JSON.parse(customization.regroupingConfig),
displayRules: JSON.parse(customization.displayRules),
},
})
} catch (error) {
console.error('Failed to save skill customization:', error)
return NextResponse.json({ error: 'Failed to save skill customization' }, { status: 500 })
}
}
/**
* DELETE /api/worksheets/skills/[skillId]/customize?operator=addition
*
* Reset a skill to its default by deleting the customization
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ skillId: string }> }
) {
try {
const viewerId = await getViewerId()
const { skillId } = await params
const { searchParams } = new URL(request.url)
const operator = searchParams.get('operator') as 'addition' | 'subtraction' | null
if (!operator) {
return NextResponse.json({ error: 'Operator is required' }, { status: 400 })
}
if (operator !== 'addition' && operator !== 'subtraction') {
return NextResponse.json({ error: 'Invalid operator' }, { status: 400 })
}
// Check if customization exists
const existing = await db.query.skillCustomizations.findFirst({
where: and(
eq(skillCustomizations.userId, viewerId),
eq(skillCustomizations.skillId, skillId),
eq(skillCustomizations.operator, operator)
),
})
if (!existing) {
return NextResponse.json({ error: 'Skill customization not found' }, { status: 404 })
}
// Delete the customization
await db
.delete(skillCustomizations)
.where(
and(
eq(skillCustomizations.userId, viewerId),
eq(skillCustomizations.skillId, skillId),
eq(skillCustomizations.operator, operator)
)
)
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to delete skill customization:', error)
return NextResponse.json({ error: 'Failed to delete skill customization' }, { status: 500 })
}
}

View File

@ -0,0 +1,101 @@
import { eq, and } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db } from '@/db'
import { customSkills } from '@/db/schema'
import { getViewerId } from '@/lib/viewer'
/**
* PUT /api/worksheets/skills/custom/[id]
*
* Update an existing custom skill
*/
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const viewerId = await getViewerId()
const { id } = await params
const body = await request.json()
const { name, description, digitRange, regroupingConfig, displayRules } = body
// Verify skill exists and belongs to user
const existing = await db.query.customSkills.findFirst({
where: and(eq(customSkills.id, id), eq(customSkills.userId, viewerId)),
})
if (!existing) {
return NextResponse.json({ error: 'Custom skill not found' }, { status: 404 })
}
// Build update object (only update provided fields)
const updates: Record<string, string> = {
updatedAt: new Date().toISOString(),
}
if (name) updates.name = name
if (description !== undefined) updates.description = description
if (digitRange) updates.digitRange = JSON.stringify(digitRange)
if (regroupingConfig) updates.regroupingConfig = JSON.stringify(regroupingConfig)
if (displayRules) updates.displayRules = JSON.stringify(displayRules)
await db
.update(customSkills)
.set(updates)
.where(and(eq(customSkills.id, id), eq(customSkills.userId, viewerId)))
// Fetch updated skill
const updated = await db.query.customSkills.findFirst({
where: and(eq(customSkills.id, id), eq(customSkills.userId, viewerId)),
})
if (!updated) {
return NextResponse.json({ error: 'Failed to fetch updated skill' }, { status: 500 })
}
// Return parsed skill
return NextResponse.json({
skill: {
...updated,
digitRange: JSON.parse(updated.digitRange),
regroupingConfig: JSON.parse(updated.regroupingConfig),
displayRules: JSON.parse(updated.displayRules),
},
})
} catch (error) {
console.error('Failed to update custom skill:', error)
return NextResponse.json({ error: 'Failed to update custom skill' }, { status: 500 })
}
}
/**
* DELETE /api/worksheets/skills/custom/[id]
*
* Delete a custom skill
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const viewerId = await getViewerId()
const { id } = await params
// Verify skill exists and belongs to user
const existing = await db.query.customSkills.findFirst({
where: and(eq(customSkills.id, id), eq(customSkills.userId, viewerId)),
})
if (!existing) {
return NextResponse.json({ error: 'Custom skill not found' }, { status: 404 })
}
// Delete the skill
await db
.delete(customSkills)
.where(and(eq(customSkills.id, id), eq(customSkills.userId, viewerId)))
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to delete custom skill:', error)
return NextResponse.json({ error: 'Failed to delete custom skill' }, { status: 500 })
}
}

View File

@ -0,0 +1,97 @@
import { eq, and } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db } from '@/db'
import { customSkills } from '@/db/schema'
import { getViewerId } from '@/lib/viewer'
import { nanoid } from 'nanoid'
/**
* GET /api/worksheets/skills/custom
*
* Get all custom skills for the current user
*/
export async function GET(request: NextRequest) {
try {
const viewerId = await getViewerId()
const { searchParams } = new URL(request.url)
const operator = searchParams.get('operator') as 'addition' | 'subtraction' | null
const query = operator
? and(eq(customSkills.userId, viewerId), eq(customSkills.operator, operator))
: eq(customSkills.userId, viewerId)
const skills = await db.query.customSkills.findMany({
where: query,
orderBy: (customSkills, { asc }) => [asc(customSkills.createdAt)],
})
// Parse JSON fields
const parsed = skills.map((skill) => ({
...skill,
digitRange: JSON.parse(skill.digitRange),
regroupingConfig: JSON.parse(skill.regroupingConfig),
displayRules: JSON.parse(skill.displayRules),
}))
return NextResponse.json({ skills: parsed })
} catch (error) {
console.error('Failed to fetch custom skills:', error)
return NextResponse.json({ error: 'Failed to fetch custom skills' }, { status: 500 })
}
}
/**
* POST /api/worksheets/skills/custom
*
* Create a new custom skill
*/
export async function POST(request: NextRequest) {
try {
const viewerId = await getViewerId()
const body = await request.json()
const { name, description, operator, digitRange, regroupingConfig, displayRules } = body
// Validate required fields
if (!name || !operator || !digitRange || !regroupingConfig || !displayRules) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
// Validate operator
if (operator !== 'addition' && operator !== 'subtraction') {
return NextResponse.json({ error: 'Invalid operator' }, { status: 400 })
}
// Generate ID with custom prefix
const id = `custom-${nanoid(10)}`
const now = new Date().toISOString()
const newSkill = {
id,
userId: viewerId,
operator,
name,
description: description || null,
digitRange: JSON.stringify(digitRange),
regroupingConfig: JSON.stringify(regroupingConfig),
displayRules: JSON.stringify(displayRules),
createdAt: now,
updatedAt: now,
}
await db.insert(customSkills).values(newSkill)
// Return parsed skill
return NextResponse.json({
skill: {
...newSkill,
digitRange,
regroupingConfig,
displayRules,
},
})
} catch (error) {
console.error('Failed to create custom skill:', error)
return NextResponse.json({ error: 'Failed to create custom skill' }, { status: 500 })
}
}

View File

@ -0,0 +1,40 @@
import { eq, and } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db } from '@/db'
import { skillCustomizations } from '@/db/schema'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/worksheets/skills/customizations?operator=addition
*
* Get all skill customizations for the current user
*/
export async function GET(request: NextRequest) {
try {
const viewerId = await getViewerId()
const { searchParams } = new URL(request.url)
const operator = searchParams.get('operator') as 'addition' | 'subtraction' | null
const query = operator
? and(eq(skillCustomizations.userId, viewerId), eq(skillCustomizations.operator, operator))
: eq(skillCustomizations.userId, viewerId)
const customizations = await db.query.skillCustomizations.findMany({
where: query,
orderBy: (skillCustomizations, { asc }) => [asc(skillCustomizations.updatedAt)],
})
// Parse JSON fields
const parsed = customizations.map((customization) => ({
...customization,
digitRange: JSON.parse(customization.digitRange),
regroupingConfig: JSON.parse(customization.regroupingConfig),
displayRules: JSON.parse(customization.displayRules),
}))
return NextResponse.json({ customizations: parsed })
} catch (error) {
console.error('Failed to fetch skill customizations:', error)
return NextResponse.json({ error: 'Failed to fetch skill customizations' }, { status: 500 })
}
}

View File

@ -171,6 +171,23 @@ function PreviewContent({ formState, initialData, isScrolling = false }: Workshe
const observer = new IntersectionObserver(
(entries) => {
// Find the most visible page among all entries
let mostVisiblePage = currentPage
let maxRatio = 0
entries.forEach((entry) => {
if (entry.intersectionRatio > maxRatio) {
maxRatio = entry.intersectionRatio
mostVisiblePage = Number(entry.target.getAttribute('data-page-index'))
}
})
// Update current page if we found a more visible page
if (maxRatio > 0) {
setCurrentPage(mostVisiblePage)
}
// Update visible pages set
setVisiblePages((prev) => {
const next = new Set(prev)
@ -183,11 +200,6 @@ function PreviewContent({ formState, initialData, isScrolling = false }: Workshe
// Preload adjacent pages for smooth scrolling
if (pageIndex > 0) next.add(pageIndex - 1)
if (pageIndex < totalPages - 1) next.add(pageIndex + 1)
// Update current page indicator based on most visible page
if (entry.intersectionRatio > 0.5) {
setCurrentPage(pageIndex)
}
}
})

View File

@ -0,0 +1,145 @@
import type { Meta, StoryObj } from '@storybook/react'
import { DifficultyPlot2D } from './DifficultyPlot2D'
import { DIFFICULTY_PROFILES } from '../../difficultyProfiles'
const meta = {
title: 'Worksheets/Config Panel/DifficultyPlot2D',
component: DifficultyPlot2D,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
} satisfies Meta<typeof DifficultyPlot2D>
export default meta
type Story = StoryObj<typeof meta>
export const EarlyLearner: Story = {
args: {
pAnyStart: DIFFICULTY_PROFILES.earlyLearner.regrouping.pAnyStart,
pAllStart: DIFFICULTY_PROFILES.earlyLearner.regrouping.pAllStart,
displayRules: DIFFICULTY_PROFILES.earlyLearner.displayRules,
onChange: (config) => {
console.log('Selected configuration:', config)
},
isDark: false,
},
}
export const Intermediate: Story = {
args: {
pAnyStart: DIFFICULTY_PROFILES.intermediate.regrouping.pAnyStart,
pAllStart: DIFFICULTY_PROFILES.intermediate.regrouping.pAllStart,
displayRules: DIFFICULTY_PROFILES.intermediate.displayRules,
onChange: (config) => {
console.log('Selected configuration:', config)
},
isDark: false,
},
}
export const Expert: Story = {
args: {
pAnyStart: DIFFICULTY_PROFILES.expert.regrouping.pAnyStart,
pAllStart: DIFFICULTY_PROFILES.expert.regrouping.pAllStart,
displayRules: DIFFICULTY_PROFILES.expert.displayRules,
onChange: (config) => {
console.log('Selected configuration:', config)
},
isDark: false,
},
}
export const CustomConfiguration: Story = {
args: {
pAnyStart: 0.5,
pAllStart: 0.1,
displayRules: {
carryBoxes: 'sometimes',
answerBoxes: 'always',
placeValueColors: 'never',
tenFrames: 'never',
problemNumbers: 'always',
cellBorders: 'always',
borrowNotation: 'never',
borrowingHints: 'never',
},
onChange: (config) => {
console.log('Selected configuration:', config)
},
isDark: false,
},
}
export const DarkMode: Story = {
args: {
pAnyStart: DIFFICULTY_PROFILES.intermediate.regrouping.pAnyStart,
pAllStart: DIFFICULTY_PROFILES.intermediate.regrouping.pAllStart,
displayRules: DIFFICULTY_PROFILES.intermediate.displayRules,
onChange: (config) => {
console.log('Selected configuration:', config)
},
isDark: true,
},
parameters: {
backgrounds: { default: 'dark' },
},
}
export const MasteryProgressionView: Story = {
args: {
pAnyStart: 0.5,
pAllStart: 0.1,
displayRules: {
carryBoxes: 'sometimes',
answerBoxes: 'always',
placeValueColors: 'never',
tenFrames: 'never',
problemNumbers: 'always',
cellBorders: 'always',
borrowNotation: 'never',
borrowingHints: 'never',
},
onChange: (config) => {
console.log('Selected configuration:', config)
},
isDark: false,
customPoints: [
{
id: 'skill-1',
label: 'Single Digit',
pAnyStart: 0,
pAllStart: 0,
displayRules: DIFFICULTY_PROFILES.earlyLearner.displayRules,
},
{
id: 'skill-2',
label: 'No Regroup',
pAnyStart: 0,
pAllStart: 0,
displayRules: DIFFICULTY_PROFILES.earlyLearner.displayRules,
},
{
id: 'skill-3',
label: 'Some Regroup',
pAnyStart: 0.25,
pAllStart: 0,
displayRules: DIFFICULTY_PROFILES.intermediate.displayRules,
},
{
id: 'skill-4',
label: 'More Regroup',
pAnyStart: 0.5,
pAllStart: 0.1,
displayRules: DIFFICULTY_PROFILES.intermediate.displayRules,
},
{
id: 'skill-5',
label: 'Advanced',
pAnyStart: 0.75,
pAllStart: 0.25,
displayRules: DIFFICULTY_PROFILES.expert.displayRules,
},
],
},
}

View File

@ -0,0 +1,640 @@
'use client'
import { useState } from 'react'
import { css } from '@styled/css'
import {
DIFFICULTY_PROFILES,
DIFFICULTY_PROGRESSION,
calculateRegroupingIntensity,
calculateScaffoldingLevel,
REGROUPING_PROGRESSION,
SCAFFOLDING_PROGRESSION,
findNearestValidState,
getProfileFromConfig,
} from '../../difficultyProfiles'
import type { DisplayRules } from '../../displayRules'
export interface PlotPoint {
id: string
label: string
pAnyStart: number
pAllStart: number
displayRules: DisplayRules
}
export interface DifficultyPlot2DProps {
/** Current regrouping config */
pAnyStart: number
pAllStart: number
/** Current display rules */
displayRules: DisplayRules
/** Callback when user clicks a point on the plot */
onChange: (config: {
pAnyStart: number
pAllStart: number
displayRules: DisplayRules
matchedProfile: string
}) => void
/** Whether to show the plot in dark mode */
isDark?: boolean
/** Custom points to plot (instead of default difficulty presets) */
customPoints?: PlotPoint[]
}
/**
* Interactive 2D plot for difficulty configuration
* Shows regrouping intensity (x-axis) vs scaffolding level (y-axis)
* Allows users to click to select a difficulty configuration
*/
export function DifficultyPlot2D({
pAnyStart,
pAllStart,
displayRules,
onChange,
isDark = false,
customPoints,
}: DifficultyPlot2DProps) {
const [hoverPoint, setHoverPoint] = useState<{ x: number; y: number } | null>(null)
const [hoveredSkill, setHoveredSkill] = useState<PlotPoint | null>(null)
// Use custom points if provided, otherwise use default presets
const pointsToPlot = customPoints ?? null
// Make responsive - use container width with max size
const maxSize = 500
const width = maxSize
const height = maxSize
const padding = 40
const graphWidth = width - padding * 2
const graphHeight = height - padding * 2
const currentReg = calculateRegroupingIntensity(pAnyStart, pAllStart)
const currentScaf = calculateScaffoldingLevel(displayRules, currentReg)
// Convert 0-10 scale to SVG coordinates
const toX = (val: number) => padding + (val / 10) * graphWidth
const toY = (val: number) => height - padding - (val / 10) * graphHeight
// Convert SVG coordinates to 0-10 scale
const fromX = (x: number) => Math.max(0, Math.min(10, ((x - padding) / graphWidth) * 10))
const fromY = (y: number) =>
Math.max(0, Math.min(10, ((height - padding - y) / graphHeight) * 10))
// Helper to calculate valid target from mouse position
const calculateValidTarget = (clientX: number, clientY: number, svg: SVGSVGElement) => {
const rect = svg.getBoundingClientRect()
const x = clientX - rect.left
const y = clientY - rect.top
// Convert to difficulty space (0-10)
const regroupingIntensity = fromX(x)
const scaffoldingLevel = fromY(y)
// Check if we're near a preset (within snap threshold)
const snapThreshold = 1.0 // 1.0 units in 0-10 scale
let nearestPreset: {
distance: number
pAnyStart: number
pAllStart: number
displayRules: DisplayRules
id: string
} | null = null
// If custom points provided, snap to those instead of default presets
if (pointsToPlot) {
for (const point of pointsToPlot) {
const presetReg = calculateRegroupingIntensity(point.pAnyStart, point.pAllStart)
const presetScaf = calculateScaffoldingLevel(point.displayRules, presetReg)
// Calculate Euclidean distance
const distance = Math.sqrt(
(regroupingIntensity - presetReg) ** 2 + (scaffoldingLevel - presetScaf) ** 2
)
if (distance <= snapThreshold) {
if (!nearestPreset || distance < nearestPreset.distance) {
nearestPreset = {
distance,
pAnyStart: point.pAnyStart,
pAllStart: point.pAllStart,
displayRules: point.displayRules,
id: point.id,
}
}
}
}
} else {
// Use default difficulty profiles
for (const profileName of DIFFICULTY_PROGRESSION) {
const p = DIFFICULTY_PROFILES[profileName]
const presetReg = calculateRegroupingIntensity(
p.regrouping.pAnyStart,
p.regrouping.pAllStart
)
const presetScaf = calculateScaffoldingLevel(p.displayRules, presetReg)
// Calculate Euclidean distance
const distance = Math.sqrt(
(regroupingIntensity - presetReg) ** 2 + (scaffoldingLevel - presetScaf) ** 2
)
if (distance <= snapThreshold) {
if (!nearestPreset || distance < nearestPreset.distance) {
nearestPreset = {
distance,
pAnyStart: p.regrouping.pAnyStart,
pAllStart: p.regrouping.pAllStart,
displayRules: p.displayRules,
id: p.name,
}
}
}
}
}
// If we found a nearby preset, snap to it
if (nearestPreset) {
return {
newRegrouping: { pAnyStart: nearestPreset.pAnyStart, pAllStart: nearestPreset.pAllStart },
newDisplayRules: nearestPreset.displayRules,
matchedProfile: nearestPreset.id,
reg: calculateRegroupingIntensity(nearestPreset.pAnyStart, nearestPreset.pAllStart),
scaf: calculateScaffoldingLevel(
nearestPreset.displayRules,
calculateRegroupingIntensity(nearestPreset.pAnyStart, nearestPreset.pAllStart)
),
}
}
// No preset nearby, use normal progression indices
const regroupingIdx = Math.round(
(regroupingIntensity / 10) * (REGROUPING_PROGRESSION.length - 1)
)
const scaffoldingIdx = Math.round(
((10 - scaffoldingLevel) / 10) * (SCAFFOLDING_PROGRESSION.length - 1)
)
// Find nearest valid state (applies pedagogical constraints)
const validState = findNearestValidState(regroupingIdx, scaffoldingIdx)
// Get actual values from progressions
const newRegrouping = REGROUPING_PROGRESSION[validState.regroupingIdx]
const newDisplayRules = SCAFFOLDING_PROGRESSION[validState.scaffoldingIdx]
// Calculate display coordinates
const reg = calculateRegroupingIntensity(newRegrouping.pAnyStart, newRegrouping.pAllStart)
const scaf = calculateScaffoldingLevel(newDisplayRules, reg)
// Check if this matches a preset
const matchedProfile = getProfileFromConfig(
newRegrouping.pAllStart,
newRegrouping.pAnyStart,
newDisplayRules
)
return {
newRegrouping,
newDisplayRules,
matchedProfile,
reg,
scaf,
}
}
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
// Don't show hover preview if we're already hovering over a skill
if (hoveredSkill) {
setHoverPoint(null)
return
}
const svg = e.currentTarget
const target = calculateValidTarget(e.clientX, e.clientY, svg)
setHoverPoint({ x: target.reg, y: target.scaf })
}
const handleMouseLeave = () => {
setHoverPoint(null)
setHoveredSkill(null)
}
const handleClick = (e: React.MouseEvent<SVGSVGElement>) => {
const svg = e.currentTarget
const target = calculateValidTarget(e.clientX, e.clientY, svg)
// Call onChange with new configuration
onChange({
pAnyStart: target.newRegrouping.pAnyStart,
pAllStart: target.newRegrouping.pAllStart,
displayRules: target.newDisplayRules,
matchedProfile: target.matchedProfile,
})
}
return (
<div
data-component="difficulty-plot-2d"
className={css({
position: 'relative',
w: 'full',
display: 'flex',
justifyContent: 'center',
bg: isDark ? 'gray.900' : 'white',
rounded: 'lg',
p: '4',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<svg
width="100%"
height={height}
viewBox={`0 0 ${width} ${height}`}
onClick={handleClick}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className={css({
maxWidth: `${maxSize}px`,
cursor: 'crosshair',
userSelect: 'none',
})}
>
{/* Grid lines */}
{[0, 2, 4, 6, 8, 10].map((val) => (
<g key={`grid-${val}`}>
<line
x1={toX(val)}
y1={padding}
x2={toX(val)}
y2={height - padding}
stroke="#e5e7eb"
strokeWidth="1"
strokeDasharray="3,3"
/>
<line
x1={padding}
y1={toY(val)}
x2={width - padding}
y2={toY(val)}
stroke="#e5e7eb"
strokeWidth="1"
strokeDasharray="3,3"
/>
</g>
))}
{/* Axes */}
<line
x1={padding}
y1={height - padding}
x2={width - padding}
y2={height - padding}
stroke="#374151"
strokeWidth="2"
/>
<line
x1={padding}
y1={padding}
x2={padding}
y2={height - padding}
stroke="#374151"
strokeWidth="2"
/>
{/* Axis labels */}
<text
x={width / 2}
y={height - 10}
textAnchor="middle"
fontSize="13"
fontWeight="500"
fill="#4b5563"
>
Regrouping Intensity
</text>
<text
x={15}
y={height / 2}
textAnchor="middle"
fontSize="13"
fontWeight="500"
fill="#4b5563"
transform={`rotate(-90, 15, ${height / 2})`}
>
Scaffolding (more help)
</text>
{/* Preset points or custom skill points */}
{pointsToPlot ? (
<>
{/* Custom points (other skills in mastery progression) */}
{/* First, render directional edges connecting skills in order */}
{pointsToPlot.map((point, index) => {
if (index === pointsToPlot.length - 1) return null // Skip last point (no edge after it)
const nextPoint = pointsToPlot[index + 1]
const fromReg = calculateRegroupingIntensity(point.pAnyStart, point.pAllStart)
const fromScaf = calculateScaffoldingLevel(point.displayRules, fromReg)
const toReg = calculateRegroupingIntensity(nextPoint.pAnyStart, nextPoint.pAllStart)
const toScaf = calculateScaffoldingLevel(nextPoint.displayRules, toReg)
const fromX = toX(fromReg)
const fromY = toY(fromScaf)
const toXPos = toX(toReg)
const toYPos = toY(toScaf)
// Calculate arrow head
const dx = toXPos - fromX
const dy = toYPos - fromY
const length = Math.sqrt(dx * dx + dy * dy)
const unitX = dx / length
const unitY = dy / length
// Shorten line to not overlap with circles (radius 5)
const startX = fromX + unitX * 8
const startY = fromY + unitY * 8
const endX = toXPos - unitX * 8
const endY = toYPos - unitY * 8
// Arrow head
const arrowSize = 8
const arrowAngle = Math.PI / 6 // 30 degrees
const angle = Math.atan2(dy, dx)
const arrowPoint1X = endX - arrowSize * Math.cos(angle - arrowAngle)
const arrowPoint1Y = endY - arrowSize * Math.sin(angle - arrowAngle)
const arrowPoint2X = endX - arrowSize * Math.cos(angle + arrowAngle)
const arrowPoint2Y = endY - arrowSize * Math.sin(angle + arrowAngle)
return (
<g key={`edge-${point.id}-${nextPoint.id}`}>
{/* Edge line */}
<line
x1={startX}
y1={startY}
x2={endX}
y2={endY}
stroke="#a855f7"
strokeWidth="2"
opacity="0.4"
strokeDasharray="4,2"
/>
{/* Arrow head */}
<polygon
points={`${endX},${endY} ${arrowPoint1X},${arrowPoint1Y} ${arrowPoint2X},${arrowPoint2Y}`}
fill="#a855f7"
opacity="0.6"
/>
</g>
)
})}
{/* Then render the skill points on top of edges */}
{pointsToPlot.map((point, index) => {
const reg = calculateRegroupingIntensity(point.pAnyStart, point.pAllStart)
const scaf = calculateScaffoldingLevel(point.displayRules, reg)
return (
<g
key={point.id}
onMouseEnter={(e) => {
e.stopPropagation()
setHoveredSkill(point)
setHoverPoint(null)
}}
onMouseLeave={(e) => {
e.stopPropagation()
setHoveredSkill(null)
}}
onMouseMove={(e) => {
e.stopPropagation()
}}
style={{ cursor: 'help' }}
>
{/* Larger invisible circle for easier hovering */}
<circle cx={toX(reg)} cy={toY(scaf)} r="15" fill="transparent" />
{/* Visible skill circle */}
<circle
cx={toX(reg)}
cy={toY(scaf)}
r="10"
fill="#9333ea"
stroke="#7e22ce"
strokeWidth="2"
opacity={hoveredSkill?.id === point.id ? 1 : 0.7}
/>
{/* Ordinal number inside circle */}
<text
x={toX(reg)}
y={toY(scaf)}
textAnchor="middle"
dominantBaseline="central"
fontSize="11"
fill="white"
fontWeight="700"
pointerEvents="none"
>
{index + 1}
</text>
</g>
)
})}
</>
) : (
// Default difficulty presets
DIFFICULTY_PROGRESSION.map((profileName) => {
const p = DIFFICULTY_PROFILES[profileName]
const reg = calculateRegroupingIntensity(p.regrouping.pAnyStart, p.regrouping.pAllStart)
const scaf = calculateScaffoldingLevel(p.displayRules, reg)
return (
<g key={profileName}>
<circle
cx={toX(reg)}
cy={toY(scaf)}
r="5"
fill="#6366f1"
stroke="#4f46e5"
strokeWidth="2"
opacity="0.7"
/>
<text
x={toX(reg)}
y={toY(scaf) - 10}
textAnchor="middle"
fontSize="11"
fill="#4338ca"
fontWeight="600"
>
{p.label}
</text>
</g>
)
})
)}
{/* Hover preview - show where click will land */}
{hoverPoint && (
<>
{/* Dashed line from hover to target */}
<line
x1={toX(hoverPoint.x)}
y1={toY(hoverPoint.y)}
x2={toX(currentReg)}
y2={toY(currentScaf)}
stroke="#f59e0b"
strokeWidth="2"
strokeDasharray="5,5"
opacity="0.5"
/>
{/* Hover target marker */}
<circle
cx={toX(hoverPoint.x)}
cy={toY(hoverPoint.y)}
r="10"
fill="#f59e0b"
stroke="#d97706"
strokeWidth="3"
opacity="0.8"
/>
<circle cx={toX(hoverPoint.x)} cy={toY(hoverPoint.y)} r="4" fill="white" />
</>
)}
{/* Current position */}
<circle
cx={toX(currentReg)}
cy={toY(currentScaf)}
r="8"
fill="#10b981"
stroke="#059669"
strokeWidth="3"
/>
<circle cx={toX(currentReg)} cy={toY(currentScaf)} r="3" fill="white" />
</svg>
{/* Hover tooltip for skill information */}
{hoveredSkill && pointsToPlot && (
<div
className={css({
position: 'absolute',
bottom: '10px',
left: '50%',
transform: 'translateX(-50%)',
bg: isDark ? 'purple.900' : 'purple.50',
border: '2px solid',
borderColor: isDark ? 'purple.700' : 'purple.300',
borderRadius: 'lg',
px: '4',
py: '2',
boxShadow: 'lg',
pointerEvents: 'none',
zIndex: 10,
maxWidth: '300px',
})}
>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color: isDark ? 'purple.400' : 'purple.600',
textTransform: 'uppercase',
letterSpacing: 'wider',
mb: '1',
})}
>
Skill {pointsToPlot.findIndex((p) => p.id === hoveredSkill.id) + 1}
</div>
<div
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: isDark ? 'purple.100' : 'purple.900',
})}
>
{hoveredSkill.label}
</div>
</div>
)}
{/* Legend for custom points */}
{pointsToPlot && (
<div
className={css({
position: 'absolute',
top: '10px',
right: '10px',
bg: isDark ? 'gray.800' : 'white',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderRadius: 'md',
px: '3',
py: '2',
fontSize: 'xs',
color: isDark ? 'gray.300' : 'gray.700',
boxShadow: 'sm',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
mb: '1',
})}
>
<div
className={css({
w: '20px',
h: '20px',
borderRadius: 'full',
bg: '#9333ea',
border: '2px solid #7e22ce',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '10px',
color: 'white',
fontWeight: '700',
})}
>
#
</div>
<span>Skills in progression</span>
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
})}
>
<div
className={css({
w: '20px',
h: '20px',
borderRadius: 'full',
bg: '#10b981',
border: '2px solid #059669',
})}
/>
<span>Current configuration</span>
</div>
<div
className={css({
mt: '2',
pt: '2',
borderTop: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
fontSize: '10px',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Hover skills for details
</div>
</div>
)}
</div>
)
}

View File

@ -8,6 +8,8 @@ import type { SkillId } from '../../skills'
import { getSkillById, getSkillsByOperator } from '../../skills'
import { AllSkillsModal } from './AllSkillsModal'
import { CustomizeMixModal } from './CustomizeMixModal'
import { SkillConfigurationModal } from './SkillConfigurationModal'
import type { DisplayRules } from '../../displayRules'
interface MasteryModePanelProps {
formState: WorksheetFormState
@ -27,6 +29,8 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master
const [isLoadingMastery, setIsLoadingMastery] = useState(true)
const [isAllSkillsModalOpen, setIsAllSkillsModalOpen] = useState(false)
const [isCustomizeMixModalOpen, setIsCustomizeMixModalOpen] = useState(false)
const [isConfigureModalOpen, setIsConfigureModalOpen] = useState(false)
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
// Get current operator (default to addition)
const operator = formState.operator ?? 'addition'
@ -216,6 +220,85 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master
}
}
// Handler: Save skill customization
const handleSaveCustomization = async (config: {
name: string
description?: string
digitRange: { min: number; max: number }
regroupingConfig: { pAnyStart: number; pAllStart: number }
displayRules: DisplayRules
}) => {
if (!currentSkill) return
try {
const response = await fetch(`/api/worksheets/skills/${currentSkill.id}/customize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
operator,
digitRange: config.digitRange,
regroupingConfig: config.regroupingConfig,
displayRules: config.displayRules,
}),
})
if (!response.ok) {
throw new Error('Failed to save skill customization')
}
// Apply the new configuration to the form state
onChange({
digitRange: config.digitRange,
pAnyStart: config.regroupingConfig.pAnyStart,
pAllStart: config.regroupingConfig.pAllStart,
displayRules: config.displayRules,
} as Partial<WorksheetFormState>)
console.log('Skill customization saved successfully')
} catch (error) {
console.error('Failed to save skill customization:', error)
alert('Failed to save skill customization. Please try again.')
}
}
// Handler: Create custom skill
const handleCreateCustomSkill = async (config: {
name: string
description?: string
digitRange: { min: number; max: number }
regroupingConfig: { pAnyStart: number; pAllStart: number }
displayRules: DisplayRules
}) => {
try {
const response = await fetch('/api/worksheets/skills/custom', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: config.name,
description: config.description,
operator,
digitRange: config.digitRange,
regroupingConfig: config.regroupingConfig,
displayRules: config.displayRules,
}),
})
if (!response.ok) {
throw new Error('Failed to create custom skill')
}
const data = await response.json()
console.log('Custom skill created successfully:', data.skill)
// TODO: Reload skills list to include new custom skill
// For now, just notify the user
alert(`Custom skill "${config.name}" created successfully!`)
} catch (error) {
console.error('Failed to create custom skill:', error)
alert('Failed to create custom skill. Please try again.')
}
}
// Mixed mode: Show both skills
if (isMixedMode) {
if (!currentAdditionSkill || !currentSubtractionSkill) {
@ -920,14 +1003,65 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master
marginTop: '1rem',
display: 'flex',
gap: '0.75rem',
flexWrap: 'wrap',
})}
>
<button
type="button"
data-action="configure-skill"
onClick={() => setIsConfigureModalOpen(true)}
className={css({
flex: '1 1 auto',
padding: '0.75rem 1rem',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'blue.600' : 'blue.500',
backgroundColor: isDark ? 'blue.700' : 'blue.50',
color: isDark ? 'blue.200' : 'blue.700',
fontSize: '0.875rem',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: isDark ? 'blue.500' : 'blue.600',
backgroundColor: isDark ? 'blue.600' : 'blue.100',
},
})}
>
Configure Skill
</button>
<button
type="button"
data-action="create-custom-skill"
onClick={() => setIsCreateModalOpen(true)}
className={css({
flex: '1 1 auto',
padding: '0.75rem 1rem',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'green.600' : 'green.500',
backgroundColor: isDark ? 'green.700' : 'green.50',
color: isDark ? 'green.200' : 'green.700',
fontSize: '0.875rem',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: isDark ? 'green.500' : 'green.600',
backgroundColor: isDark ? 'green.600' : 'green.100',
},
})}
>
Create Custom Skill
</button>
<button
type="button"
data-action="view-all-skills"
onClick={() => setIsAllSkillsModalOpen(true)}
className={css({
flex: 1,
flex: '1 1 auto',
padding: '0.75rem 1rem',
borderRadius: '6px',
border: '1px solid',
@ -1059,6 +1193,38 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master
}}
isDark={isDark}
/>
{/* Configure Skill Modal */}
<SkillConfigurationModal
open={isConfigureModalOpen}
onClose={() => setIsConfigureModalOpen(false)}
mode="edit"
operator={operator === 'mixed' ? 'addition' : operator}
existingConfig={{
name: currentSkill.name,
description: currentSkill.description,
digitRange: currentSkill.digitRange,
regroupingConfig: currentSkill.regroupingConfig,
displayRules: currentSkill.recommendedScaffolding,
}}
onSave={handleSaveCustomization}
masteryProgressionSkills={availableSkills.map((skill) => ({
id: skill.id,
label: skill.name.split(' ').slice(0, 2).join(' '), // Shorten labels
pAnyStart: skill.regroupingConfig.pAnyStart,
pAllStart: skill.regroupingConfig.pAllStart,
displayRules: skill.recommendedScaffolding,
}))}
/>
{/* Create Custom Skill Modal */}
<SkillConfigurationModal
open={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
mode="create"
operator={operator === 'mixed' ? 'addition' : operator}
onSave={handleCreateCustomSkill}
/>
</div>
)
}

View File

@ -0,0 +1,138 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { SkillConfigurationModal } from './SkillConfigurationModal'
import { DIFFICULTY_PROFILES } from '../../difficultyProfiles'
const meta = {
title: 'Worksheets/Config Panel/SkillConfigurationModal',
component: SkillConfigurationModal,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
} satisfies Meta<typeof SkillConfigurationModal>
export default meta
type Story = StoryObj<typeof meta>
// Wrapper component to manage modal state
function ModalWrapper(args: React.ComponentProps<typeof SkillConfigurationModal>) {
const [open, setOpen] = useState(true)
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
style={{
padding: '12px 24px',
background: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
}}
>
Open Modal
</button>
<SkillConfigurationModal {...args} open={open} onClose={() => setOpen(false)} />
</>
)
}
export const CreateMode: Story = {
render: (args) => <ModalWrapper {...args} />,
args: {
mode: 'create',
operator: 'addition',
onSave: (config) => {
console.log('Created skill:', config)
},
},
}
export const EditModeEarlyLearner: Story = {
render: (args) => <ModalWrapper {...args} />,
args: {
mode: 'edit',
operator: 'addition',
existingConfig: {
name: 'Early Learner Addition',
description: 'Simple addition with lots of scaffolding',
digitRange: { min: 2, max: 2 },
regroupingConfig: DIFFICULTY_PROFILES.earlyLearner.regrouping,
displayRules: DIFFICULTY_PROFILES.earlyLearner.displayRules,
},
onSave: (config) => {
console.log('Updated skill:', config)
},
},
}
export const EditModeIntermediate: Story = {
render: (args) => <ModalWrapper {...args} />,
args: {
mode: 'edit',
operator: 'addition',
existingConfig: {
name: 'Intermediate Addition',
description: 'Moderate difficulty with some scaffolding',
digitRange: { min: 2, max: 3 },
regroupingConfig: DIFFICULTY_PROFILES.intermediate.regrouping,
displayRules: DIFFICULTY_PROFILES.intermediate.displayRules,
},
onSave: (config) => {
console.log('Updated skill:', config)
},
},
}
export const EditModeExpert: Story = {
render: (args) => <ModalWrapper {...args} />,
args: {
mode: 'edit',
operator: 'subtraction',
existingConfig: {
name: 'Expert Subtraction',
description: 'Advanced subtraction with minimal support',
digitRange: { min: 3, max: 4 },
regroupingConfig: DIFFICULTY_PROFILES.expert.regrouping,
displayRules: DIFFICULTY_PROFILES.expert.displayRules,
},
onSave: (config) => {
console.log('Updated skill:', config)
},
},
}
export const CustomConfiguration: Story = {
render: (args) => <ModalWrapper {...args} />,
args: {
mode: 'edit',
operator: 'addition',
existingConfig: {
name: 'Custom Skill',
description: 'My customized difficulty settings',
digitRange: { min: 2, max: 4 },
regroupingConfig: {
pAnyStart: 0.5,
pAllStart: 0.1,
},
displayRules: {
carryBoxes: 'sometimes',
answerBoxes: 'always',
placeValueColors: 'sometimes',
tenFrames: 'never',
problemNumbers: 'always',
cellBorders: 'always',
borrowNotation: 'never',
borrowingHints: 'never',
},
},
onSave: (config) => {
console.log('Updated custom skill:', config)
},
},
}

View File

@ -0,0 +1,382 @@
'use client'
import { useState } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { css } from '@styled/css'
import { stack } from '@styled/patterns'
import { useTheme } from '@/contexts/ThemeContext'
import { DifficultyPlot2D, type PlotPoint } from './DifficultyPlot2D'
import { DigitRangeSection } from './DigitRangeSection'
import type { DisplayRules } from '../../displayRules'
export interface SkillConfig {
digitRange: { min: number; max: number }
regroupingConfig: { pAnyStart: number; pAllStart: number }
displayRules: DisplayRules
}
export interface SkillConfigurationModalProps {
/** Whether modal is open */
open: boolean
/** Callback when modal should close */
onClose: () => void
/** Modal mode: 'create' for new skill, 'edit' for existing skill */
mode: 'create' | 'edit'
/** Operator for the skill */
operator: 'addition' | 'subtraction'
/** Existing skill configuration (for edit mode) */
existingConfig?: SkillConfig & { name: string; description?: string }
/** Callback when save is clicked */
onSave: (config: {
name: string
description?: string
digitRange: { min: number; max: number }
regroupingConfig: { pAnyStart: number; pAllStart: number }
displayRules: DisplayRules
}) => void
/** Other skills in mastery progression to plot (for edit mode) */
masteryProgressionSkills?: PlotPoint[]
}
/**
* Modal for configuring custom skills or editing existing skills
* Uses the 2D difficulty plot and digit range slider
*/
export function SkillConfigurationModal({
open,
onClose,
mode,
operator,
existingConfig,
onSave,
masteryProgressionSkills,
}: SkillConfigurationModalProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Form state
const [name, setName] = useState(existingConfig?.name ?? '')
const [description, setDescription] = useState(existingConfig?.description ?? '')
const [digitRange, setDigitRange] = useState(existingConfig?.digitRange ?? { min: 2, max: 2 })
const [regroupingConfig, setRegroupingConfig] = useState(
existingConfig?.regroupingConfig ?? { pAnyStart: 0.25, pAllStart: 0 }
)
const [displayRules, setDisplayRules] = useState<DisplayRules>(
existingConfig?.displayRules ?? {
carryBoxes: 'never',
answerBoxes: 'always',
placeValueColors: 'never',
tenFrames: 'never',
problemNumbers: 'always',
cellBorders: 'always',
borrowNotation: 'never',
borrowingHints: 'never',
}
)
const handleDifficultyChange = (config: {
pAnyStart: number
pAllStart: number
displayRules: DisplayRules
}) => {
setRegroupingConfig({ pAnyStart: config.pAnyStart, pAllStart: config.pAllStart })
setDisplayRules(config.displayRules)
}
const handleSave = () => {
if (!name.trim()) {
alert('Please enter a skill name')
return
}
onSave({
name: name.trim(),
description: description.trim() || undefined,
digitRange,
regroupingConfig,
displayRules,
})
onClose()
}
return (
<Dialog.Root open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<Dialog.Portal>
<Dialog.Overlay
className={css({
position: 'fixed',
inset: 0,
bg: 'rgba(0, 0, 0, 0.5)',
zIndex: 50,
})}
/>
<Dialog.Content
data-component="skill-configuration-modal"
className={css({
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
bg: isDark ? 'gray.800' : 'white',
borderRadius: 'xl',
boxShadow: 'xl',
p: '6',
maxWidth: '650px',
width: '90vw',
maxHeight: '90vh',
overflowY: 'auto',
zIndex: 51,
})}
>
{/* Header */}
<div className={stack({ gap: '2', mb: '5' })}>
<Dialog.Title
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.900',
})}
>
{mode === 'create' ? 'Create Custom Skill' : 'Configure Skill'}
</Dialog.Title>
<Dialog.Description
className={css({
fontSize: 'sm',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
{mode === 'create'
? 'Create a new skill with custom difficulty settings'
: 'Modify the difficulty settings for this skill'}
</Dialog.Description>
</div>
{/* Form */}
<div className={stack({ gap: '5' })}>
{/* Name Field */}
<div className={stack({ gap: '2' })}>
<label
htmlFor="skill-name"
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: isDark ? 'gray.300' : 'gray.700',
})}
>
Skill Name *
</label>
<input
id="skill-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Advanced Two-Digit Addition"
className={css({
px: '3',
py: '2',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderRadius: 'md',
bg: isDark ? 'gray.700' : 'white',
color: isDark ? 'gray.100' : 'gray.900',
fontSize: 'sm',
_focus: {
outline: 'none',
borderColor: isDark ? 'blue.500' : 'blue.600',
ring: '2px',
ringColor: isDark ? 'blue.500/20' : 'blue.600/20',
},
})}
/>
</div>
{/* Description Field */}
<div className={stack({ gap: '2' })}>
<label
htmlFor="skill-description"
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: isDark ? 'gray.300' : 'gray.700',
})}
>
Description (optional)
</label>
<textarea
id="skill-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe what students will practice in this skill..."
rows={3}
className={css({
px: '3',
py: '2',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderRadius: 'md',
bg: isDark ? 'gray.700' : 'white',
color: isDark ? 'gray.100' : 'gray.900',
fontSize: 'sm',
resize: 'vertical',
_focus: {
outline: 'none',
borderColor: isDark ? 'blue.500' : 'blue.600',
ring: '2px',
ringColor: isDark ? 'blue.500/20' : 'blue.600/20',
},
})}
/>
</div>
{/* Digit Range */}
<div>
<div
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: isDark ? 'gray.300' : 'gray.700',
mb: '3',
})}
>
Number of Digits
</div>
<DigitRangeSection
digitRange={digitRange}
onChange={(newDigitRange) => setDigitRange(newDigitRange)}
/>
</div>
{/* 2D Difficulty Plot */}
<div>
<div
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: isDark ? 'gray.300' : 'gray.700',
mb: '3',
})}
>
Difficulty Configuration
</div>
<DifficultyPlot2D
pAnyStart={regroupingConfig.pAnyStart}
pAllStart={regroupingConfig.pAllStart}
displayRules={displayRules}
onChange={handleDifficultyChange}
isDark={isDark}
customPoints={masteryProgressionSkills}
/>
<div
className={css({
mt: '2',
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.500',
textAlign: 'center',
})}
>
Click on the plot to select a difficulty configuration
</div>
</div>
{/* Current Configuration Summary */}
<div
className={css({
p: '3',
bg: isDark ? 'gray.700' : 'gray.50',
borderRadius: 'md',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
})}
>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
mb: '2',
})}
>
Current Configuration
</div>
<div
className={css({
fontSize: 'sm',
color: isDark ? 'gray.300' : 'gray.700',
})}
>
<div>
<strong>Digits:</strong>{' '}
{digitRange.min === digitRange.max
? digitRange.min
: `${digitRange.min}-${digitRange.max}`}
</div>
<div>
<strong>Regrouping:</strong> {Math.round(regroupingConfig.pAnyStart * 100)}%
</div>
<div>
<strong>Operator:</strong> {operator}
</div>
</div>
</div>
</div>
{/* Actions */}
<div
className={css({
display: 'flex',
gap: '3',
justifyContent: 'flex-end',
mt: '6',
})}
>
<button
type="button"
onClick={onClose}
data-action="cancel"
className={css({
px: '4',
py: '2',
fontSize: 'sm',
fontWeight: 'medium',
color: isDark ? 'gray.300' : 'gray.700',
bg: 'transparent',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderRadius: 'md',
cursor: 'pointer',
_hover: {
bg: isDark ? 'gray.700' : 'gray.50',
},
})}
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
data-action="save-skill"
className={css({
px: '4',
py: '2',
fontSize: 'sm',
fontWeight: 'medium',
color: 'white',
bg: isDark ? 'blue.600' : 'blue.700',
border: 'none',
borderRadius: 'md',
cursor: 'pointer',
_hover: {
bg: isDark ? 'blue.700' : 'blue.800',
},
})}
>
{mode === 'create' ? 'Create Skill' : 'Save Changes'}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}