diff --git a/.changeset/wicked-eggs-cheer.md b/.changeset/wicked-eggs-cheer.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/wicked-eggs-cheer.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/architecture-research.md b/.claude/architecture-research.md new file mode 100644 index 0000000000..5a89eb0827 --- /dev/null +++ b/.claude/architecture-research.md @@ -0,0 +1,612 @@ +# Perseus Architecture Research + +**Date**: February 5, 2026 +**Researcher**: Claude (AI Assistant) +**Focus**: Current widget state management, data flow, onChange patterns, and package structure + +## Executive Summary + +Perseus is a well-structured React monorepo with a clear separation of concerns across multiple packages. The current architecture uses a props-driven approach where widgets receive their state through `handleUserInput` callbacks and `userInput` props, rather than the deprecated `onChange` pattern. State management has been intentionally refactored to move away from the old widget-centric model toward a more centralized approach managed by the `UserInputManager` and `Renderer`. + +--- + +## 1. Widget State Management + +### Current Pattern: Props-Driven with Callbacks + +Modern widgets do NOT use the old `onChange` handler. Instead, they: + +1. **Receive state through props**: `userInput: TUserInput` (typed per widget) +2. **Call `handleUserInput` callback** when state changes: `handleUserInput(newUserInput, callback?, silent?)` +3. **Call `trackInteraction` callback** to notify about interactions + +#### Radio Widget Example (Multiple Choice) + +From `/packages/perseus/src/widgets/radio/multiple-choice-widget.tsx`: + +```typescript +type Props = WidgetProps; + +const handleChoiceChange = (choiceId: string, newCheckedState: boolean): void => { + // Build list of checked choice IDs + const checkedChoiceIds: string[] = []; + // ... logic to determine new selection state ... + + // Create new choice states + const newChoiceStates: ChoiceState[] = choiceStates + ? choiceStates.map((state) => ({...state})) + : choices.map(() => ({ + selected: false, + // ... other state fields ... + })); + + // Update states based on selection + newChoiceStates.forEach((choiceState: ChoiceState, i) => { + const choiceId = choices[i].id; + choiceState.selected = checkedChoiceIds.includes(choiceId); + }); + + // Notify parent of change + onChange({choiceStates: newChoiceStates}); // ← Still has onChange! + trackInteraction(); + announceChoiceChange(newChoiceStates); +}; +``` + +**Note**: The Radio widget still uses `onChange`, but this is marked for deprecation (LEMS-3542, LEMS-3245). This is a transitional state during the Radio Revitalization Project. + +#### NumericInput Widget Example + +From `/packages/perseus/src/widgets/numeric-input/numeric-input.tsx`: + +```typescript +const handleChange = (newValue: string): void => { + // Call handleUserInput with new state + props.handleUserInput({currentValue: newValue}); + props.trackInteraction(); +}; + +const handleFocus = (): void => { + props.onFocus([]); + setIsFocused(true); +}; + +const handleBlur = (): void => { + props.onBlur([]); + setIsFocused(false); +}; +``` + +**Key differences from Radio**: +- Uses `handleUserInput` callback (modern pattern) +- No local component state for user input +- Props-driven: receives `props.userInput.currentValue` and sends updates via callback + +### State Storage Location + +**User Input State**: Managed by `UserInputManager` component +- Location: `/packages/perseus/src/user-input-manager.tsx` +- Stored in a central `userInput` object: `UserInputMap` (type from `@khanacademy/perseus-core`) +- Updated via `handleUserInput(widgetId, newUserInput, widgetsEmpty)` callback + +**Widget-Specific State**: +- Some widgets maintain internal component state (see Radio's `choiceStates`) +- This is being transitioned to centralized state management +- Focus management state: `onFocus`, `onBlur` callbacks update parent renderer + +--- + +## 2. Data Flow Architecture + +### High-Level Flow + +``` +ServerItemRenderer + ↓ +UserInputManager (manages userInput state) + ↓ (provides userInput, handleUserInput, initializeUserInput) + ↓ +Renderer (question rendering) + ├─ renderWidget() → WidgetContainer + │ ├─ Passes WidgetProps (from getWidgetProps()) + │ └─ WidgetContainer → Widget Component + │ ├─ Receives userInput prop + │ ├─ Calls handleUserInput on change + │ └─ Calls trackInteraction on user action + │ + └─ Returns UserInputMap to parent +``` + +### Detailed Props Flow + +**Where**: `/packages/perseus/src/renderer.tsx` lines 511-575 + +```typescript +getWidgetProps(widgetId: string): WidgetProps { + const apiOptions = this.getApiOptions(); + const widgetProps = this.props.widgets[widgetId].options; // ← Config from question + const widgetInfo = this.state.widgetInfo[widgetId]; + + return { + ...widgetProps, // ← Widget-specific options + userInput: this.props.userInput?.[widgetId], // ← Current user input + widgetId: widgetId, + widgetIndex: this._getWidgetIndexById(widgetId), + alignment: widgetInfo && widgetInfo.alignment, + static: widgetInfo?.static, + problemNum: this.props.problemNum, + apiOptions: this.getApiOptions(), // ← API configuration + keypadElement: this.props.keypadElement, + showSolutions: this.props.showSolutions, + onFocus: _.partial(this._onWidgetFocus, widgetId), + onBlur: _.partial(this._onWidgetBlur, widgetId), + findWidgets: this.findWidgets, // ← Inter-widget communication + reviewMode: this.props.reviewMode, + + // ← Core state management callback + handleUserInput: (newUserInput: UserInput) => { + const updatedUserInput = { + ...this.props.userInput, + [widgetId]: newUserInput, + }; + const emptyWidgetIds = emptyWidgetsFunctional( + this.state.widgetInfo, + this.widgetIds, + updatedUserInput, + this.context.locale, + ); + const widgetsEmpty = emptyWidgetIds.length > 0; + this.props.handleUserInput?.( + widgetId, + newUserInput, + widgetsEmpty, + ); + this.props.apiOptions?.interactionCallback?.(updatedUserInput); + }, + + trackInteraction: interactionTracker.track, + }; +} +``` + +### User Input Manager Flow + +**Where**: `/packages/perseus/src/user-input-manager.tsx` + +```typescript +export function sharedInitializeUserInput( + widgetOptions: PerseusWidgetsMap | undefined, + problemNum: number, +): UserInputMap { + const startUserInput: UserInputMap = {}; + + // For each widget, call widget's initialization function + Object.entries(widgetOptions).forEach(([id, widgetInfo]) => { + const widgetExports = Widgets.getWidgetExport(widgetInfo.type); + + // Static widgets use correct answer + if (widgetInfo.static && widgetExports?.getCorrectUserInput) { + startUserInput[id] = widgetExports.getCorrectUserInput( + widgetInfo.options, + ); + } + // Other widgets use start input + else if (widgetExports?.getStartUserInput) { + startUserInput[id] = widgetExports.getStartUserInput( + widgetInfo.options, + problemNum ?? 0, + ); + } + }); + + return startUserInput; +} + +// Then in component: +function handleUserInput( + id: string, + nextUserInput: UserInputMap[keyof UserInputMap], + widgetsEmpty: boolean, +) { + const next = { + ...userInput, + [id]: nextUserInput, // ← Update only this widget's input + }; + setUserInput(next); + props.handleUserInput?.(next, widgetsEmpty); +} +``` + +--- + +## 3. onChange Pattern Status + +### Current Status: DEPRECATION IN PROGRESS + +**The `onChange` pattern is being removed but still exists in legacy code.** + +#### Where it's still used: + +1. **Radio Widget** (partially during migration) + - File: `/packages/perseus/src/widgets/radio/radio.ff.tsx` + - Type definition: `/packages/perseus/src/types.ts` line 117 + - Status: TODO marked for removal (LEMS-3542, LEMS-3245) + +2. **Types Still Define It** + - File: `/packages/perseus/src/types.ts` + ```typescript + /** + * TODO(LEMS-3245) remove ChangeHandler + * @deprecated + */ + export type ChangeHandler = ( + arg1: { + hints?: ReadonlyArray; + replace?: boolean; + content?: string; + widgets?: PerseusWidgetsMap; + images?: ImageDict; + choiceStates?: ReadonlyArray; + // ... more fields ... + }, + callback?: () => void, + silent?: boolean, + ) => unknown; + ``` + +#### Where it's NOT used: + +- **NumericInput**: Uses `handleUserInput` directly +- **Most modern widgets**: Use `handleUserInput` callback +- **All new widget code**: Should use the props-driven approach + +#### Migration Path + +The Radio widget wrapper (`radio.ff.tsx`) bridges old and new: + +```typescript +class Radio extends React.Component implements Widget { + _handleChange(arg: {choiceStates?: ReadonlyArray}) { + const newChoiceStates = arg.choiceStates; + // ... converts ChoiceState to UserInput ... + const props = this._mergePropsAndState(); + // Use getUserInputFromSerializedState to get proper UserInput format + const userInput = getUserInputFromSerializedState(mergedProps, ...); + this.props.handleUserInput(userInput); // ← Calls new pattern + } +} +``` + +--- + +## 4. Widget Interface and Lifecycle + +### Widget Interface Definition + +**File**: `/packages/perseus/src/types.ts` lines 60-93 + +```typescript +export interface Widget { + /** + * don't use isWidget; it's just a dummy property to help TypeScript's + * weak typing to recognize non-interactive widgets as Widgets + * @deprecated + */ + isWidget?: true; + + focus?: () => { + id: string; + path: FocusPath; + } | boolean; + + getDOMNodeForPath?: (path: FocusPath) => Element | Text | null; + + /** + * @deprecated - do not use in new code. + */ + getSerializedState?: () => SerializedState; + + blurInputPath?: (path: FocusPath) => void; + focusInputPath?: (path: FocusPath) => void; + getInputPaths?: () => ReadonlyArray; + + getPromptJSON?: () => WidgetPromptJSON; +} +``` + +### WidgetExports Pattern + +**File**: `/packages/perseus/src/types.ts` lines 468-512 + +```typescript +export type WidgetExports< + T extends React.ComponentType & Widget = React.ComponentType, + TUserInput = Empty, +> = Readonly<{ + name: string; + displayName: string; + + widget: T; // The component itself + + version?: Version; + isLintable?: boolean; + tracking?: Tracking; + + // Static widget support + getOneCorrectAnswerFromRubric?: (rubric: WidgetOptions) => string | null; + + // ← Initialization functions + getCorrectUserInput?: (widgetOptions: WidgetOptions) => TUserInput; + getStartUserInput?: ( + widgetOptions: WidgetOptions, + problemNum: number, + ) => TUserInput; + + // ← Deprecation functions + getUserInputFromSerializedState?: ( + serializedState: unknown, + widgetOptions?: WidgetOptions, + ) => TUserInput; +}>; +``` + +--- + +## 5. Package Structure and Dependencies + +### Package Organization + +``` +packages/ +├── perseus/ # Main renderer package (74.0.2) +│ ├── src/ +│ │ ├── renderer.tsx # Core renderer component +│ │ ├── server-item-renderer.tsx # Exercise renderer +│ │ ├── widget-container.tsx # Widget wrapper +│ │ ├── user-input-manager.tsx # State management +│ │ ├── widgets/ # Widget implementations +│ │ │ ├── radio/ +│ │ │ ├── numeric-input/ +│ │ │ ├── interactive-graphs/ +│ │ │ └── ... (40+ widget types) +│ │ ├── types.ts # Core type definitions +│ │ ├── widgets.ts # Widget registry +│ │ └── index.ts # Main exports +│ └── package.json +│ +├── perseus-core/ # Shared types & utilities (23.0.0) +│ ├── src/ +│ │ ├── data-schema.ts # Data type definitions +│ │ ├── scoring-functions/ # Widget-specific scoring +│ │ └── utils/generators # Test data generators +│ └── package.json +│ +├── perseus-score/ # Server-side scoring +│ ├── src/widgets/[type]/score-[type].ts +│ └── widgets/widget-registry.ts +│ +├── perseus-linter/ # Content validation +├── perseus-editor/ # Editor components +├── math-input/ # Math keypad components +└── ... (other packages) +``` + +### Dependencies + +**Perseus Package** depends on: +- `@khanacademy/perseus-core` - Type definitions +- `@khanacademy/perseus-score` - Scoring logic +- `@khanacademy/perseus-linter` - Content validation +- `@khanacademy/math-input` - Math input components +- Wonder Blocks components (UI library) +- React/ReactDOM + +**Perseus-Core** dependencies: +- `@khanacademy/kas` - CAS system +- `@khanacademy/perseus-utils` - Shared utilities +- `@khanacademy/pure-markdown` - Markdown parser +- `tiny-invariant` - Assertions + +### Import Boundaries + +**Rules** (from CLAUDE.md): +- Use package aliases: `@khanacademy/perseus`, `@khanacademy/perseus-core` +- NO file extensions in imports (TypeScript file) +- NO cross-package relative imports +- Import order: builtin > external > internal > relative > types + +**Example correct imports**: +```typescript +import React from "react"; // builtin +import {ApiOptions} from "@khanacademy/perseus"; // internal package +import {WidgetContainer} from "../widget-container"; // relative + +import type {WidgetProps} from "@khanacademy/perseus-core"; +``` + +--- + +## 6. Module Boundaries and Inter-Widget Communication + +### WidgetContainer Boundary + +**File**: `/packages/perseus/src/widget-container.tsx` + +```typescript +type Props = { + shouldHighlight: boolean; + type: string; // ← Widget type + id: string; // ← Widget ID + widgetProps: WidgetProps; // ← All props + linterContext: LinterContextProps; +}; + +class WidgetContainer extends React.Component { + render() { + const WidgetType = Widgets.getWidget(this.props.type); + if (WidgetType == null) { + console.warn(`Widget type '${this.props.type}' not found!`); + return
; + } + + return ( + + + + ); + } +} +``` + +**Responsibilities**: +- Looks up widget component from registry +- Handles error boundaries +- Measures container size on mobile +- Passes props through without modification + +### Inter-Widget Communication + +**File**: `/packages/perseus/src/renderer.tsx` lines 626-683 + +```typescript +findInternalWidgets = (filterCriterion: FilterCriterion) => { + // Returns widgets matching filter + // Filters can be: + // - Widget ID: "interactive-graph 3" + // - Widget type: "interactive-graph" + // - Function: (id, widgetInfo, widget) => boolean +}; + +findWidgets = (filterCriterion: FilterCriterion) => { + // Combines internal and external widgets + return [ + ...this.findInternalWidgets(filterCriterion), + ...this.props.findExternalWidgets(filterCriterion), + ]; +}; +``` + +**Usage**: Passed to all widgets as `findWidgets` prop, enabling: +- Graded group widgets to find their children +- Custom widgets to communicate with other widgets +- Cross-renderer communication through parent's `findExternalWidgets` + +--- + +## 7. Key Files and Their Responsibilities + +| File | Responsibility | +|------|-----------------| +| `server-item-renderer.tsx` | Top-level exercise renderer; manages hints | +| `renderer.tsx` | Core question renderer; widget lifecycle | +| `widget-container.tsx` | Widget wrapper; error boundaries | +| `user-input-manager.tsx` | Central state management for user input | +| `types.ts` | Core type definitions (Widget, WidgetProps, etc.) | +| `widgets.ts` | Widget registry and lookup | +| `widgets/[type]/index.ts` | Widget export entry point | +| `widgets/[type]/[type].ts` | Widget registration (WidgetExports) | +| `widgets/[type]/[component].tsx` | Widget component implementation | + +--- + +## 8. Recent Architecture Changes + +### Recent PRs (from git log): +1. **Upgrade to Jest v30** (#3230) - Testing infrastructure +2. **Fix parsing of answerless Label Image widgets** (#3231) - Bug fix +3. **Remove unused `isItemRenderableByVersion`** (#3229) - Code cleanup +4. **Delete TODOs we're never going to fix** (#3197) - Debt reduction +5. **Simplify applyDefaultsToWidgets** (#3217) - Refactoring + +### Known Deprecations + +- `getSerializedState()` - Use `getStartUserInput()` instead (LEMS-3185) +- `onChange` handler - Use `handleUserInput()` instead (LEMS-3245, LEMS-3542) +- `ChangeHandler` type - Being phased out +- Old Radio widget files - Being replaced (LEMS-2994) + +--- + +## 9. Testing Architecture + +### Test Patterns + +**Location**: Widgets typically have `[widget].test.ts` or `.test.tsx` files + +**Key testing helpers** (from `packages/perseus-core/src/utils/generators`): +- Widget generators for creating test data +- testdata files for example questions + +**Example structure** (from CLAUDE.md): +```typescript +import {render, screen} from "@testing-library/react"; +import {userEvent} from "@testing-library/user-event"; +import {question1} from "../__testdata__/widget.testdata"; +import WidgetComponent from "../widget-component"; + +describe("WidgetComponent", () => { + it("renders correctly", () => { + render(); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + it("handles user interaction", async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + await user.click(screen.getByRole("button")); + expect(onChange).toHaveBeenCalled(); + }); +}); +``` + +--- + +## 10. Key Insights and Findings + +### Ground Truth: Modern Widget Pattern + +1. **No onChange**: Modern widgets use `handleUserInput(newState)` callback +2. **Props-driven**: Widget state is passed via props, not stored internally +3. **Centralized state**: `UserInputManager` holds all widget states in one place +4. **Lazy initialization**: Widgets use `getStartUserInput()` to initialize state + +### Current Transition State + +- **Radio widget**: Partially migrated (still uses `onChange` internally) +- **NumericInput widget**: Fully migrated to modern pattern +- **Legacy code**: Still contains `onChange` and `ChangeHandler` types +- **Feature flags**: Used to conditionally render new Radio widget (radio.ff.tsx) + +### Architecture Strengths + +1. **Clear separation**: Renderer, WidgetContainer, Widgets have distinct roles +2. **Type safety**: Extensive use of TypeScript generics for widget props +3. **Extensibility**: Widget registry system allows plugins +4. **Testability**: Props-driven design makes widgets easy to test +5. **Error handling**: Error boundaries at widget level + +### Areas of Complexity + +1. **State shape duality**: Some widgets still have both `ChoiceState` and `UserInput` +2. **Serialization deprecation**: Old serialized state format still supported for backwards compatibility +3. **Focus management**: FocusPath system is complex but necessary for accessibility +4. **Feature flags**: Multiple codepaths for old vs. new Radio implementation + +--- + +## Conclusion + +Perseus has a well-designed, modern React architecture with clear data flow. The system is transitioning from widget-centric state management (onChange) to centralized state management (UserInputManager + handleUserInput callbacks). This research shows the actual current state of the codebase, which differs from some of the documentation in that modern widgets are fully prop-driven with no onChange pattern at all. + +The key takeaway: **When building or modifying widgets, use `handleUserInput()` callbacks, NOT `onChange` handlers**. The latter is deprecated and being actively removed. + +--- + +**Co-authored by**: Claude (AI Assistant) +**Research Depth**: Thorough - examined 15+ source files and traced data flow through the entire system diff --git a/.claude/prompts/README.md b/.claude/prompts/README.md new file mode 100644 index 0000000000..e7644b8c47 --- /dev/null +++ b/.claude/prompts/README.md @@ -0,0 +1,33 @@ +# Perseus Prompt Library + +This directory contains modular documentation for AI assistants working on the Perseus codebase. Each document focuses on a specific aspect of development. + +## Available Documents + +### Core References +- **[codebase-map.md](./codebase-map.md)** - Package structure, data flow, and module boundaries +- **[widget-development.md](./widget-development.md)** - Complete guide for creating and maintaining widgets +- **[testing-best-practices.md](./testing-best-practices.md)** - Testing patterns, utilities, and guidelines + +### Development Guidelines +- **[component-best-practices.md](./component-best-practices.md)** - React components, Wonder Blocks, accessibility +- **[file-organization.md](./file-organization.md)** - Directory structure, naming conventions, imports +- **[iteration-and-feedback.md](./iteration-and-feedback.md)** - When to ask for help vs. iterate autonomously + +## How to Use + +These documents are referenced from the main `CLAUDE.md` file. When working on specific tasks, the relevant documents will be loaded into context. + +## Maintenance + +When updating these documents: +1. Verify information against actual code (not just documentation) +2. Use concrete examples from the codebase +3. Include file paths and line numbers where helpful +4. Mark deprecated patterns clearly +5. Date any time-sensitive information + +## Recent Updates + +- **2024-02-05**: Initial prompt library created based on current codebase analysis +- **2024-02-05**: Corrected widget patterns after discovering Rubric is still used (but only for scoring) \ No newline at end of file diff --git a/.claude/prompts/codebase-map.md b/.claude/prompts/codebase-map.md new file mode 100644 index 0000000000..14ffc21d23 --- /dev/null +++ b/.claude/prompts/codebase-map.md @@ -0,0 +1,95 @@ +# Perseus Codebase Map + +## Package Structure Overview + +``` +packages/ +├── perseus/ (v74.0.2) # Core rendering engine +│ ├── src/ +│ │ ├── widgets/ # Interactive components +│ │ ├── components/ # Reusable UI components +│ │ ├── renderers/ # Content renderers +│ │ ├── util/ # Utility functions +│ │ └── __docs__/ # Storybook documentation +│ └── __testdata__/ # Test fixtures +│ +├── perseus-editor/ # Content authoring tools +│ ├── src/ +│ │ ├── components/ # Editor UI components +│ │ └── widgets/ # Widget editors +│ +├── perseus-core/ (v23.0.0) # Shared types and utilities +│ ├── src/ +│ │ ├── data-schema.ts # Core type definitions +│ │ ├── utils/ +│ │ │ └── generators/ # Test data generators +│ │ └── types/ # Type exports +│ +├── perseus-score/ # Server-side scoring +│ ├── src/ +│ │ └── widgets/ # Widget-specific scoring +│ +├── math-input/ # Math keypad and input +│ ├── src/ +│ │ ├── components/ # Math UI components +│ │ └── services/ # Math processing +│ +└── perseus-linter/ # Content validation + └── src/ + └── rules/ # Linting rules +``` + +## Key Entry Points + +### Renderers & State Management +- `packages/perseus/src/server-item-renderer.tsx` - Main exercise renderer +- `packages/perseus/src/user-input-manager.ts` - Centralized state management +- `packages/perseus/src/renderer.tsx` - Base renderer component +- `packages/perseus/src/widget-container.tsx` - Widget boundary layer + +### Widget System +- `packages/perseus/src/widgets.ts` - Widget registry +- Individual widgets in `packages/perseus/src/widgets/[name]/` +- Modern widgets use `handleUserInput()` callback pattern +- Legacy widgets being migrated from `onChange` pattern + +### Type System +- `packages/perseus-core/src/data-schema.ts` - Core types +- `packages/perseus-core/src/types/` - Type exports +- Widget-specific types in each widget directory + +## Modern Data Flow (Post-Migration) + +1. **State Initialization**: + - ServerItemRenderer → UserInputManager.getStartUserInput() + +2. **User Interaction**: + - Widget → handleUserInput() → UserInputManager → Re-render + +3. **Props Distribution**: + - UserInputManager.getWidgetProps() → WidgetContainer → Widget + +4. **Scoring**: + - UserInputManager state → Perseus Score → Validation result + +## Important Migration Notes + +- **onChange is DEPRECATED** (LEMS-3245, LEMS-3542) +- New widgets must use `handleUserInput()` pattern +- Radio widget currently bridges old/new patterns during migration +- NumericInput is fully modernized example + +## Module Boundaries + +- **perseus**: Public rendering API (React-based) +- **perseus-editor**: Editor-only functionality +- **perseus-core**: Shared types (no React dependencies) +- **perseus-score**: Server-side scoring (no React) +- **math-input**: Standalone math components + +## Import Rules + +- Use package aliases: `@khanacademy/perseus`, etc. +- NO file extensions in imports +- NO cross-package relative imports +- Import order: builtin > external > internal > relative > types \ No newline at end of file diff --git a/.claude/prompts/component-best-practices.md b/.claude/prompts/component-best-practices.md new file mode 100644 index 0000000000..cf4cfcd69d --- /dev/null +++ b/.claude/prompts/component-best-practices.md @@ -0,0 +1,324 @@ +# Component Development Best Practices + +## Component Structure + +### Prefer Functional Components +```typescript +// ✅ Good - Named function +function ComponentName(props: Props) { + return
{props.content}
; +} +ComponentName.displayName = "ComponentName"; // Explicit for debugging + +// ❌ Avoid - Arrow function requires displayName +const ComponentName = (props: Props) => { + return
{props.content}
; +}; +ComponentName.displayName = "ComponentName"; // Extra step +``` + +### Type Definitions +```typescript +// Place types at the top of the file +type Props = { + content: string; + onAction: () => void; + optional?: boolean; +}; + +// For complex props, use intersection types +type Props = BaseProps & { + specificProp: string; +}; +``` + +## Wonder Blocks Integration + +Perseus uses Khan Academy's Wonder Blocks design system: + +```typescript +import {View} from "@khanacademy/wonder-blocks-core"; +import {Button} from "@khanacademy/wonder-blocks-button"; +import {TextField} from "@khanacademy/wonder-blocks-form"; +import {LabelMedium} from "@khanacademy/wonder-blocks-typography"; + +function MyComponent() { + return ( + + Label text + + + + ); +} +``` + +## State Management Patterns + +### Component State (UI only) +```typescript +// Local state for UI concerns only +function Component() { + const [isExpanded, setIsExpanded] = React.useState(false); + const [hoveredIndex, setHoveredIndex] = React.useState(null); + + // User answer state goes through handleUserInput, not local state! +} +``` + +### Derived State +```typescript +// Calculate values instead of storing them +function Component({items, filter}: Props) { + // ✅ Good - Derived from props + const filteredItems = React.useMemo( + () => items.filter(item => item.matches(filter)), + [items, filter] + ); + + // ❌ Bad - Duplicating prop data in state + const [filteredItems, setFilteredItems] = React.useState([]); + React.useEffect(() => { + setFilteredItems(items.filter(...)); + }, [items, filter]); +} +``` + +## Performance Optimization + +### Memoization +```typescript +// Memoize expensive calculations +const expensiveValue = React.useMemo(() => { + return computeExpensiveValue(data); +}, [data]); + +// Memoize callbacks passed to children +const handleClick = React.useCallback(() => { + doSomething(value); +}, [value]); + +// Memoize component when props comparison is cheaper than re-render +const MemoizedChild = React.memo(ChildComponent); +``` + +### Avoid Inline Objects/Functions +```typescript +// ❌ Bad - Creates new object every render + + +// ✅ Good - Stable reference +const style = {margin: 10}; + + +// Or use Wonder Blocks styling + +``` + +## Accessibility + +### ARIA Labels +```typescript + +``` + +### Focus Management +```typescript +function Component() { + const inputRef = React.useRef(null); + + // Auto-focus on mount when appropriate + React.useEffect(() => { + if (shouldAutoFocus) { + inputRef.current?.focus(); + } + }, [shouldAutoFocus]); + + return ; +} +``` + +### Keyboard Navigation +```typescript +function handleKeyDown(e: React.KeyboardEvent) { + switch (e.key) { + case "Enter": + submitAnswer(); + break; + case "Escape": + clearInput(); + break; + case "ArrowUp": + selectPrevious(); + e.preventDefault(); // Prevent scroll + break; + } +} +``` + +## Error Handling + +### Error Boundaries +```typescript +// Components are wrapped by error boundaries +// Provide meaningful error states +function Component({data}: Props) { + if (!data) { + return No data available; + } + + try { + return ; + } catch (error) { + return Failed to render content; + } +} +``` + +## Mobile Support + +### Touch-Friendly Interfaces +```typescript +// Use Wonder Blocks components (mobile-optimized) +import {Button} from "@khanacademy/wonder-blocks-button"; + +// Ensure touch targets are at least 44x44px + +``` + +### Responsive Layout +```typescript +import {useIsMobile} from "../hooks/use-is-mobile"; + +function Component() { + const isMobile = useIsMobile(); + + return ( + + {/* Content adapts to screen size */} + + ); +} +``` + +## Component Organization + +### File Structure +```typescript +// component-name.tsx + +// 1. Imports +import React from "react"; +import {View} from "@khanacademy/wonder-blocks-core"; + +// 2. Type definitions +type Props = { + // ... +}; + +// 3. Constants +const DEFAULT_VALUE = ""; + +// 4. Main component +function ComponentName(props: Props) { + // ... +} + +// 5. Display name +ComponentName.displayName = "ComponentName"; + +// 6. Sub-components (if needed) +function SubComponent() { + // ... +} + +// 7. Export +export default ComponentName; +``` + +### Props Patterns +```typescript +// Destructure common props +function Component({ + userInput, + handleUserInput, + trackInteraction, + ...restProps +}: Props) { + // Use restProps for pass-through + return ; +} +``` + +## Testing Components + +### Render Testing +```typescript +import {render, screen} from "@testing-library/react"; +import {userEvent} from "@testing-library/user-event"; + +it("handles interaction", async () => { + const user = userEvent.setup(); + const onClick = jest.fn(); + + render(); + await user.click(screen.getByRole("button")); + + expect(onClick).toHaveBeenCalled(); +}); +``` + +### Snapshot Testing (use sparingly) +```typescript +it("renders complex structure", () => { + const {container} = render(); + expect(container).toMatchSnapshot(); +}); +``` + +## Common Patterns + +### Conditional Rendering +```typescript +// Simple conditions +{showHint && } + +// Multiple conditions +{(() => { + if (loading) return ; + if (error) return ; + return ; +})()} +``` + +### Lists and Keys +```typescript +{items.map((item, index) => ( + handleSelect(item.id)} + /> +))} +``` + +## Anti-Patterns to Avoid + +1. **Don't mutate props or state directly** +2. **Don't use array index as key when list can reorder** +3. **Don't put business logic in components** - Keep in utilities +4. **Don't use inline styles** - Use Wonder Blocks or CSS modules +5. **Don't forget React.memo for expensive child components** +6. **Don't use useEffect for derived state** - Use useMemo instead \ No newline at end of file diff --git a/.claude/prompts/file-organization.md b/.claude/prompts/file-organization.md new file mode 100644 index 0000000000..164c6b4706 --- /dev/null +++ b/.claude/prompts/file-organization.md @@ -0,0 +1,271 @@ +# File and Folder Organization + +## Directory Naming Conventions + +### Use Kebab-Case for Directories +``` +✅ Good: +packages/perseus/src/widgets/numeric-input/ +packages/perseus/src/widgets/interactive-graph/ + +❌ Bad: +packages/perseus/src/widgets/NumericInput/ +packages/perseus/src/widgets/interactive_graph/ +``` + +## Widget File Structure + +Each widget should follow this structure: +``` +widgets/widget-name/ +├── index.ts # Public exports only +├── widget-name.tsx # Main component +├── widget-name.test.ts # Component tests +├── widget-name.testdata.ts # Test data using generators +├── types.ts # Widget-specific types (if complex) +├── utils.ts # Widget-specific utilities (if needed) +└── __docs__/ + ├── widget-name.stories.tsx # Storybook stories + ├── a11y.mdx # Accessibility documentation + └── examples/ # Example JSON fixtures (if needed) +``` + +## Component Organization + +### Simple Components (single file) +``` +components/ +├── simple-component.tsx +├── simple-component.test.tsx +└── simple-component.stories.tsx +``` + +### Complex Components (folder structure) +``` +components/complex-component/ +├── index.ts # Public exports +├── complex-component.tsx # Main component +├── complex-component.test.tsx # Tests +├── sub-component.tsx # Private sub-components +├── utils.ts # Component-specific utils +└── types.ts # Component-specific types +``` + +## File Naming Rules + +### TypeScript/JavaScript Files +```typescript +// Components - PascalCase file, named export +widget-name.tsx → export function WidgetName() {} + +// Utilities - kebab-case file, named exports +math-utils.ts → export function calculateSum() {} + +// Types - kebab-case file with .types.ts extension +validation.types.ts → export type ValidationResult = {} + +// Test files - match source with .test.ts +widget-name.tsx → widget-name.test.ts + +// Test data - match source with .testdata.ts +widget-name.tsx → widget-name.testdata.ts +``` + +### Index Files +```typescript +// index.ts should only re-export, no logic +export {default} from "./widget-name"; +export type {WidgetNameProps} from "./types"; + +// Don't put implementation in index files +``` + +## Import Organization + +### Import Order (enforced by ESLint) +```typescript +// 1. Node built-ins +import fs from "fs"; +import path from "path"; + +// 2. External packages +import React from "react"; +import {render} from "@testing-library/react"; + +// 3. Internal packages (Khan Academy) +import {View} from "@khanacademy/wonder-blocks-core"; +import {PerseusScore} from "@khanacademy/perseus-core"; + +// 4. Relative imports +import {WidgetContainer} from "../widget-container"; +import {calculateAnswer} from "./utils"; + +// 5. Type imports (always last) +import type {WidgetProps} from "@khanacademy/perseus-core"; +import type {LocalType} from "./types"; +``` + +## Test File Organization + +### Test Data Files +```typescript +// widget-name.testdata.ts +import {widgetNameGenerator} from "@khanacademy/perseus-core"; + +// Group related test cases +export const basicQuestions = { + simple: widgetNameGenerator.build({...}), + withHint: widgetNameGenerator.build({...}), + multipleChoice: widgetNameGenerator.build({...}), +}; + +export const edgeCases = { + empty: widgetNameGenerator.build({...}), + veryLong: widgetNameGenerator.build({...}), +}; +``` + +### Test Structure +```typescript +// widget-name.test.ts +describe("WidgetName", () => { + describe("rendering", () => { + it("renders with default props", () => {}); + it("renders with custom props", () => {}); + }); + + describe("user interaction", () => { + it("handles input changes", () => {}); + it("validates input", () => {}); + }); + + describe("scoring", () => { + it("scores correct answer", () => {}); + it("scores incorrect answer", () => {}); + }); +}); +``` + +## Package Exports + +### Main Package Exports (packages/perseus/src/index.ts) +```typescript +// Group exports by category +// Renderers +export {default as ServerItemRenderer} from "./server-item-renderer"; +export {default as ArticleRenderer} from "./article-renderer"; + +// Widgets +export {widgets} from "./widgets"; + +// Types +export type {WidgetProps} from "./types"; +``` + +### Package.json Exports +```json +{ + "exports": { + ".": "./dist/index.js", + "./styles": "./dist/styles.css" + } +} +``` + +## Documentation Files + +### Required Documentation +``` +widget-name/ +└── __docs__/ + ├── widget-name.stories.tsx # Interactive examples + ├── a11y.mdx # Accessibility guide + └── README.mdx # Usage documentation (optional) +``` + +### Storybook Organization +```typescript +// __docs__/widget-name.stories.tsx +export default { + title: "Perseus/Widgets/WidgetName", // Hierarchical organization + component: WidgetName, +}; + +// Stories in logical order +export const Default = {}; +export const WithHint = {}; +export const Mobile = {}; +export const ErrorState = {}; +``` + +## Asset Organization + +### Static Assets +``` +packages/perseus/src/ +├── assets/ +│ └── images/ # Shared images +├── widgets/ +│ └── widget-name/ +│ └── assets/ # Widget-specific assets +``` + +### Styles +``` +packages/perseus/src/ +├── styles/ +│ ├── global.less # Global styles +│ └── variables.less # Shared variables +├── widgets/ +│ └── widget-name/ +│ └── widget-name.less # Widget-specific styles +``` + +## Build Artifacts + +### Git Ignored +``` +# Never commit these +node_modules/ +dist/ +build/ +*.tsbuildinfo +coverage/ +.turbo/ +``` + +### Generated Files +```typescript +// Mark generated files clearly +// Generated by: script-name.js +// DO NOT EDIT MANUALLY +``` + +## Migration Patterns + +### Gradual Migration +``` +widgets/old-widget/ # Legacy structure +├── old-widget.jsx # To be migrated +├── old-widget-new.tsx # New implementation +└── __migration__/ # Migration utilities +``` + +### Deprecation +```typescript +// Mark deprecated code clearly +/** + * @deprecated Use NewWidget instead (LEMS-1234) + * @removeBefore 2024-12-01 + */ +export const OldWidget = () => {}; +``` + +## Best Practices + +1. **Keep files focused** - Single responsibility per file +2. **Co-locate related code** - Tests, stories, and component together +3. **Use index.ts for public API** - Hide internal implementation +4. **Consistent naming** - Follow conventions strictly +5. **Clean imports** - No circular dependencies +6. **Document complex structures** - Add README.md when needed \ No newline at end of file diff --git a/.claude/prompts/iteration-and-feedback.md b/.claude/prompts/iteration-and-feedback.md new file mode 100644 index 0000000000..bcf293a0b2 --- /dev/null +++ b/.claude/prompts/iteration-and-feedback.md @@ -0,0 +1,421 @@ +# Iteration and Feedback Guidelines + +## When to Seek Human Feedback + +### Always Ask When: +1. **Architectural decisions** - Choosing between different design patterns +2. **Breaking changes** - Modifications that affect external APIs +3. **Performance trade-offs** - Optimizing for speed vs. memory vs. readability +4. **Business logic ambiguity** - Multiple valid interpretations of requirements +5. **User experience changes** - Altering how users interact with features +6. **Data model changes** - Modifying core types or data structures + +### Example Decision Points: +```typescript +// ASK: Which error handling approach? +// Option A: Show inline error +// Option B: Toast notification +// Option C: Modal dialog + +// ASK: State management strategy? +// Option A: Local component state +// Option B: Centralized in UserInputManager +// Option C: Context provider +``` + +## Autonomous Iteration Patterns + +### Can Iterate Without Asking: +1. **Fixing clear bugs** - Null pointer, type errors, off-by-one errors +2. **Following established patterns** - Using existing widget patterns +3. **Code formatting** - Prettier, ESLint fixes +4. **Adding missing tests** - Improving coverage for existing code +5. **Refactoring for clarity** - Extracting functions, renaming variables +6. **Performance optimizations** - When metrics clearly show improvement + +### Iteration Workflow: +```typescript +// 1. Initial implementation +function calculateScore(answer: string): number { + return answer === "42" ? 1 : 0; +} + +// 2. Identify issue (too simplistic) +// Can iterate autonomously because pattern is clear + +// 3. Improved implementation +function calculateScore( + userInput: UserInput, + rubric: Rubric, +): PerseusScore { + const isCorrect = userInput.value === rubric.correctAnswer; + return { + type: "points", + earned: isCorrect ? 1 : 0, + total: 1, + message: null, + }; +} +``` + +## Testing Feedback Loop + +### Run Tests → Analyze → Fix → Repeat +```bash +# Autonomous iteration process +pnpm test widget-name + +# If test fails with clear error: +# ✅ Fix autonomously + +# If test fails with ambiguous requirement: +# ❌ Ask for clarification +``` + +### Test Failure Decision Tree: +``` +Test Failure +├── Type Error → Fix autonomously +├── Assertion Failure +│ ├── Clear expectation → Fix autonomously +│ └── Business logic unclear → Ask human +├── Timeout +│ ├── Missing async/await → Fix autonomously +│ └── Performance issue → Discuss approach +└── Snapshot Mismatch + ├── Intentional change → Update snapshot + └── Unexpected change → Investigate cause +``` + +## Code Review Self-Checklist + +### Before Presenting Code: +- [ ] Tests pass (`pnpm test`) +- [ ] Types check (`pnpm tsc`) +- [ ] Linted (`pnpm lint`) +- [ ] Formatted (`pnpm prettier . --write`) +- [ ] Accessibility documented +- [ ] Mobile tested (if applicable) +- [ ] Performance impact considered + +### Autonomous Fixes: +```typescript +// These issues should be fixed without asking: + +// ❌ Unused import (ESLint) +import {unused} from "./utils"; + +// ❌ Console statement (ESLint) +console.log("debug"); + +// ❌ Missing type (TypeScript) +function process(data) { // Should be: data: DataType + +// ❌ Formatting (Prettier) +const x={a:1,b:2}; // Should be: const x = {a: 1, b: 2}; +``` + +## Incremental Development + +### Build → Test → Refine Cycle +```typescript +// Iteration 1: Basic functionality +function Widget() { + return ; +} + +// Iteration 2: Add user input handling (autonomous) +function Widget({userInput, handleUserInput}) { + return ( + handleUserInput({value: e.target.value})} + /> + ); +} + +// Iteration 3: Add validation (check pattern first) +// If validation rules are clear → implement +// If business rules unclear → ask +``` + +## Performance Optimization Decisions + +### Autonomous Optimizations: +```typescript +// Clear performance win - optimize without asking +// Before: O(n²) +const hasDuplicate = items.some( + (item, i) => items.slice(i + 1).includes(item) +); + +// After: O(n) +const hasDuplicate = items.length !== new Set(items).size; +``` + +### Needs Discussion: +```typescript +// Performance vs. Readability trade-off +// Option A: Readable but slower +const result = items + .filter(item => item.active) + .map(item => item.value) + .reduce((sum, val) => sum + val, 0); + +// Option B: Faster but less readable +let result = 0; +for (let i = 0; i < items.length; i++) { + if (items[i].active) result += items[i].value; +} +// ASK: Which approach aligns with team preferences? +``` + +## Error Handling Patterns + +### Autonomous Error Handling: +```typescript +// Null checks - add without asking +function process(data?: DataType) { + if (!data) { + return defaultValue; // Clear fallback + } + // ... process data +} + +// Try-catch for parsing - add without asking +try { + const parsed = JSON.parse(userInput); + return parsed; +} catch { + return null; // Safe fallback +} +``` + +### Needs Human Input: +```typescript +// User-facing error messages +function validateInput(value: string) { + if (!value) { + // ASK: What message helps users best? + // Option A: "This field is required" + // Option B: "Please enter your answer" + // Option C: "Answer cannot be empty" + } +} +``` + +## Documentation Decisions + +### Auto-document: +- Public APIs (JSDoc for complex functions) +- Accessibility requirements +- Component props +- Test scenarios + +### Ask Before Documenting: +- Architecture decisions (ADRs) +- Migration guides +- Public-facing documentation +- API breaking changes + +## Useful Feedback Prompts + +When uncertain, use these prompts: + +1. **"I see two approaches here..."** - Present options with trade-offs +2. **"The tests suggest X but the code does Y..."** - Highlight inconsistencies +3. **"This would be cleaner if..."** - Propose refactoring +4. **"I'm assuming X because..."** - Make assumptions explicit +5. **"Would you prefer..."** - Get style/pattern preferences + +## Continuous Improvement + +### Track Patterns: +```typescript +// When you make the same fix repeatedly, suggest: +"I notice we're frequently fixing X. Should we add a linting rule?" +"This pattern appears often. Should we create a utility function?" +"Multiple widgets need this. Should we move it to shared code?" +``` + +### Learn from Feedback: +- Note preference corrections +- Update approach based on feedback +- Suggest codifying patterns in documentation + +## Debugging Practices + +### Console Debugging +```typescript +// Temporary debugging (remove before commit) +console.log("Widget state:", state); +console.log("Props received:", props); +console.log("Calculated value:", result); + +// Better: Use descriptive messages +console.log("[WidgetName] Handling user input:", userInput); +console.log("[WidgetName] Validation result:", isValid); +``` + +### Remember to Clean Up +- Remove ALL console statements before commit +- ESLint will catch these in pre-commit +- Use debugger statements sparingly +- Clean up TODO comments + +### Error Boundaries +Perseus widgets are wrapped in error boundaries. When debugging: + +1. **Check browser console** for widget-specific errors +2. **Look for error boundary messages** in the UI +3. **Implement graceful fallbacks**: +```typescript +function WidgetComponent({data}: Props) { + if (!data || !data.required) { + // Graceful fallback + return Unable to load widget content; + } + + try { + return ; + } catch (error) { + // Log for debugging but show user-friendly message + console.error("[WidgetName] Render error:", error); + return This content is temporarily unavailable; + } +} +``` + +### Storybook Development & Debugging +Use Storybook for isolated development: + +1. **Test different prop combinations** - Create stories for edge cases +2. **Verify accessibility** - Use the a11y addon to catch issues +3. **Check mobile layouts** - Use device frame addon +4. **Debug interactions** - Use actions addon to log callbacks +5. **Visual debugging** - Compare component states side-by-side + +```typescript +// Useful Storybook patterns for debugging +export const DebugStory = { + args: { + // Test with extreme values + value: "Very long text that might break layout...", + options: Array(100).fill("Option"), + }, + play: async ({canvasElement}) => { + // Automated interaction testing + const canvas = within(canvasElement); + const input = canvas.getByRole("textbox"); + await userEvent.type(input, "test"); + }, +}; +``` + +## Deployment & Pre-Submit Checklist + +### Before Submitting PR + +#### Required Checks +```bash +# 1. Run full test suite +pnpm test + +# 2. Type checking +pnpm tsc +# or +pnpm build:types + +# 3. Linting (with auto-fix) +pnpm lint --fix + +# 4. Format code +pnpm prettier . --write + +# 5. Build packages to ensure no build errors +pnpm build + +# 6. Test in Storybook +pnpm storybook +``` + +#### Manual Verification +- [ ] Accessibility documented (new widgets need a11y.mdx) +- [ ] Mobile tested (touch interactions work) +- [ ] Performance impact assessed (no unnecessary re-renders) +- [ ] Error cases handled gracefully +- [ ] Console clear of debug statements + +### Common Pre-commit Failures & Fixes + +#### ESLint Failures +```typescript +// ❌ Unused imports +import {unused} from "./utils"; // Remove + +// ❌ Console statements +console.log("debug"); // Remove or use proper logging + +// ❌ Missing dependencies in hooks +useEffect(() => { + doSomething(value); +}, []); // Add 'value' to dependency array + +// ❌ Prefer const +let unchangedVar = 5; // Change to const +``` + +#### Prettier Failures +```typescript +// Auto-fix with: pnpm prettier . --write + +// Common issues: +// - Inconsistent quotes (use double quotes) +// - Missing semicolons +// - Incorrect indentation (4 spaces) +// - Line length > 80 characters +``` + +#### TypeScript Failures +```typescript +// ❌ Implicit any +function process(data) {} // Add type: data: DataType + +// ❌ Missing return type +function calculate(x: number) { // Add : number + return x * 2; +} + +// ❌ Type mismatch +const value: string = 42; // Fix type or value +``` + +#### Test Failures +```bash +# Run specific test to debug +pnpm test path/to/test.test.ts + +# Update snapshots if changes are intentional +pnpm test -u + +# Run with coverage +pnpm test --coverage +``` + +### Post-Submit Monitoring + +After PR is merged: +1. Monitor for any reverted changes +2. Check for related bug reports +3. Watch performance metrics +4. Respond to code review feedback + +### Rollback Preparation + +Be prepared to revert if issues arise: +```bash +# Create revert PR if needed +git revert +git push origin revert-branch +# Create PR with explanation of issue +``` \ No newline at end of file diff --git a/.claude/prompts/math-and-content.md b/.claude/prompts/math-and-content.md new file mode 100644 index 0000000000..b3185d8529 --- /dev/null +++ b/.claude/prompts/math-and-content.md @@ -0,0 +1,270 @@ +# Math and Content Rendering + +## Math Rendering (TeX) + +### Basic Syntax +- **Inline math**: Use `$...$` for math within text + - Example: `The answer is $x = 42$` +- **Display math**: Use `$$...$$` for centered, standalone equations + - Example: `$$\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2}$$` + +### Complex Expressions + +#### Fractions +```latex +// ✅ Good - Use \dfrac for display-style fractions +$$\dfrac{a+b}{c+d}$$ + +// ❌ Avoid - \frac can be too small in complex expressions +$$\frac{a+b}{c+d}$$ + +// For inline, \frac is acceptable +The fraction $\frac{1}{2}$ is equivalent to 0.5 +``` + +#### Common Patterns +```latex +// Aligned equations +\begin{align} +x + y &= 10 \\ +2x - y &= 5 +\end{align} + +// Matrices +\begin{bmatrix} +a & b \\ +c & d +\end{bmatrix} + +// Cases +f(x) = \begin{cases} +x^2 & \text{if } x > 0 \\ +0 & \text{otherwise} +\end{cases} +``` + +### Testing Math Rendering + +Test math in different contexts: +1. **Articles** - Both inline and display math +2. **Exercise questions** - Main problem statement +3. **Hints** - Step-by-step solutions +4. **Answer choices** - In radio/multiple choice widgets +5. **Feedback messages** - Correct/incorrect explanations + +### MathJax Configuration + +Perseus uses MathJax for rendering. Key points: +- Automatic equation numbering disabled +- Custom macros available (check `math-input` package) +- Accessibility features enabled (screen reader support) + +### Common Math Issues + +#### Issue: Math not rendering +```typescript +// ❌ Problem: Unescaped backslashes +const tex = "The answer is $\frac{1}{2}$"; + +// ✅ Solution: Escape or use raw strings +const tex = "The answer is $\\frac{1}{2}$"; +const tex = String.raw`The answer is $\frac{1}{2}$`; +``` + +#### Issue: Spacing problems +```latex +// Add manual spacing when needed +$x\,\text{cm}$ // Thin space +$x\ \text{units}$ // Normal space +$x\quad\text{m}$ // Quad space +``` + +## Content Structure + +### Perseus Content Format +```json +{ + "question": { + "content": "What is $2 + 2$?", + "widgets": { + "numeric-input 1": { + "type": "numeric-input", + "options": { + "answers": [{ + "value": 4, + "status": "correct" + }] + } + } + } + }, + "hints": [ + {"content": "Think about counting on your fingers."}, + {"content": "The answer is $4$."} + ] +} +``` + +### Widget References + +Widgets are referenced in content using special syntax: +```markdown +Enter your answer: [[☃ numeric-input 1]] + +Choose the correct option: [[☃ radio 1]] +``` + +The snowman character (☃) is used as a unique delimiter. + +### Markdown Extensions + +Perseus extends standard Markdown: + +#### Columns +```markdown +::: columns +::: column +Left column content +::: +::: column +Right column content +::: +::: +``` + +#### Callout Boxes +```markdown +::: callout +**Important:** This is a callout box. +::: +``` + +#### Images with Alignment +```markdown +![Description](url.png){: .align-right} +``` + +## Content Validation + +### Linting Rules + +The `perseus-linter` package enforces: +- Valid widget references +- Proper math syntax +- Accessible image alt text +- No broken links +- Consistent formatting + +### Running the Linter +```bash +pnpm --filter perseus-linter lint-content content.json +``` + +## Accessibility in Content + +### Math Accessibility +- All math automatically gets ARIA labels +- MathJax provides screen reader support +- Test with screen readers for complex expressions + +### Image Guidelines +```markdown +// ✅ Good - Descriptive alt text +![Graph showing linear growth from 0 to 100 over 10 years](graph.png) + +// ❌ Bad - Generic or missing alt text +![Graph](graph.png) +![](graph.png) +``` + +### Widget Labels +```typescript +// Ensure widgets have proper labels + +``` + +## Performance Considerations + +### Math Rendering Performance +- Initial render can be slow for many expressions +- Use `React.memo` for components with static math +- Consider pagination for content with 50+ expressions + +### Content Loading +- Large exercises should lazy-load hints +- Images should have width/height to prevent reflow +- Preload critical assets + +## Testing Content Rendering + +### Unit Tests +```typescript +it("renders math expressions correctly", () => { + const {container} = render( + + ); + + // MathJax adds specific classes + expect(container.querySelector(".katex")).toBeInTheDocument(); +}); +``` + +### Visual Regression Tests +- Use Storybook for visual testing +- Snapshot complex math layouts +- Test responsive behavior + +### Manual Testing Checklist +- [ ] Math renders correctly +- [ ] Widgets display properly +- [ ] Images load and align correctly +- [ ] Content is responsive on mobile +- [ ] Screen reader announces math properly +- [ ] Print view works correctly + +## Common Content Patterns + +### Exercise with Multiple Parts +```json +{ + "question": { + "content": "Solve the system of equations:\n\n$$\\begin{align}x + y &= 10\\\\2x - y &= 5\\end{align}$$\n\nWhat is $x$? [[☃ numeric-input 1]]\n\nWhat is $y$? [[☃ numeric-input 2]]" + } +} +``` + +### Article with Interactive Elements +```markdown +# Understanding Quadratics + +The standard form of a quadratic equation is $ax^2 + bx + c = 0$. + +Try adjusting the parameters below: + +[[☃ interactive-graph 1]] + +Notice how changing $a$ affects the parabola's shape. +``` + +## Debugging Tips + +### Math Not Rendering +1. Check browser console for MathJax errors +2. Verify TeX syntax is valid +3. Ensure proper escaping in strings +4. Check for conflicting CSS + +### Widget Not Appearing +1. Verify widget ID matches reference +2. Check widget is registered +3. Ensure widget data is valid +4. Look for error boundaries in console + +### Content Layout Issues +1. Inspect element for CSS conflicts +2. Check responsive breakpoints +3. Verify image dimensions +4. Test with different viewport sizes \ No newline at end of file diff --git a/.claude/prompts/testing-best-practices.md b/.claude/prompts/testing-best-practices.md new file mode 100644 index 0000000000..8ae66a4ddf --- /dev/null +++ b/.claude/prompts/testing-best-practices.md @@ -0,0 +1,181 @@ +# Perseus Testing Best Practices + +## Test Structure + +### Follow the AAA Pattern +```typescript +describe("ComponentName", () => { + it("does something specific", () => { + // Arrange + const props = {...}; + + // Act + const result = doSomething(props); + + // Assert + expect(result).toBe(expected); + }); + + it("handles user interaction", async () => { + // Arrange, Act (when single action) + const {container} = render(); + + // Assert + expect(container).toMatchSnapshot(); + }); +}); +``` + +## Use Test Data Generators + +Located in `packages/perseus-core/src/utils/generators/`: + +```typescript +import {radioGenerator} from "@khanacademy/perseus-core"; + +// Generate complete widget props +const radioProps = radioGenerator.build({ + choices: ["Option A", "Option B"], + hasNoneOfTheAbove: false, +}); + +// Generate multiple variants +const testCases = radioGenerator.buildMany(3); +``` + +## Widget Testing Patterns + +### Modern Widget Pattern (with handleUserInput) +```typescript +import {renderWidget} from "../__tests__/test-utils"; +import {userEvent} from "@testing-library/user-event"; + +it("handles user input correctly", async () => { + // Arrange + const handleUserInput = jest.fn(); + const user = userEvent.setup(); + + renderWidget({ + widgetType: "numeric-input", + widgetProps: numericInputGenerator.build(), + handleUserInput, + }); + + // Act + await user.type(screen.getByRole("textbox"), "42"); + + // Assert + expect(handleUserInput).toHaveBeenCalledWith("42"); +}); +``` + +### Legacy Widget Pattern (with onChange - avoid for new tests) +```typescript +// Only for widgets not yet migrated +const onChange = jest.fn(); +render(); +``` + +## Testing Priorities + +1. **User interactions** - How users actually use the widget +2. **Accessibility** - Keyboard navigation, screen readers +3. **Edge cases** - Empty states, invalid input, boundaries +4. **Scoring logic** - Correct/incorrect answers +5. **Mobile behavior** - Touch interactions + +## Scoring Tests + +Located in `packages/perseus-score/src/widgets/[widget]/`: + +```typescript +import {score} from "../score-radio"; + +it("scores correct answer", () => { + const result = score(userInput, scoringData); + expect(result).toHaveBeenAnsweredCorrectly(); +}); + +it("validates input", () => { + const result = validate(userInput); + expect(result).toHaveInvalidInput("Choose an option"); +}); +``` + +## Snapshot Testing + +Use sparingly, only for: +- Complex rendered output structure +- Markdown/TeX rendering +- Error messages + +```typescript +it("renders complex math correctly", () => { + const {container} = render(); + expect(container).toMatchSnapshot(); +}); +``` + +## Test Utilities + +### Custom Matchers +```typescript +// In test files +expect(result).toHaveBeenAnsweredCorrectly(); +expect(result).toHaveInvalidInput("message"); +``` + +### Testing Library Preferences +- Use `screen` queries over container queries +- Prefer `getByRole` over `getByTestId` +- Use `userEvent` over `fireEvent` +- Wait for async operations with `waitFor` + +## Common Testing Patterns + +### Testing Focus Management +```typescript +it("manages focus correctly", async () => { + const user = userEvent.setup(); + render(); + + await user.tab(); + expect(screen.getByRole("button")).toHaveFocus(); +}); +``` + +### Testing Accessibility +```typescript +it("has accessible labels", () => { + render(); + expect(screen.getByLabelText("Answer")).toBeInTheDocument(); +}); +``` + +### Testing Mobile Interactions +```typescript +it("handles touch events", async () => { + const user = userEvent.setup(); + render(); + + await user.pointer({keys: "[TouchA>]", target: element}); + // Assert touch-specific behavior +}); +``` + +## What NOT to Test + +- Implementation details (internal state, private methods) +- Third-party library behavior +- Static types (TypeScript handles this) +- Styles and CSS (unless functionally important) + +## File Organization + +``` +widget-name/ +├── widget-name.tsx +├── widget-name.test.ts # Component tests +├── widget-name.testdata.ts # Test data using generators +└── score-widget-name.test.ts # Scoring tests (if applicable) +``` \ No newline at end of file diff --git a/.claude/prompts/widget-development.md b/.claude/prompts/widget-development.md new file mode 100644 index 0000000000..4557afdadf --- /dev/null +++ b/.claude/prompts/widget-development.md @@ -0,0 +1,286 @@ +# Widget Development Guide + +## Three-Layer Type Architecture + +Perseus widgets use a three-layer type system: + +1. **WidgetOptions** - Widget configuration/display settings (in data-schema.ts) +2. **ValidationData** - Client-side validation data, no sensitive info (in validation.types.ts) +3. **Rubric** - Full scoring data: `ValidationData & WidgetOptions` (in validation.types.ts) + +## Creating a New Widget + +### 1. Directory Structure +``` +packages/perseus/src/widgets/[widget-name]/ +├── index.ts # Exports +├── [widget-name].tsx # Main component +├── [widget-name].test.ts # Tests +├── [widget-name].testdata.ts # Test data using generators +└── __docs__/ + ├── [widget-name].stories.tsx # Storybook stories + └── a11y.mdx # Accessibility docs +``` + +### 2. Modern Widget Implementation + +```typescript +// [widget-name].tsx +import React from "react"; +import type {PerseusWidgetNameUserInput} from "@khanacademy/perseus-core"; + +// Props are just the widget options spread directly, plus system props +type Props = { + // Widget-specific options (from WidgetOptions) + correctAnswer?: string; + placeholder?: string; + // System props + userInput: PerseusWidgetNameUserInput; + handleUserInput: (input: PerseusWidgetNameUserInput) => void; + trackInteraction: () => void; + onFocus: () => void; + onBlur: () => void; +}; + +function WidgetName(props: Props) { + const {userInput, handleUserInput} = props; + + const handleChange = (newValue: string) => { + handleUserInput({value: newValue}); + props.trackInteraction(); + }; + + return ( +
+ handleChange(e.target.value)} + onFocus={props.onFocus} + onBlur={props.onBlur} + placeholder={props.placeholder} + /> +
+ ); +} + +// IMPORTANT: Set displayName for debugging +WidgetName.displayName = "WidgetName"; + +// Export with WidgetExports interface +export default { + name: "widget-name" as const, + displayName: "Widget Display Name", + widget: WidgetName, + isLintable: true, + + // Optional scoring helpers (Rubric only used here, not in props!) + getOneCorrectAnswerFromRubric: (rubric: PerseusWidgetNameRubric) => { + return rubric.correctAnswer || null; + }, +} as WidgetExports; +``` + +### 3. Define Types in perseus-core + +```typescript +// packages/perseus-core/src/data-schema.ts + +// Widget configuration (what content creators set) +export type PerseusWidgetNameOptions = { + correctAnswer?: string; + placeholder?: string; + static?: boolean; // Common option for most widgets +}; + +// User's input +export type PerseusWidgetNameUserInput = { + value: string; +}; + +// packages/perseus-core/src/validation.types.ts + +// Client-side validation data (subset of options, no answers) +export type PerseusWidgetNameValidationData = { + placeholder?: string; +}; + +// Full scoring data (intersection type) +export type PerseusWidgetNameRubric = + PerseusWidgetNameValidationData & + PerseusWidgetNameOptions; +``` + +### 4. Register the Widget + +```typescript +// packages/perseus/src/widgets.ts +import widgetNameWidget from "./widgets/widget-name"; + +export const widgets = { + // ... existing widgets + "widget-name": widgetNameWidget, +}; +``` + +### 5. Add Scoring Function (if scorable) + +```typescript +// packages/perseus-score/src/widgets/widget-name/score-widget-name.ts +import type { + PerseusWidgetNameUserInput, + PerseusWidgetNameRubric, +} from "@khanacademy/perseus-core"; + +export function scoreWidgetName( + userInput: PerseusWidgetNameUserInput, + rubric: PerseusWidgetNameRubric, +): PerseusScore { + if (userInput.value === rubric.correctAnswer) { + return { + type: "points", + earned: 1, + total: 1, + message: null, + }; + } + return { + type: "points", + earned: 0, + total: 1, + message: null, + }; +} + +// Register in widget-registry.ts +import {scoreWidgetName} from "./widget-name/score-widget-name"; + +widgets["widget-name"] = { + score: scoreWidgetName, +}; +``` + +## Important: Props Pattern + +**What widgets receive as props:** +- Widget options spread directly (NOT wrapped in `options` or `rubric`) +- `userInput` - Current user state +- `handleUserInput` - State update callback +- System props (`trackInteraction`, `onFocus`, `onBlur`, etc.) + +**What widgets DON'T receive:** +- `rubric` prop (only used in scoring functions) +- `scoringData` or `validationData` props +- `onChange` prop (deprecated) + +## Widget State Management + +### Modern Pattern (Required for new widgets) +```typescript +// Receive state via userInput +const currentValue = props.userInput?.value || ""; + +// Update state via handleUserInput +const handleChange = (newValue: string) => { + props.handleUserInput({value: newValue}); + props.trackInteraction(); // Track user interaction +}; +``` + +### NO Local State for Answers +- User answers must be stored in UserInputManager +- Local React state only for UI concerns (hover, focus, etc.) + +## Testing Your Widget + +### Use Test Generators +```typescript +// [widget-name].testdata.ts +import {widgetNameGenerator} from "@khanacademy/perseus-core"; + +export const question1 = widgetNameGenerator.build({ + correctAnswer: "42", + placeholder: "Enter your answer", +}); +``` + +### Test Pattern +```typescript +import {renderQuestion} from "../__tests__/renderQuestion"; +import {question1} from "./widget-name.testdata"; + +describe("WidgetName", () => { + it("accepts user input", async () => { + // Arrange + const {renderer} = renderQuestion(question1); + + // Act + await renderer.setInputValue({ + widgetId: "widget-name 1", + value: "42", + }); + + // Assert + const score = renderer.score(); + expect(score).toHaveBeenAnsweredCorrectly(); + }); +}); +``` + +## Common Widget Patterns + +### Multiple Inputs +```typescript +type UserInput = { + numerator: string; + denominator: string; +}; + +const handleNumeratorChange = (value: string) => { + handleUserInput({ + ...userInput, + numerator: value, + }); +}; +``` + +### Validation (Optional) +```typescript +// packages/perseus-score/src/widgets/widget-name/validate-widget-name.ts +export function validateWidgetName( + userInput: PerseusWidgetNameUserInput, +): ValidationResult { + if (!userInput.value) { + return { + type: "invalid", + message: "Please enter an answer", + }; + } + return {type: "valid"}; +} +``` + +### Focus Management +```typescript +const inputRef = React.useRef(null); + +React.useImperativeHandle(props.widgetRef, () => ({ + focus: () => inputRef.current?.focus(), + blur: () => inputRef.current?.blur(), +})); +``` + +## Migration Notes + +- Radio widget is transitioning (TODO(LEMS-2994)) +- Numeric-input is fully modernized (good reference) +- Expression uses class component pattern (older style) +- New widgets should follow functional component pattern + +## Common Pitfalls + +1. **Don't expect rubric in props** - It's only for scoring +2. **Don't use onChange** - Use handleUserInput +3. **Don't forget displayName** - Required for debugging +4. **Don't store answers in local state** - Use UserInputManager +5. **Don't skip trackInteraction** - Needed for analytics +6. **Don't use cross-package relative imports** - Use @khanacademy/* aliases \ No newline at end of file diff --git a/.claude/widget-patterns-research.md b/.claude/widget-patterns-research.md new file mode 100644 index 0000000000..755029ce76 --- /dev/null +++ b/.claude/widget-patterns-research.md @@ -0,0 +1,551 @@ +# Perseus Widget Development Patterns Research + +**Research Date:** February 5, 2026 +**Conducted by:** Claude Code +**Co-Author:** Tamara Bozich + +## Executive Summary + +This document details the current widget development patterns in Perseus based on examination of actual codebase implementations. The key finding is that **Rubric is still the primary pattern**, not replaced by `scoringData` or `validationData` props. Widget types are split between widget configuration (in `PerseusWidgetOptions` from data-schema) and validation/scoring data (in validation.types.ts). + +--- + +## 1. Widget Type Definitions Architecture + +### 1.1 The Type System (Three-Layer Pattern) + +Perseus uses a three-layer type system for widget data, defined in `packages/perseus-core/src/validation.types.ts`: + +1. **WidgetOptions** (in `data-schema.ts`): The configuration/definition of the widget + - What a content creator sees in the editor + - Immutable across a session + - Example: `PerseusRadioWidgetOptions`, `PerseusNumericInputWidgetOptions` + +2. **ValidationData** (in `validation.types.ts`): The data needed for client-side validation + - Does NOT contain sensitive scoring information + - Can be checked before submission + - Used for accessibility, input validation + - Often overlaps with WidgetOptions + +3. **Rubric** (in `validation.types.ts`): The data needed for scoring + - Contains the "correct" answers and scoring logic parameters + - Always intersected with `ValidationData` via `&` operator + - Passed to scoring functions + - Example: `PerseusNumericInputRubric = { answers, coefficient } & PerseusNumericInputValidationData` + +### 1.2 Key Finding: Rubric is NOT Being Replaced + +**Status:** Rubric pattern is active and unchanged +- `PerseusNumericInputRubric` (line 223 in validation.types.ts): `{ answers, coefficient }` +- `PerseusRadioRubric` (line 261 in validation.types.ts): `{ choices, countChoices? }` +- Both are used as-is in widget implementations and scoring functions + +**No evidence of transition to:** +- `scoringData` prop +- `validationData` prop +- Alternative naming schemes + +--- + +## 2. Widget Implementation Patterns + +### 2.1 Current Widget Export Pattern + +All widgets follow the `WidgetExports` pattern (defined in `packages/perseus/src/types.ts` lines 468-512): + +```typescript +export type WidgetExports< + T extends React.ComponentType & Widget = React.ComponentType, + TUserInput = Empty, +> = Readonly<{ + name: string; // Widget identifier + displayName: string; // Display name in editor + widget: T; // The component + version?: Version; // Widget schema version + isLintable?: boolean; // Can be linted + tracking?: Tracking; // Tracking behavior + + // Scoring/Validation functions (OPTIONAL) + getOneCorrectAnswerFromRubric?: (rubric: WidgetOptions) => string | null; + getCorrectUserInput?: (widgetOptions: WidgetOptions) => TUserInput; + getStartUserInput?: (widgetOptions: WidgetOptions, problemNum: number) => TUserInput; + getUserInputFromSerializedState?: (serializedState: unknown, widgetOptions?: WidgetOptions) => TUserInput; +}>; +``` + +### 2.2 Numeric Input Widget (Modern Pattern) + +**File:** `packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx` + +```typescript +// Props type - combines widget options with universal props +type ExternalProps = WidgetProps< + PerseusNumericInputWidgetOptions, + PerseusNumericInputUserInput +>; + +// Widget component definition +export class NumericInput extends React.Component implements Widget { + // Widget interface methods + focus: () => boolean + focusInputPath: () => void + blurInputPath: () => void + getInputPaths: () => ReadonlyArray> + getPromptJSON(): NumericInputPromptJSON + // ... +} + +// Export follows WidgetExports pattern +export default { + name: "numeric-input", + displayName: "Numeric input", + widget: NumericInput, + isLintable: true, + getCorrectUserInput, // Function + getOneCorrectAnswerFromRubric(rubric: PerseusNumericInputRubric) { /* ... */ }, + getStartUserInput, // Function + getUserInputFromSerializedState, // Function +} satisfies WidgetExports; +``` + +**Actual Prop Names Used:** +- `answers: PerseusNumericInputAnswer[]` - from widget options +- `coefficient: boolean` - from widget options +- `userInput: { currentValue: string }` - user input (typed separately) +- `handleUserInput` - callback to update user input +- `trackInteraction` - analytics callback +- No `rubric`, `scoringData`, or `validationData` props passed directly to component + +### 2.3 Radio Widget (In Transition) + +**File:** `packages/perseus/src/widgets/radio/radio.ff.tsx` + `radio.ts` + +```typescript +type Props = WidgetProps; + +export default { + name: "radio", + displayName: "Radio / Multiple choice", + widget: Radio, + getStartUserInput, + version: radioLogic.version, + isLintable: true, + getUserInputFromSerializedState: (serializedState: unknown) => { + return getUserInputFromSerializedState(serializedState); + }, +} satisfies WidgetExports; +``` + +**Status:** +- Still using class component wrapper (line 52: `class Radio extends React.Component implements Widget`) +- Transitioning toward functional component (comment on line 50: "TODO(LEMS-2994): Clean up this file") +- Using old API with `ChoiceState` that combines UI state and user input + +### 2.4 Expression Widget (Functional) + +**File:** `packages/perseus/src/widgets/expression/expression.tsx` + +```typescript +type ExternalProps = WidgetProps< + PerseusExpressionWidgetOptions, + PerseusExpressionUserInput +>; + +type Props = ExternalProps & { + buttonSets: NonNullable; + functions: NonNullable; + times: NonNullable; +}; + +export class Expression extends React.Component implements Widget { + // Widget methods + focus: () => boolean + focusInputPath: () => void + // ... +} + +export default { + name: "expression", + displayName: "Expression / Equation", + widget: Expression, + version: expressionLogic.version, + isLintable: true, + getOneCorrectAnswerFromRubric, + getStartUserInput, + getCorrectUserInput, + getUserInputFromSerializedState, +} satisfies WidgetExports; +``` + +--- + +## 3. Props Flow Architecture + +### 3.1 How Props Are Passed to Widgets + +From `packages/perseus/src/renderer.tsx` (lines 511-575): + +```typescript +getWidgetProps(widgetId: string): WidgetProps { + const widgetProps = this.props.widgets[widgetId].options; // WidgetOptions + const widgetInfo = this.state.widgetInfo[widgetId]; // Metadata + + return { + ...widgetProps, // Spread widget options directly + userInput: this.props.userInput?.[widgetId], + widgetId: widgetId, + widgetIndex: this._getWidgetIndexById(widgetId), + alignment: widgetInfo && widgetInfo.alignment, + static: widgetInfo?.static, + apiOptions: this.getApiOptions(), // API options + onFocus: _.partial(this._onWidgetFocus, widgetId), + onBlur: _.partial(this._onWidgetBlur, widgetId), + handleUserInput: (newUserInput) => { /* ... */ }, + trackInteraction: interactionTracker.track, + }; +} +``` + +**Key Pattern:** +- Widget options are spread directly: `...widgetProps` +- UserInput is passed separately: `userInput` prop +- No `rubric` prop passed to widget component +- Rubric is only used during scoring (server-side or via scoring functions) + +### 3.2 UserInput vs WidgetOptions + +**UserInput Examples:** +```typescript +// Numeric Input +PerseusNumericInputUserInput = { currentValue: string } + +// Radio +PerseusRadioUserInput = { selectedChoiceIds: string[] } + +// Expression +PerseusExpressionUserInput = string + +// Label Image +PerseusLabelImageUserInput = { + markers: Array<{ selected?: string[], label: string }> +} +``` + +**WidgetOptions Examples:** +```typescript +// Numeric Input +PerseusNumericInputWidgetOptions = { + answers: PerseusNumericInputAnswer[], + labelText?: string, + size: string, + coefficient: boolean, + rightAlign?: boolean, + static: boolean, +} + +// Radio +PerseusRadioWidgetOptions = { + choices: PerseusRadioChoice[], + hasNoneOfTheAbove?: boolean, + countChoices?: boolean, + numCorrect?: number, + randomize?: boolean, + multipleSelect?: boolean, + deselectEnabled?: boolean, +} +``` + +--- + +## 4. Rubric Pattern in Scoring + +### 4.1 Rubric Types (validation.types.ts) + +Rubric types are ONLY used in: +1. Scoring functions (registered in perseus-score) +2. `getOneCorrectAnswerFromRubric` export in WidgetExports +3. Server-side validation/scoring + +**Examples:** + +```typescript +// Line 223-228 of validation.types.ts +export type PerseusNumericInputRubric = { + answers: PerseusNumericInputAnswer[]; + coefficient: boolean; +}; + +// Line 261-266 +export type PerseusRadioRubric = { + choices: PerseusRadioChoice[]; + countChoices?: boolean; +}; + +// Line 114-117 +export type PerseusExpressionRubric = { + answerForms: Array; + functions: string[]; +}; +``` + +### 4.2 Scoring Function Pattern + +From numeric-input implementation: + +```typescript +function getCorrectUserInput( + options: PerseusNumericInputWidgetOptions, +): PerseusNumericInputUserInput { + for (const answer of options.answers) { + if (answer.status === "correct" && answer.value != null) { + // Logic to find correct answer + if (answer.answerForms?.includes("decimal")) { + return {currentValue: answer.value.toString()}; + } + // ... more logic + } + } + return {currentValue: ""}; +} + +// Usage in WidgetExports: +getOneCorrectAnswerFromRubric( + rubric: PerseusNumericInputRubric, +): string | null | undefined { + const correctAnswers = rubric.answers.filter( + (answer) => answer.status === "correct" + ); + // ... logic to format for display +} +``` + +--- + +## 5. Type Relationships + +### 5.1 Data-Schema Hierarchy (PerseusWidgetTypes) + +``` +PerseusWidgetTypes (Interface in data-schema.ts) +├── categorizer: CategorizerWidget +├── dropdown: DropdownWidget +├── numeric-input: NumericInputWidget +├── radio: RadioWidget +├── expression: ExpressionWidget +└── ... (33+ widgets) + +Each entry like: +NumericInputWidget = WidgetOptions<'numeric-input', PerseusNumericInputWidgetOptions> +``` + +### 5.2 Validation Type Hierarchy (validation.types.ts) + +``` +RubricRegistry (Interface) +├── categorizer: PerseusCategorizerRubric +├── radio: PerseusRadioRubric +├── numeric-input: PerseusNumericInputRubric +└── ... (corresponds to PerseusWidgetTypes) + +UserInputRegistry (Interface) +├── categorizer: PerseusCategorizerUserInput +├── radio: PerseusRadioUserInput +└── ... +``` + +### 5.3 Type Parameter Naming in Widgets + +```typescript +// Standard pattern +type ExternalProps = WidgetProps< + PerseusNumericInputWidgetOptions, // TWidgetOptions + PerseusNumericInputUserInput // TUserInput +>; + +// WidgetProps definition (types.ts line 527-533): +export type WidgetProps< + TWidgetOptions, + TUserInput = Empty, + TrackingExtraArgs = Empty, +> = TWidgetOptions & UniversalWidgetProps; +``` + +--- + +## 6. Recent Widget Examples & Practices + +### 6.1 Numeric Input Widget - Fully Modernized ✓ + +**Status:** Class component with functional component integration (hybrid) +- Component: `NumericInputComponent` (functional, lines 28-132) +- Wrapper: `NumericInput` class (implements Widget interface) +- Props: Proper typing with `ExternalProps` and `NumericInputProps` +- Features: Focus management, accessibility (labelText, ariaLabel) + +### 6.2 Radio Widget - In Transition + +**Status:** Class wrapper around multiple-choice-widget +- Uses old `ChoiceState` API (combines UI state + user input) +- Comment: "TODO(LEMS-2994): Clean up this file" +- Issue: Hard to migrate due to component inheritance + +### 6.3 Expression Widget - Modern Pattern + +**Status:** Class component implementing Widget interface +- Proper prop typing +- Focus management via refs +- Comprehensive widget interface implementation + +--- + +## 7. Named Conventions Summary + +### 7.1 Type Naming + +| Pattern | Location | Purpose | +|---------|----------|---------| +| `PerseusWidgetOptions` | data-schema.ts | Widget configuration | +| `PerseusRubric` | validation.types.ts | Scoring data | +| `PerseusUserInput` | validation.types.ts | User's answer | +| `PerseusValidationData` | validation.types.ts | Client-side validation data | + +### 7.2 Prop Naming (Actual Usage) + +**What IS passed to widget components:** +- Widget option fields (spread from `widgetProps`) +- `userInput: TUserInput` +- `handleUserInput: (newUserInput: TUserInput) => void` +- `widgetId: string` +- `apiOptions: APIOptionsWithDefaults` +- `trackInteraction: () => void` +- `onFocus`, `onBlur`, `linterContext`, etc. + +**What IS NOT passed:** +- No `rubric` prop (used only in scoring) +- No `scoringData` prop +- No `validationData` prop +- No separate validation/scoring objects + +### 7.3 Focus Management Requirements + +Every widget must implement: +```typescript +focus?: () => { id: string; path: FocusPath } | boolean; +focusInputPath?: (path: FocusPath) => void; +blurInputPath?: (path: FocusPath) => void; +getInputPaths?: () => ReadonlyArray; +``` + +--- + +## 8. Widget Registration & Discovery + +### 8.1 Registration System (widgets.ts) + +```typescript +const widgets = new Registry("Perseus widget registry"); + +export const registerWidget = (type: string, widget: WidgetExports) => { + widgets.set(type, widget); +}; + +// All widgets register via: +// registerWidgets([numericInput, radio, expression, /* ... */]) +``` + +### 8.2 PerseusWidgetTypes Interface + +```typescript +export interface PerseusWidgetTypes { + categorizer: CategorizerWidget; + dropdown: DropdownWidget; + radio: RadioWidget; + "numeric-input": NumericInputWidget; + // ... 30+ more widgets +} +``` + +This interface: +- Is open for extension (can be module-augmented) +- Used by `MakeWidgetMap` to create `PerseusWidgetsMap` +- Provides strong typing for widget data throughout Perseus + +--- + +## 9. Current State Assessment + +### What Works Well +1. **Type Safety:** Three-layer system (WidgetOptions → ValidationData → Rubric) is robust +2. **Props Architecture:** Clear separation between widget config and user input +3. **Widget Interface:** Consistent interface for all widgets via `Widget` type +4. **Registration:** Flexible registry system supports custom widgets + +### Transition Areas +1. **Radio Widget:** Still transitioning from old `ChoiceState` API +2. **Class Components:** Some widgets still using class components (being converted to functional) +3. **Serialized State:** Deprecated API still present but marked `@deprecated` + +### No Evidence Of +1. Planned move to `scoringData` or `validationData` props +2. Changes to Rubric pattern +3. Alternative type naming schemes + +--- + +## 10. Key Findings for Implementation + +### When Creating a New Widget: + +1. **Define types in data-schema.ts:** + ```typescript + type MyNewWidget = WidgetOptions<'my-new-widget', MyNewWidgetOptions>; + // Add to PerseusWidgetTypes interface + ``` + +2. **Define scoring types in validation.types.ts:** + ```typescript + type PerseuMyNewWidgetRubric = { /* scoring data */ } & PerseuMyNewWidgetValidationData; + type PerseuMyNewWidgetUserInput = { /* user data */ }; + ``` + +3. **Create widget component with WidgetProps typing:** + ```typescript + type Props = WidgetProps; + ``` + +4. **Export via WidgetExports pattern:** + ```typescript + export default { + name: "my-new-widget", + displayName: "My New Widget", + widget: MyNewWidget, + isLintable: true, + getStartUserInput, + getCorrectUserInput, + // ... scoring functions + } satisfies WidgetExports; + ``` + +5. **Implement Widget interface methods:** + - `focus(): boolean` + - `focusInputPath(path: FocusPath): void` + - `blurInputPath(path: FocusPath): void` + - `getInputPaths(): ReadonlyArray` + - `getPromptJSON?(): WidgetPromptJSON` + +--- + +## Files Examined + +- `/packages/perseus-core/src/data-schema.ts` - Widget type definitions +- `/packages/perseus-core/src/validation.types.ts` - Scoring/validation types +- `/packages/perseus/src/types.ts` - Widget interface definitions +- `/packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx` - Example widget +- `/packages/perseus/src/widgets/radio/radio.ff.tsx` - Example widget (in transition) +- `/packages/perseus/src/widgets/expression/expression.tsx` - Example widget +- `/packages/perseus/src/renderer.tsx` - Props passing mechanism +- `/packages/perseus/src/widgets.ts` - Widget registration + +--- + +**End of Research Document** diff --git a/CLAUDE.md b/CLAUDE.md index f6c5da9c7e..132d578894 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,24 +1,42 @@ # Perseus Development Guide for AI Assistants -This document provides essential information for AI assistants working on the Perseus codebase. +This document provides essential context for AI assistants working on the Perseus codebase. Detailed documentation for specific topics is available in `.claude/prompts/`. ## Project Overview -Perseus is Khan Academy's educational content rendering system that powers all exercises and articles. It's a TypeScript -monorepo that extends Markdown with interactive widgets and beautiful math rendering. +Perseus is Khan Academy's educational content rendering system that powers all exercises and articles. It's a TypeScript monorepo that extends Markdown with interactive widgets and beautiful math rendering. -**Core Architecture:** +**Core Components:** - **Renderers**: Display content (ServerItemRenderer for exercises, ArticleRenderer for articles) - **Widgets**: Interactive components (radio, numeric-input, interactive-graph, etc.) -- **Editors**: Authoring interfaces for content creators +- **State Management**: Centralized via UserInputManager with `handleUserInput` pattern - **Math**: TeX expressions rendered via MathJax +## Prompt Library Reference + +Comprehensive documentation is organized by topic in `.claude/prompts/`: + +### Architecture & Organization +- **[codebase-map.md](.claude/prompts/codebase-map.md)** - Package structure, data flow, module boundaries +- **[file-organization.md](.claude/prompts/file-organization.md)** - Directory structure, naming conventions, imports + +### Development Guides +- **[widget-development.md](.claude/prompts/widget-development.md)** - Creating and maintaining widgets +- **[component-best-practices.md](.claude/prompts/component-best-practices.md)** - React components, Wonder Blocks, accessibility +- **[testing-best-practices.md](.claude/prompts/testing-best-practices.md)** - Testing patterns and utilities +- **[math-and-content.md](.claude/prompts/math-and-content.md)** - Math rendering, content structure, MathJax + +### Workflow & Process +- **[iteration-and-feedback.md](.claude/prompts/iteration-and-feedback.md)** - When to ask for help, debugging, deployment checklist + ## Quick Start Commands ### Development ```bash pnpm storybook # Launch Storybook documentation pnpm test # Run tests +pnpm build:types # Build TypeScript types +pnpm build # Build all packages ``` ### Code Quality @@ -30,176 +48,68 @@ pnpm prettier . --write # Auto-format code pnpm tsc # Type-check all packages ``` -### Testing Specific Packages +### Testing ```bash pnpm --filter perseus test # Test main perseus package pnpm --filter perseus-editor test # Test editor package pnpm test packages/perseus/src/widgets/radio # Test specific widget +pnpm test -u # Update snapshots +pnpm test --coverage # Run with coverage ``` -## Key Directories - -``` -packages/ -├── perseus/ # Main package (renderers, widgets, components) -│ ├── src/__docs__/ # Main Storybook stories -│ ├── src/widgets/ # Widget implementations -│ └── src/components/ # Reusable components -├── perseus-editor/ # Editor UI components -├── math-input/ # Math keypad and input components -├── perseus-core/ # Shared types and utilities -├── perseus-linter/ # Content validation tools -└── perseus-score/ # Server-side scoring functions -``` - -## Common Development Patterns - -### Creating a New Widget -1. **Create widget directory**: `packages/perseus/src/widgets/[widget-name]/` -2. **Implement widget files**: - - `[widget-name].tsx` - Main component - - `[widget-name].test.ts` - Tests - - `index.ts` - Exports - - `__docs__/[widget-name].stories.tsx` - Storybook story - - `__docs__/a11y.mdx` - Accessibility documentation -3. **Register widget** in `packages/perseus/src/widgets.ts` -4. **If scorable, add scoring functions** in `packages/perseus-score/src/widgets/[widget-name]/`: - - `score-[widget-name].ts` - Scoring logic - - `score-[widget-name].test.ts` - Scoring tests - - `validate-[widget-name].ts` - Input validation (optional) - - `validate-[widget-name].test.ts` - Validation tests (optional) -5. **Register scoring** in `packages/perseus-score/src/widgets/widget-registry.ts` -6. **Add types** to `packages/perseus-core/src/data-schema.ts` - -### Widget Implementation Pattern -```typescript -// Export interface following WidgetExports pattern -export default { - name: "widget-name", - displayName: "Widget Display Name", - widget: WidgetComponent, - isLintable: true, // For use by the editor -} as WidgetExports; -``` +## Key Architectural Decisions -### Focus Management -All widgets must implement proper focus management for accessibility. +### State Management Evolution +- **Legacy**: Widgets used `onChange` callbacks with local state +- **Current**: Transitioning to `handleUserInput` with centralized UserInputManager +- **Migration Status**: Radio widget bridging old/new patterns (LEMS-2994) +- **Example**: NumericInput fully modernized, use as reference -## Package Dependencies +### Type System +- **Three-layer architecture**: + 1. `WidgetOptions` - Configuration from content creators + 2. `ValidationData` - Client-side validation (no answers) + 3. `Rubric` - Full scoring data (`ValidationData & WidgetOptions`) +- **Important**: Rubric never passed as prop to widgets, only used in scoring -### Import Guidelines -- Use package aliases: `@khanacademy/perseus`, `@khanacademy/perseus-editor` -- NO file extensions in imports (`.ts`, `.tsx` banned by ESLint) +### Import Rules +- Use package aliases: `@khanacademy/perseus`, etc. +- NO file extensions in imports - NO cross-package relative imports - Import order: builtin > external > internal > relative > types -### Example Correct Imports -```typescript -import React from "react"; // external -import {ApiOptions} from "@khanacademy/perseus"; // internal package -import {WidgetContainer} from "../widget-container"; // relative - -import type {WidgetProps} from "@khanacademy/perseus-core"; -``` - -## Testing Guidelines - -### Test Structure -1. Follow the AAA pattern: Arrange, Act, Assert -1.1 If Arrange and Act are one action, combine them to `//Arrange, Act` -2. Use widget generators to build test data and test data options. - You can find generators for all widgets in packages/perseus-core/src/utils/generators. - An example usage can be seen here: packages/perseus/src/widgets/expression/expression.testdata.ts. -3. Follow the test structure below: -```typescript -import {render, screen} from "@testing-library/react"; -import {userEvent} from "@testing-library/user-event"; - -import {question1} from "../__testdata__/widget.testdata"; -import WidgetComponent from "../widget-component"; - -describe("WidgetComponent", () => { - it("renders correctly", () => { - render(); - expect(screen.getByRole("button")).toBeInTheDocument(); - }); - - it("handles user interaction", async () => { - const user = userEvent.setup(); - const onChange = jest.fn(); - - render(); - await user.click(screen.getByRole("button")); - - expect(onChange).toHaveBeenCalled(); - }); -}); -``` - -### Writing Tests -- use `it` for individual test cases and not `test` -- use `describe` to group related tests - -## Common Issues & Solutions - -### Math Rendering (TeX) -- Use `$...$` for inline math, `$$...$$` for display math -- For complex expressions, use `\dfrac` instead of `\frac` -- Test math rendering in different contexts (articles, exercises, hints) - -### Widget State Management -- Use `useState` for local component state -- Props flow down from parent renderer -- Call `onChange` to notify parent of state changes -- Implement proper serialization for persistent state - -### Mobile Considerations -- All widgets must work on mobile devices -- Support touch interactions -- Consider on-screen keypad for math inputs -- Test with different screen sizes using Storybook - -### Performance Optimization -- Use `React.memo()` for expensive components -- Implement `useMemo()` for complex calculations -- Avoid unnecessary re-renders in widget hierarchies -- Profile performance with React DevTools - -## Debugging Tips - -### Storybook Development -- Use Storybook for isolated component development -- Test different props combinations -- Verify accessibility with Storybook a11y addon -- Check mobile layouts with device frame addon - -### Console Debugging -```typescript -// Temporary debugging (remove before commit) -console.log("Widget state:", this.state); -console.log("Props received:", this.props); -``` - -### Error Boundaries -- Widgets are wrapped in error boundaries -- Check browser console for widget-specific errors -- Implement graceful fallbacks for failed widgets - -## Deployment Notes - -### Before Submitting PR -1. Run full test suite: `pnpm test` -2. Check types: `pnpm build:types` -3. Lint and format: `pnpm lint --fix && pnpm prettier . --write` -4. Test in Storybook: `pnpm storybook` -5. Verify accessibility compliance - -### Common Pre-commit Failures -- ESLint errors (unused imports, console statements) -- Prettier formatting (spacing, quotes, semicolons) -- TypeScript type errors -- Missing accessibility documentation -- Test failures +## Before Submitting Code + +### Pre-commit Checklist +1. `pnpm test` - All tests pass +2. `pnpm tsc` - No type errors +3. `pnpm lint --fix` - No linting issues +4. `pnpm prettier . --write` - Code formatted +5. `pnpm build` - Build succeeds +6. `pnpm storybook` - Components render correctly +7. Accessibility documented (for new widgets) +8. Mobile tested (if applicable) +9. Console clear of debug statements + +### Common Issues to Check +- Console statements left in code +- Unused imports +- Missing displayName on components +- onChange used instead of handleUserInput +- Local state for user answers (should use UserInputManager) +- Unescaped backslashes in TeX strings + +## Migration & Deprecation + +### Deprecated Patterns +- `onChange` prop - Use `handleUserInput` instead (LEMS-3245, LEMS-3542) +- Local widget state for answers - Use UserInputManager +- Class components for new widgets - Use functional components + +### In Transition +- Radio widget - Bridging old/new patterns +- Some widgets still using legacy patterns +- Gradual migration to centralized state ## Additional Resources @@ -208,7 +118,9 @@ console.log("Props received:", this.props); - **Widget Gallery**: Browse existing widgets in Storybook for patterns - **Accessibility Guidelines**: Each widget should have `a11y.mdx` documentation - **Khan Academy Design System**: Wonder Blocks components for consistent UI +- **Test Generators**: `@khanacademy/perseus-core/utils/generators` +- **Type Definitions**: `packages/perseus-core/src/data-schema.ts` --- -*This document is maintained for AI assistants. For human developers, see the main README.md files in each package.* +*This document provides an overview and quick reference. For detailed information on any topic, refer to the appropriate document in `.claude/prompts/`. For human developers, see the main README.md files in each package.* \ No newline at end of file