This file provides instructions for Claude when working on the Wonder Blocks design system codebase.
- Language: TypeScript (Strict mode enforced)
- Framework: React (Functional Components and Hooks)
- Styling: Aphrodite (
aphrodite) for CSS-in-JS with Wonder Blocks tokens (@khanacademy/wonder-blocks-tokens) - State Management: Local state (
useState) and React Context API (useContext) - Data Fetching: There should be no data fetching in the design system UI components
- Routing: Design system components with link functionality should also support React-Router routes
- Testing: Jest, React Testing Library (RTL),
@testing-library/user-event, Storybook - Package Manager: pnpm
- Use kebab-case for files and directories (e.g.,
activity-button.tsx,utils.ts) - Test files:
*.test.ts(x) - Storybook files:
*.stories.tsx - PascalCase for React components and types (e.g.,
Button,type ButtonProps) - camelCase for functions, variables, hooks (e.g.,
getButtonProps,useButtonFunctionality)
- Use
strictmode - Define clear interfaces/types. Use
typefor props and state,interfacefor shared structures where appropriate - Use utility types (
Partial,Omit,Pick,Readonly, etc.) - Use the
satisfiesoperator for type-safe object literals - Prefer type-only imports:
import type {...}for types - Avoid
any; useunknownor specific types instead
- Imports: Always use
import * as React from "react"(required for JSX transformation) - Use functional components and hooks (
useState,useEffect,useContext,useCallback,useMemo) - Define explicit
Propstypes with object destructuring. Pass complex objects/callbacks with stable references (useCallback,useMemo) if they are dependencies of effects or memoized children - Keep component state minimal. Lift state up when necessary
- Provide stable
keyprops for lists (use item IDs if available) - Build complex UI by composing smaller Wonder Blocks when possible
- Use
React.forwardRefwhen components need to expose DOM refs - Extract reusable logic into custom hooks (e.g.,
useFieldValidation,useIsMountedfrom@khanacademy/wonder-blocks-core) - Event Handlers: Internal handlers prefixed with
handle(e.g.,handleClick), callback props prefixed withon(e.g.,onClick) - Don't use
React.FC<Props>, use(props: Props) =>instead - Avoid creating new class components
Class to Functional Migration: When converting class components to functional components, if there is a componentWillUnmount method, the corresponding useEffect should either not have any dependencies or should call the isMounted function returned by useIsMounted from @khanacademy/wonder-blocks-core.
- Define styles using
StyleSheet.createfromaphrodite. Colocate styles with the component - Use
addStylefrom@khanacademy/wonder-blocks-coreto create styled HTML elements - Use semantic color tokens from
@khanacademy/wonder-blocks-tokens(e.g.,semanticColor.core.background.base) - Use the
focusStylesutility from@khanacademy/wonder-blocks-stylesfor focus indicators - Avoid using primitive
colortokens; usesemanticColortokens instead
- Use
Viewfrom@khanacademy/wonder-blocks-corefor layout containers instead of plain divs - Use React's
useIdhook to generate unique ids (not the deprecatedIdcomponent) - Use
HeadingandBodyTextfrom@khanacademy/wonder-blocks-typographyfor text - Import Phosphor icons from
@phosphor-icons/core(e.g.,import plusIcon from "@phosphor-icons/core/regular/plus.svg") and usePhosphorIconfrom@khanacademy/wonder-blocks-icon - Avoid deprecated components like
Strut
- Organize imports: React, third-party libs, internal absolute paths (
@khan/,@khanacademy/), relative paths (./,../) - Use absolute paths for cross-package imports
- Strictly adhere to ESLint and Prettier
- The project enforces import order: React, third-party libs, then internal imports
- JSDoc comments should be used for complex functions, but TypeScript types are preferred over JSDoc type annotations
- Never remove existing comments that are used to provide context
- Run
pnpm lintbefore submitting changes
- Configuration (Preferred): Components accept props that control rendering
- Example:
ButtonhasstartIconandendIconprops rather than children - Use when: Precise control over styling, positioning, or behavior of child elements
- Example:
- Composition: Components accept other components as children
- Example:
SingleSelectwithOptionItemcomponents as children - Use when: Component contains many similar items or flexibility in structure is needed
- Example:
- Controlled: Parent passes
valueandonChangeprops (e.g.,TextField,TextArea) - Uncontrolled: State lives in the component itself (e.g.,
Accordion) - Both: Support both by making
valueandonChangeoptional (e.g.,Modal,Popover)
General Props:
id?: string- Unique identifier (auto-generated withuseIdif not provided)testId?: string- Test ID for e2e testingref?: React.Ref<T>- Reference to DOM element (useReact.forwardRef)kind?: string- Variant type (e.g.,'primary' | 'secondary' | 'tertiary')value?: T- The value of the component (for form components)disabled?: boolean- Disabled stateautoFocus?: boolean- Focus on page loadlabels?: CustomLabelsType- Custom labels for i18ninitialFocusRef?: Ref | null- Element to receive initial focus (prefer refs over element ids)
ARIA-related Props:
- If a component supports ARIA props, include
AriaPropstype with component props AriaPropsincludes:role,aria-label,aria-labelledby,aria-describedby, etc.- Examples:
Breadcrumbs,TextField
Styling Props:
style?: StyleType- Custom styles for root elementstyles?: {root?, icon?}- Custom styles for multiple elementssize?: SizeUnion- Component size (e.g.,'small' | 'medium' | 'large')animated?: boolean- Enable animations (defaults tofalse)icon?: ReactElement- Supports both PhosphorIcon and Icon components
Event Handler Props:
onChange?: (value: T) => void- Value change (uses value, not event)onClick,onKeyDown,onKeyUp,onFocus,onBlur- Standard React event handlers with appropriate event types
Validation Props (Form Components):
validate?: (value: T) => string | null | void- Returns error message or nullonValidate?: (errorMessage: string | null) => void- Validation callbackerror?: boolean- Error state
Disabled State:
- Use
aria-disabledattribute instead ofdisabledto keep components focusable - Apply
cursor: not-allowedfor disabled elements - Allow disabled elements to receive focus and blur events
- Don't use the
disabledattribute (it removes from focus order)
Focused State:
- Use
:focus-visibleinstead of:focusfor focus indicators - Use
focusStylesutility from@khanacademy/wonder-blocks-styles - Avoid using
box-shadowfor focus indicators
Other States:
- Hover: Style appropriately; consider in combination with other states
- Active/Pressed: Use CSS
:activepseudo-class instead of JavaScript state tracking - Readonly: Prevent editing but allow focus and selection
- Error/Invalid: Provide clear visual indication of validation errors
- Browser Inconsistencies: Test across browsers; use CSS normalization where needed
Use CSS pseudo-classes (:hover, :focus-visible, :active) for state styling instead of JavaScript state tracking. This enables browser dev tools debugging and Storybook Pseudo States add-on for visual regression tests.
- Use semantic HTML or appropriate ARIA roles/attributes
- Ensure keyboard navigation and focus indicators work correctly
- Use
aria-disabledinstead ofdisabledattribute - Prefer visual text for accessible names; use
aria-labeloraria-labelledbywhere necessary - Animations disabled by default, enabled with
animatedprop - Components must work with screen readers and keyboard navigation
- Use logical CSS properties for RTL support
Reference patterns:
We document Wonder Blocks components using Storybook. Documentation includes:
- Document all public props using JSDoc comments (
/** ... */) - The props table on the autodocs page for Storybook should be extracted from the JSDoc comments on the props for a component
- Provide clear descriptions for each prop, especially for complex or non-obvious props
- Include default values and usage examples where helpful
- If the auto-generated type for a prop is not helpful (e.g., something generic like
"union"), the type can be overridden in anargTypes.tsfile - Props in the table can be grouped into categories like
Visual style,Events,Accessibility - Main component should have a JSDoc comment describing its purpose and basic usage
- The stories should showcase the different ways a component can be used
- The comment block before a story declaration can be used to document more about a specific prop or behavior highlighted in the example
- Snapshot stories should include scenarios and state sheets for showing the different variations and states of a component
- Document what's been implemented in the component for accessibility
- Create separate pages in Storybook to describe the accessibility for a component (examples: Accordion Accessibility, Combobox Accessibility, TextArea Accessibility)
packages/wonder-blocks-*/
├── src/
│ ├── index.ts # Package entry point (exports public API)
│ ├── components/
│ │ ├── component-name.tsx
│ │ └── __tests__/
│ │ └── component-name.test.tsx
│ └── util/
└── package.json
- Stories:
__docs__/*.stories.tsx(root__docs__folder) - Export components as named exports (not default exports)
type StoryComponentType = StoryObj<typeof Component>;
export default {
title: "Packages / ComponentName / SubComponent",
component: ComponentName,
parameters: {
chromatic: { disableSnapshot: false },
},
argTypes: ComponentArgTypes,
} as Meta<typeof ComponentName>;
/**
* JSDoc comment describing this story.
*/
export const Default: StoryComponentType = {
args: {
children: "Click me",
onClick: () => {},
},
};- The Default story should be interactive and work with Storybook controls
- Write stories for all possible prop combinations/states
- Disable Chromatic for stories that don't need visual regression tests (limited monthly snapshots)
- Avoid specific background colors in Stories; let users control via Storybook toolbar
Use function declarations for render functions:
export const WithState: StoryComponentType = {
render: function Render(args) {
const [value, setValue] = React.useState(args.value || "");
return <Component {...args} value={value} onChange={setValue} />;
},
};title: "Packages / Button / Button"
title: "Packages / Dropdown / SingleSelect"
title: "Packages / Button / Testing / Snapshots / ActivityButton"StateSheet for pseudo-state testing:
export const StateSheetStory: Story = {
name: "StateSheet",
render: (args) => (
<StateSheet rows={kinds} columns={actionTypes} title="Kind / Action Type">
{({props, className}) => (
<Component {...args} {...props} className={className} />
)}
</StateSheet>
),
parameters: {
pseudo: defaultPseudoStates, // focus, hover, active states
},
};ScenariosLayout for edge cases:
export const Scenarios: Story = {
render() {
const scenarios = [
{name: "Long label", props: {children: longText}},
{name: "RTL", decorator: <div dir="rtl" />, props: {children: "یہ اردو میں"}},
];
return (
<ScenariosLayout scenarios={scenarios}>
{(props) => props.children}
</ScenariosLayout>
);
},
};Use play functions for browser-specific behavior that jsdom can't handle:
import {expect, within} from "storybook/test";
export const BrowserBehaviorTest: StoryComponentType = {
play: async ({canvasElement}) => {
const canvas = within(canvasElement);
// Test scroll, layout, clipboard, complex focus
},
parameters: {
chromatic: {disableSnapshot: true},
},
};Use Storybook actions for event logging:
import {action} from "storybook/actions";
export const Default: StoryComponentType = {
args: {
onClick: action("clicked"),
onChange: action("changed"),
},
};For stateful stories, combine actions with state updates:
action("onChange")(newValue); setValue(newValue);
Test Workflow Priority:
- Always fix failing tests before fixing linting errors
- Focus on underlying errors, not
Unhandled console.error callmessages - When tests fail with
Unhandled console.error call, look for the root cause error (e.g.,ReferenceError: window is not defined)
Always use this structure with comments:
it("should add two numbers correctly", () => {
// Arrange
const a = 5;
const b = 3;
// Act
const result = add(a, b);
// Assert
expect(result).toBe(8);
});- Never combine sections (don't write
// Act & Assert) - Never use multiple Act or Assert sections in a single test
- Never remove Arrange, Act, Assert comments
it("should throw an error when input is invalid", () => {
// Arrange
const invalidInput = "invalid";
// Act
const underTest = () => processInput(invalidInput);
// Assert
expect(underTest).toThrow("Invalid input");
});DO Test:
- Non-trivial business logic
- User interactions
- Accessibility (ARIA attributes, keyboard support, focus management)
- Edge cases and error conditions
- Bug fixes (add regression tests)
DON'T Test:
- Trivial implementations (simple getters/setters)
- Style-only props (use visual regression tests)
- Third-party libraries
- Implementation details
- Semantic queries (best):
getByRole,getByLabelText,getByText - Test IDs (fallback):
getByTestId - Never use: CSS classes, IDs, structural selectors, direct node access (
.parentElement,.children, etc.)
// ✅ Good
screen.getByRole("button", {name: /submit/i});
screen.getByLabelText("Email address");
// ❌ Bad
container.querySelector(".my-class");Always use userEvent instead of fireEvent:
import userEvent from "@testing-library/user-event";
await userEvent.click(screen.getByRole("button"));
await userEvent.type(screen.getByRole("textbox"), "hello");- Never mock
console.error- hides real issues - Always use
jest.spyOn()to create spies - never treat original functions as spies - Store spy return values only when asserting on them - avoids unused variable errors
- Never mock outside of tests - keep mocks inside test cases
// ✅ Mock only (no variable needed when not asserting)
jest.spyOn(API, "fetchUser").mockResolvedValue(mockUserData);
// ✅ Mock AND verify (store when asserting)
const trackEventSpy = jest.spyOn(Analytics, "trackEvent").mockReturnValue(undefined);
expect(trackEventSpy).toHaveBeenCalledWith("button_click", {buttonId: "submit"});
// ❌ Wrong - treating original as spy without jest.spyOn()
expect(SomeFile.someMethod).toHaveBeenCalled(); // ERROR! Not a spyjsdom doesn't fully implement browser behaviors (scroll, layout, clipboard, Observers):
// Mock scrollIntoView
Element.prototype.scrollIntoView = jest.fn();
// Mock getBoundingClientRect
jest.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({
top: 100, left: 100, bottom: 200, right: 200,
width: 100, height: 100, x: 100, y: 100, toJSON: () => {},
});Use Storybook interaction tests for behavior that's difficult to mock accurately.
describe("MyComponent", () => {
describe("Props", () => { /* prop tests */ });
describe("Event Handlers", () => { /* onClick, onChange tests */ });
describe("Accessibility", () => {
describe("axe", () => { /* toHaveNoA11yViolations tests */ });
describe("ARIA", () => { /* aria attribute tests */ });
describe("Focus", () => { /* focus management tests */ });
describe("Keyboard Interactions", () => { /* keyboard nav tests */ });
});
});Unit tests for a component should cover:
- ref is forwarded
- Cover expected behaviour when certain props are set
- Exclude tests for props that are related to styles only - use visual regression tests instead
- Cover expected behaviour with default prop values
- Use
it.eachwhen there are multiple combinations of things you want to test together
- Check that any event handlers are triggered by the expected conditions
- Verify callbacks are called with correct arguments
- Confirm that roles, semantics, and aria attributes are correctly set and wired together
- Use the
.toHaveNoA11yViolationsjest matcher to confirm that a component doesn't have accessibility warnings - Confirm keyboard interactions and navigation
- Focus management
- Confirm accessible names
- Check for
aria-disabled="true"for determining disabled state (not thedisabledattribute)
Use it.each for data-driven tests:
it.each([
[2, 3, 5],
[0, 0, 0],
[-1, 1, 0],
])("should add %i and %i to equal %i", (a, b, expected) => {
expect(add(a, b)).toBe(expected);
});- Use specific matchers:
toBe,toEqual,toHaveBeenCalledWith - Use RTL matchers:
toBeInTheDocument(),toBeVisible(),toHaveAttribute() - Avoid Jest snapshots (
.toMatchSnapshot()) - use Chromatic + Storybook for visual regression - Prefer one expect per test when possible
| Command | Description |
|---|---|
pnpm start |
Start dev server (Storybook) |
pnpm install |
Install/update dependencies |
pnpm build |
Build all packages |
pnpm lint |
Lint check |
pnpm typecheck |
Type check |
pnpm test |
Run tests |
pnpm build:storybook |
Build Storybook |
pnpm test:storybook |
Run Storybook tests with a11y checks |
pnpm changeset |
Create a changeset |
pnpm changeset --empty |
Empty changeset (tests, stories, tooling changes) |
Don't use pnpx or npx to run commands.
Wonder Blocks runs the Storybook MCP addon (@storybook/addon-mcp) so AI agents can discover components, list stories, and use docs tooling.
- MCP endpoint: When Storybook is running (
pnpm start), the MCP server is athttp://localhost:6061/mcp(this repo uses port 6061, not the default 6006). - Setup: Start Storybook with
pnpm startbefore connecting an MCP client. The addon is configured withtoolsets: { dev: true, docs: true }andexperimentalComponentsManifest: true. - Agent-facing details: See AGENTS.md for connection steps, repo layout for agents, and how to keep documentation agent-friendly.
When working on components or stories, prefer using the MCP tools (list components, get story details) when the client is connected to avoid guessing story IDs or structure.
Wonder Blocks follows Semantic Versioning 2.0.0:
| Version | When to Use | Example |
|---|---|---|
Major (X.0.0) |
Breaking changes | Renaming a prop from enabled to disabled |
Minor (0.X.0) |
New features (backward compatible) | Adding a new error prop |
Patch (0.0.X) |
Bug fixes, internal changes | Fixing border color, upgrading dependencies |
If consumers must change their code for Wonder Blocks to work, it should be a major change.
Don't hesitate to bump major or minor versions when appropriate—following semver correctly is more valuable than trying to minimize version number changes.
CRITICAL: Keep AI assistant rules in sync across platforms.
This project maintains rules for multiple AI assistants. When updating this file, also update the corresponding files for other platforms:
| Claude File | Cursor File | Copilot File |
|---|---|---|
CLAUDE.md |
.cursor/rules/general.mdc |
.github/instructions/frontend-rules.instructions.md |
CLAUDE.md (Storybook section) |
.cursor/rules/storybook.mdc |
.github/instructions/storybook.instructions.md |
CLAUDE.md (Jest section) |
.cursor/rules/unit-tests.mdc |
.github/instructions/unit-tests.instructions.md |
When making rule changes:
- Make the change in this
CLAUDE.mdfile - Apply the same change to the corresponding
.cursor/rules/*.mdcfile for Cursor - Apply the same change to the corresponding
.github/instructions/*.instructions.mdfile for Copilot
Keep content semantically equivalent:
- The exact formatting may differ between platforms
- The core rules and guidance should remain consistent
- Cursor rules use YAML frontmatter with glob patterns
- Copilot instructions use standard Markdown in
.github/instructions/ - This
CLAUDE.mdis a consolidated file with all major rules
Never update only one platform's rules - this causes inconsistent behavior between AI assistants.
- TypeScript: Use strict mode, avoid
any, prefer type-only imports - React: Use functional components and hooks, avoid
React.FC - Styling: Use semantic color tokens and
StyleSheet.createfrom Aphrodite - Accessibility: Use
aria-disabledinstead ofdisabled, ensure keyboard navigation works - Components: Small, single-responsibility, with proper TypeScript types
- Props: Follow common patterns (
id,testId,disabled,onChange, etc.) - States: Use CSS pseudo-classes for hover/focus/active styling
- Documentation: JSDoc comments for props, Storybook stories for examples
- Testing: Jest + RTL for behavior, Storybook for visual regression
- Tokens: Use
semanticColor,font, andsizingfrom@khanacademy/wonder-blocks-tokens