| name | migrate-component |
| description | Migrate a React-only component to the shared core/React/Vue architecture |
| argument-hint | component-name |
Migrate Component to Core Architecture
Migrate a React-only component to the core/React/Vue architecture pattern.
Usage
/migrate-component <ComponentName>
Example:
/migrate-component Switch
/migrate-component Table
Description
This skill migrates a component (or component family) from React-only implementation to a shared core architecture where:
- Core package (
@lumx/core): Contains framework-agnostic UI logic, tests (plain data only, no JSX), and stories (JSX renders using injected framework components)
- React package (
@lumx/react): Thin wrapper that delegates to core
- Vue package (
@lumx/vue): Thin wrapper that delegates to core
Component Families:
- A component name may refer to a family of components (e.g., "Table" includes Table, TableRow, TableCell, TableBody, TableHeader)
- When migrating a component family, the skill will:
- Discover all components in the family by scanning the React folder
- Analyze dependencies between components and external dependencies
- Determine migration order based on dependencies
- Migrate each component in the correct order through all phases
Component Organization:
- Sub-components go in the same folder as their parent component
- Example:
Badge and BadgeWrapper both live in the badge/ folder
- Core:
/packages/lumx-core/src/js/components/Badge/ contains both Badge and BadgeWrapper
- React:
/packages/lumx-react/src/components/badge/ contains both Badge.tsx and BadgeWrapper.tsx
- Vue:
/packages/lumx-vue/src/components/badge/ contains both Badge.tsx and BadgeWrapper.tsx
- Example:
Table family in the table/ folder
- Core:
/packages/lumx-core/src/js/components/Table/ contains index.tsx (Table), TableRow.tsx, TableCell.tsx, etc.
- React:
/packages/lumx-react/src/components/table/ contains Table.tsx, TableRow.tsx, TableCell.tsx, etc.
- Vue:
/packages/lumx-vue/src/components/table/ contains Table.tsx, TableRow.tsx, TableCell.tsx, etc.
- The folder name uses lowercase-with-dashes (e.g.,
badge/, table/), while the component names use PascalCase (e.g., Badge, BadgeWrapper, Table, TableRow)
Prerequisites
Before running this skill, ensure:
- The component exists in
@lumx/react and is fully functional
- The component has existing tests and stories
- A reference component (like Checkbox) has already been migrated and can serve as a pattern
Phase 0: Discovery & Dependency Analysis
Goal: Discover all components in the family and determine the correct migration order.
IMPORTANT: This phase MUST be completed before starting any migration work.
-
Check for existing core implementation:
- BEFORE starting migration, check if the component already exists in
@lumx/core
- Read
/packages/lumx-core/src/js/components/<ComponentName>/ to see if any files exist
- Check for existing artifacts:
- UI files:
index.tsx, <SubComponent>.tsx, constants.ts
- Stories:
Stories.tsx (or Stories.ts)
- Tests:
Tests.ts
- Document what already exists:
- If core UI implementation exists → Plan to reuse and skip Phase 1 (UI extraction)
- If core Stories exist → Plan to reuse and skip Phase 2 Step 1 (Core stories creation)
- If core Tests exist → Plan to reuse and skip Phase 3 Step 2 (Core tests creation)
- If ANY existing core code is found:
- Review the existing code to understand its structure
- USE AskUserQuestion tool to inform the developer about what exists
- Ask if any changes to the existing core implementation are needed
- If changes are needed, document them and get approval before proceeding
- If no core implementation exists, proceed with full migration
-
Discover component family:
- Read
/packages/lumx-react/src/components/<component-name>/index.ts to find all exported components
- List all
.tsx files in the component folder
- Identify which component is the parent and which are sub-components
-
Analyze dependencies:
- For each component, check if it imports:
- Constants from a shared
constants.ts file (dependency on parent)
- Other components from the same folder (dependency on siblings)
- Components from
@lumx/react or @lumx/core (external dependencies)
- Verify external component dependencies are available in
@lumx/core
- Document any blocking dependencies (components not yet migrated to core)
-
Determine migration order:
- If there's a
constants.ts file, migrate the parent component first (it will create the constants in core)
- If sub-components import the parent component, migrate the parent first
- Otherwise, components can be migrated in any order
- Create a numbered list of components in migration order
-
Present migration plan to developer:
- Show whether core implementation already exists
- Show discovered components
- Show dependency analysis
- Show proposed migration order
- List any blocking dependencies
- If core exists, list any proposed changes to core implementation
- Ask for confirmation before proceeding
Example for Table family:
Discovered components:
1. Table (parent - defines constants.ts)
2. TableBody (imports TABLE_CLASSNAME from constants)
3. TableCell (imports TABLE_CLASSNAME from constants, uses Icon)
4. TableHeader (imports TABLE_CLASSNAME from constants)
5. TableRow (imports TABLE_CLASSNAME from constants)
Dependencies:
- All sub-components depend on constants.ts (created by Table)
- TableCell uses Icon (already available in core ✓)
Migration order:
1. Table (parent, creates constants)
2. TableBody
3. TableCell
4. TableHeader
5. TableRow
Validation Checkpoint 0:
- Developer is informed about any existing core implementation (UI, Stories, Tests)
- Developer reviews and approves the migration plan
- Developer confirms all external dependencies are available or acceptable to skip
- If changes to existing core code are proposed, developer approves those changes
🛑 IMPORTANT: Validation Checkpoints
This skill has MANDATORY validation checkpoints where you MUST stop and wait for user approval:
- Checkpoint 1: After Phase 1 (UI Implementation) - Verify React/Vue components work
- Checkpoint 2a: After stories migration - Verify React stories in Storybook
- Checkpoint 2b: After Vue stories - Verify Vue stories in Storybook
- Checkpoint 3: After tests migration - Verify all tests pass
At each checkpoint:
- STOP immediately - Do not continue to the next phase
- USE the AskUserQuestion tool to present results and ask for approval
- WAIT for user response - Only proceed when user explicitly approves
- DO NOT skip or rush through checkpoints - Each validation is critical
Migration Steps
IMPORTANT: After Phase 0 approval, migrate each component in the determined order by going through Phases 1-6 for each component before moving to the next.
Phase 1: UI Extraction & Implementation
Goal: Extract the core UI logic and create thin wrappers for React and Vue.
IMPORTANT - Check for Existing Core Implementation:
- If Phase 0 discovered that core UI implementation already exists, skip steps 1-2 and proceed directly to step 3 (Update React wrapper)
- If any modifications to the existing core implementation are needed:
- ALWAYS use AskUserQuestion tool BEFORE making any changes to core code
- Present the proposed changes clearly with rationale
- Wait for user approval before modifying any core files
- If no core implementation exists, proceed with steps 1-2 to create it
-
Create core component files:
- If migrating the parent component first (has constants.ts in React):
packages/lumx-core/src/js/components/<ComponentName>/
├── constants.ts (migrate from React)
└── index.tsx (parent component)
- For standalone components:
packages/lumx-core/src/js/components/<ComponentName>/
└── index.tsx
- For sub-components being migrated after parent (e.g.,
TableRow after Table):
packages/lumx-core/src/js/components/<ParentComponentName>/
├── index.tsx (parent component)
└── <SubComponentName>.tsx (e.g., BadgeWrapper.tsx)
- Sub-components use separate files (e.g.,
BadgeWrapper.tsx), not index.tsx
-
Extract UI logic:
- For parent components with constants.ts:
- First migrate
constants.ts to core (keep exact same structure)
- Update React imports to use
@lumx/core/js/components/<Component>/constants
- For sub-components:
- Update imports:
import { CLASSNAME as PARENT_CLASSNAME } from './constants' → import { CLASSNAME as PARENT_CLASSNAME } from '@lumx/core/js/components/<Parent>/constants'
- For all components:
- Change
children prop to label: JSXElement (framework-agnostic)
- Add required
inputId: string prop if needed (generated by wrappers)
- Use functional JSX calls:
InputLabel({ ... }) instead of <InputLabel ... />
- Remove React-specific code (Children.count, etc.)
- Check for new callback props (e.g.,
onCustomEvent, onSpecialAction) defined in core UI
- If new callbacks are introduced, update
PropsToOverride in /packages/lumx-core/src/js/types/jsx/PropsToOverride.ts to include them
- Export:
Component, ComponentProps, COMPONENT_NAME, CLASSNAME, DEFAULT_PROPS (or import from constants if applicable)
-
Update React wrapper:
- Import UI component from core
- Import
ReactToJSX type utility from @lumx/react/utils/type/ReactToJSX
- Define props interface using
ReactToJSX<UIProps, 'additionalPropsToOmit'> instead of manual Omit
- Transform into thin wrapper using
forwardRef
- Use hooks:
useId, useTheme, useDisableStateProps, useMergeRefs
- Map
children → label for core component
- Call
UI({ ... }) instead of rendering JSX
- Maintain backward compatibility
-
Create Vue wrapper:
- For standalone components, create directory structure:
packages/lumx-vue/src/components/<component-name>/
├── <Component>.tsx
└── index.ts
- For sub-components (e.g.,
BadgeWrapper alongside Badge), add to existing parent folder:
packages/lumx-vue/src/components/<parent-component-name>/
├── <ParentComponent>.tsx
├── <SubComponent>.tsx (e.g., BadgeWrapper.tsx)
└── index.ts (update to export both components)
- Use
defineComponent with render function
- Use composables:
useTheme, useId, useDisableStateProps
- Support both
label prop and default slot
- Emit events instead of onChange callbacks
- Use JSX rendering:
return (<ComponentUI ... />)
- Add stop propagation:
event.stopImmediatePropagation()
- Define props using
keysOf<ComponentProps>()
- Set
name: 'Lumx<Component>'
- Set
inheritAttrs: false
- Create
index.ts with exports for components and types only:
- Read the React component's
index.ts file for reference
- Export components and types (props, enums, utilities)
- Do NOT export
CLASSNAME, COMPONENT_NAME, or DEFAULT_PROPS — these are internal constants
- Vue uses default export for the component:
export { default as Component }
- React uses named export:
export { Component }
-
Update Vue package index:
export * from './components/<component-name>';
🛑 MANDATORY Validation Checkpoint 1 - STOP HERE:
- Run
yarn test to ensure no regressions
- Run
yarn type-check to verify TypeScript compilation
- STOP AND USE AskUserQuestion tool to ask developer for validation:
- Present test and type-check results
- Ask: "Phase 1 complete. Please verify React components work correctly and Vue components render basic UI. Should I proceed to Phase 2 (Stories Migration)?"
- Options: "Yes, proceed" / "No, fix issues first"
- DO NOT PROCEED to Phase 2 until developer selects "Yes, proceed"
- If developer selects "No", fix issues and ask again
Phase 2: Stories Migration
Goal: Create shared core stories with JSX renders and thin framework-specific wrappers.
KEY ARCHITECTURE:
- Core stories use JSX (
.tsx file) with framework components injected via a components parameter
- Both React and Vue story files are thin wrappers that pass
components and decorators to core, then re-export stories
- No
.vue template files for stories — all rendering is done via JSX in core
- No
withRender utility — replaced by JSX render functions defined in core
- Keep the same stories — Don't add or remove stories; migrate existing ones only
HOW IT WORKS:
- Core
.tsx files use "jsx": "preserve" — the JSX is NOT compiled by core
- When React's build imports the core
.tsx file, React's toolchain compiles JSX to React.createElement
- When Vue's build imports it, Vue's toolchain compiles JSX to
h() calls
- This means the same JSX code works for both frameworks, as long as it uses injected components (not hardcoded imports from
@lumx/react or @lumx/vue)
Step 1: Analyze and Create Core Stories
IMPORTANT - Check for Existing Core Stories:
- If Phase 0 discovered that
Stories.ts or Stories.tsx already exists in core, review it and plan changes
- If any modifications to existing core stories are needed:
- ALWAYS use AskUserQuestion tool BEFORE making any changes
- Present proposed changes with clear rationale
- Wait for user approval before modifying core Stories
-
Read and analyze existing React stories:
- Identify all components used in the stories (Icon, Text, FlexBox, etc.)
- These components will be passed via the
components parameter
- Identify framework-only stories (e.g., React-only stories using
GenericBlock) — these stay in the framework file
-
Create core stories (packages/lumx-core/src/js/components/<ComponentName>/Stories.tsx):
- File extension is
.tsx — JSX is used for render functions
- Export
setup() function that takes { component, components, render, decorators } and returns story configurations
- The
components parameter receives framework-specific component implementations (e.g., { Badge, Icon })
- Use
overrides only when a story needs completely different structure per framework (rare)
KEY RULES for core stories:
- NEVER put JSX in
args — all JSX must live in render functions. This includes args.children, args.before, args.after, args.badge, and any other prop. JSX in args causes errors in Vue storybook tests (vitest). Only serializable data (strings, numbers, booleans, enums, objects) should be in args.
- NEVER put JSX in
withCombinations rows/sections/cols — combination values are merged into args at runtime, so they have the same restriction. Use render functions to produce JSX for different variants instead.
- Define each story and
meta as individual const variables — this enables stories to reference each other (e.g., WithIcon.render reused by AllTypography). Return them as a flat object: return { meta, StoryA, StoryB, ... }.
- Stories that need JSX content get their own
render function — the render function receives args (serializable data) and returns JSX using the injected framework components.
- Stories can reuse other stories'
render — e.g., AllTypography can set render: WithIcon.render to reuse the same rendering.
- Composite stories can call other renders — e.g.,
AllColors can compose {WithText.render(args)}, {WithIcon.render(args)} together.
- ALWAYS destructure
children out of args in render functions — when a render function provides its own inline JSX children, it must destructure children from the args to prevent the inherited children value (from meta.args) from leaking via {...args} onto the component. In Vue, spreading children as a prop on a DOM element causes a "Failed setting prop children" warning because children is a read-only DOM property. Use ({ children, ...args }: any) => instead of (args: any) =>.
Pattern:
```tsx
import type { SetupStoriesOptions } from '@lumx/core/stories/types';
import { colorArgType } from '@lumx/core/stories/controls/color';
import { withUndefined } from '@lumx/core/stories/controls/withUndefined';
import { mdiHeart } from '@lumx/icons';
import { ColorPalette } from '../../constants';
import { DEFAULT_PROPS } from '.';
export function setup({
component: Badge,
components: { Icon, Thumbnail, FlexBox },
decorators: { withCombinations },
}: SetupStoriesOptions<{
decorators: 'withCombinations';
components: { Icon: any; Thumbnail: any; FlexBox: any };
}>) {
// Define meta and each story as individual consts
const meta = {
component: Badge,
render: (args: any) => <Badge {...args} />,
argTypes: {
color: colorArgType,
},
args: DEFAULT_PROPS,
};
/** Using badge with text children */
const WithText = {
// JSX in render, NOT in args
render: (args: any) => (
<Badge {...args}>
<span>30</span>
</Badge>
),
};
/** With icon child — uses Icon from injected components */
const WithIcon = {
render: (args: any) => (
<Badge {...args}>
<Icon icon={mdiHeart} />
</Badge>
),
};
/** All color combinations — composes other stories' renders */
const AllColors = {
render: (args: any) => (
<FlexBox orientation="vertical" gap="regular">
{WithText.render(args)}
{WithIcon.render(args)}
</FlexBox>
),
argTypes: { color: { control: false } },
decorators: [
withCombinations({
combinations: {
// Only serializable data in combinations — NO JSX
cols: { key: 'color', options: withUndefined(ColorPalette) },
},
}),
],
};
// Return flat object with all consts
return { meta, WithText, WithIcon, AllColors };
}
```
More examples of the pattern:
When a story just needs different args (no JSX), it doesn't need a custom render:
```tsx
/** Story with only serializable args — inherits meta render */
const Disabled = {
args: { isDisabled: true },
};
```
When multiple stories share the same render:
```tsx
/** Text with inline icons — destructure children to prevent leaking from meta.args */
const WithIcon = {
render: ({ children, ...args }: any) => (
<Text {...args}>
Some text <Icon icon={mdiHeart} /> with icons <Icon icon={mdiEarth} />
</Text>
),
};
/** All typographies — reuses WithIcon's render */
const AllTypography = {
render: WithIcon.render,
argTypes: { typography: { control: false } },
decorators: [
withCombinations({
combinations: {
rows: { key: 'typography', options: withUndefined(ALL_TYPOGRAPHY) },
},
}),
],
};
```
When a component has slot-like props (before, after, badge), put the JSX in render, not args:
```tsx
const DefaultRender = render || ((args: any) => <Toolbar {...args} />);
/** Toolbar with all content areas */
const WithAll = {
render: () => (
<DefaultRender
before={<Icon icon={mdiMenu} />}
after={<Icon icon={mdiMagnify} />}
label="Page title"
/>
),
};
```
Step 2: Implement React Stories
-
Update React stories to use core setup:
-
Import setup from core stories
-
Pass framework components via components and decorators via decorators
-
Export each story as a thin re-export: export const StoryName = { ...stories.StoryName };
-
Add framework-only stories (using components not available in core) as separate exports
-
Pattern:
import { Badge, Icon } from '@lumx/react';
import { withCombinations } from '@lumx/react/stories/decorators/withCombinations';
import { setup } from '@lumx/core/js/components/Badge/Stories';
const { meta, ...stories } = setup({
component: Badge,
components: { Icon },
decorators: { withCombinations },
});
export default {
title: 'LumX components/badge/Badge',
...meta,
};
export const WithText = { ...stories.WithText };
export const WithIcon = { ...stories.WithIcon };
export const AllColors = { ...stories.AllColors };
export const WithThumbnail = {
args: {
children: <Thumbnail ... />,
},
};
🛑 MANDATORY Validation Checkpoint 2a - STOP HERE:
- Run
yarn type-check to verify TypeScript compilation
- STOP AND USE AskUserQuestion tool to ask developer for validation:
- Present type-check status
- Ask: "React stories migrated. Please verify ALL React stories render correctly in Storybook. Should I proceed to create Vue stories?"
- Options: "Yes, proceed to Vue stories" / "No, fix issues first"
- DO NOT PROCEED to Vue stories until developer selects "Yes, proceed to Vue stories"
- If developer selects "No", fix issues and ask again
Step 3: Implement Vue Stories
-
Create Vue stories as thin wrapper (.tsx file):
- Import
setup from core stories
- Pass Vue framework components via
components and Vue decorators via decorators
- Export each story as a thin re-export — identical structure to React
- No
.vue template files needed — all rendering handled by JSX in core
- IMPORTANT: Provide a
render override when the Vue component uses slots instead of props. Since core stories now put JSX in render functions (not args), the render override maps slot-like props to Vue slots when the core render passes them as JSX props/children.
Pattern A — Default slot only (e.g., children or label prop → default slot):
```tsx
import { Flag } from '@lumx/vue';
import { setup } from '@lumx/core/js/components/Flag/Stories';
const { meta, ...stories } = setup({
component: Flag,
// Destructure `label` out of args and pass it as default slot (JSX children)
render: ({ label, ...args }: any) => <Flag {...args}>{label}</Flag>,
decorators: { /* ... */ },
});
```
Pattern B — Named slots (e.g., before, after, label → named slots):
```tsx
import { Toolbar, Icon } from '@lumx/vue';
import { setup } from '@lumx/core/js/components/Toolbar/Stories';
const { meta, ...stories } = setup({
component: Toolbar,
components: { Icon },
// Map props to Vue named slots using Vue JSX slot object syntax
render: ({ label, before, after, ...args }: any) => (
<Toolbar {...args}>
{{
default: label ? () => label : undefined,
before: before ? () => before : undefined,
after: after ? () => after : undefined,
}}
</Toolbar>
),
});
```
Pattern C — No slot mapping needed (core render handles everything):
When the core stories already handle all JSX in their own render functions (the new default pattern), and the Vue component doesn't need slot mapping because the core render directly renders the component with children via JSX, no render override is needed:
```tsx
import { Badge, FlexBox, Icon, Thumbnail } from '@lumx/vue';
import { withCombinations } from '@lumx/vue/stories/decorators/withCombinations';
import { setup } from '@lumx/core/js/components/Badge/Stories';
const { meta, ...stories } = setup({
component: Badge,
components: { Icon, Thumbnail, FlexBox },
decorators: { withCombinations },
// No render override needed — core stories already define render per story
});
```
How to decide which pattern to use:
- Check the core
Stories.tsx — if stories define their own render functions that directly render the component with JSX children (the new pattern), Vue usually doesn't need a render override (Pattern C)
- If the core stories use a shared
meta.render that receives slot-like content via args (e.g., label, before, after), the Vue side needs to map those to slots (Pattern A or B)
- Read the Vue component's
.tsx file and check if it accesses slots (e.g., slots.default?.(), slots.before?.())
- If it uses
slots.default?.() only → use Pattern A
- If it uses named slots (e.g.,
slots.before?.(), slots.after?.()) → use Pattern B
- If core stories already handle rendering inline (each story has its own render) → use Pattern C
Full example with re-exports:
```tsx
import { Badge, FlexBox, Icon, Thumbnail } from '@lumx/vue';
import { withCombinations } from '@lumx/vue/stories/decorators/withCombinations';
import { setup } from '@lumx/core/js/components/Badge/Stories';
const { meta, ...stories } = setup({
component: Badge,
components: { Icon, Thumbnail, FlexBox },
decorators: { withCombinations },
});
export default {
title: 'LumX components/badge/Badge',
...meta,
};
export const WithText = { ...stories.WithText };
export const WithIcon = { ...stories.WithIcon };
export const AllColors = { ...stories.AllColors };
```
🛑 MANDATORY Validation Checkpoint 2b - STOP HERE:
- Run
yarn test to ensure no regressions
- Run
yarn type-check to verify TypeScript compilation
- STOP AND USE AskUserQuestion tool to ask developer for validation:
- Present test and type-check results
- Ask: "All Vue stories complete. Please verify ALL Vue stories render correctly in Storybook. Should I proceed to Phase 3 (Tests Migration)?"
- Options: "Yes, proceed to Phase 3" / "No, fix issues first"
- DO NOT PROCEED to Phase 3 until developer selects "Yes, proceed to Phase 3"
- If developer selects "No", fix issues and ask again
Phase 3: Tests Migration
Goal: Extract core tests and update framework-specific test suites.
IMPORTANT RULES:
- NO JSX ELEMENTS or component calls in core tests - Use plain data only
- NO interaction/event tests in core - Core tests should only test rendering, props, and DOM structure
- Event handler tests belong in React/Vue - Test
onClick interactions in React tests, test emit('click') in Vue tests
- Tests that need component children must use framework-specific setup - Don't migrate those to core
- Vue tests should mimic React tests - Include the same structure: core tests import, framework-specific describe block, and
commonTestsSuiteVTL (Vue) or commonTestsSuiteRTL (React)
- DO NOT add NOTE comments or explanatory comments in generated files - Keep code clean without meta-commentary
IMPORTANT - Check for Existing Core Tests:
- If Phase 0 discovered that
Tests.ts already exists in core, skip step 2 and proceed to step 3 (React tests update)
- If any modifications to existing core tests are needed:
- ALWAYS use AskUserQuestion tool BEFORE making any changes
- Present proposed changes with clear rationale
- Wait for user approval before modifying core Tests.ts
-
Read and analyze existing React tests:
- Identify tests that use plain data (strings, numbers, etc.) - these can migrate to core
- Identify tests that use JSX components (Icon, Thumbnail, etc.) - these stay in React/Vue only
- Document which tests cannot be migrated due to component dependencies
-
Create core tests (packages/lumx-core/src/js/components/<ComponentName>/Tests.ts):
-
NO JSX ELEMENTS or component calls allowed - Use plain data only
-
Export setup() function that takes props and SetupOptions
-
Export default test suite function that receives SetupOptions and contains describe/it blocks
-
Only migrate tests that use plain data (strings, numbers, booleans)
-
Follow the Button pattern exactly
-
Example pattern:
import { getByClassName } from '../../../testing/queries';
import { SetupOptions } from '../../../testing';
import { ColorPalette } from '../../constants';
const CLASSNAME = 'lumx-badge';
export const setup = (propsOverride: any = {}, { render, ...options }: SetupOptions<any>) => {
const props = { ...propsOverride };
const wrapper = render(props, options);
const badge = getByClassName(document.body, CLASSNAME);
return { props, badge, wrapper };
};
export default (renderOptions: SetupOptions<any>) => {
const { screen } = renderOptions;
describe('Badge core tests', () => {
describe('Props', () => {
it('should use default props', () => {
const { badge } = setup({ children: '30' }, renderOptions);
expect(badge.className).toContain('lumx-badge');
expect(badge.className).toContain('lumx-badge--color-primary');
expect(badge).toHaveTextContent(/30/);
});
it('should render color', () => {
const { badge } = setup({ children: 'Badge', color: ColorPalette.red }, renderOptions);
expect(badge).toHaveClass('lumx-badge--color-red');
});
});
});
};
-
Update React tests:
-
Import default export from core tests (the test suite)
-
Call the test suite with { render, screen } options
-
Keep React-specific tests (ref forwarding, theme context, JSX children)
-
Keep commonTestsSuiteRTL (React-specific)
-
Example pattern:
import { commonTestsSuiteRTL } from '@lumx/react/testing/utils';
import { getByClassName } from '@lumx/react/testing/utils/queries';
import { render, screen } from '@testing-library/react';
import { Badge, BadgeProps } from './Badge';
import BaseBadgeTests from '@lumx/core/js/components/Badge/Tests';
const CLASSNAME = Badge.className as string;
const setup = (propsOverride: Partial<BadgeProps> = {}) => {
const props: BadgeProps = {
children: <span>30</span>,
...propsOverride,
};
render(<Badge {...props} />);
const badge = getByClassName(document.body, CLASSNAME);
return { badge, props };
};
describe(`<${Badge.displayName}>`, () => {
BaseBadgeTests({
render: (props: BadgeProps) => render(<Badge {...props} />),
screen,
});
describe('React', () => {
it('should render empty children', () => {
const { badge } = setup({ children: null });
expect(badge).toBeInTheDocument();
expect(badge).toBeEmptyDOMElement();
});
});
commonTestsSuiteRTL(setup, {
baseClassName: CLASSNAME,
forwardClassName: 'badge',
forwardAttributes: 'badge',
forwardRef: 'badge',
});
});
-
Create Vue tests (packages/lumx-vue/src/components/<component-name>/<Component>.test.ts):
-
IMPORTANT: Vue tests should mimic React tests exactly - Same structure with core tests, framework describe, and commonTestsSuite
-
Import default export from core tests (the test suite)
-
Import and use the core setup function
-
Call the test suite with render function that converts children to slots
-
Create a local setup function that wraps the core setup
-
Add commonTestsSuiteVTL (Vue equivalent of React's commonTestsSuiteRTL)
-
Add Vue-specific tests (emit events, disabled states) if needed
-
Use @testing-library/vue
-
Example pattern:
import { render, screen } from '@testing-library/vue';
import BaseBadgeTests, { setup } from '@lumx/core/js/components/Badge/Tests';
import { CLASSNAME } from '@lumx/core/js/components/Badge';
import { commonTestsSuiteVTL, SetupRenderOptions } from '@lumx/vue/testing';
import { Badge } from '.';
describe('<Badge />', () => {
const renderBadge = ({ children, ...props }: any, options?: SetupRenderOptions<any>) =>
render(Badge, {
...options,
props,
slots: children ? { default: children } : undefined,
});
BaseBadgeTests({
render: renderBadge,
screen,
});
const setupBadge = (props: any = {}, options: SetupRenderOptions<any> = {}) =>
setup(props, { ...options, render: renderBadge, screen });
commonTestsSuiteVTL(setupBadge, {
baseClassName: CLASSNAME,
forwardClassName: 'div',
forwardAttributes: 'div',
forwardRef: 'div',
});
});
🛑 MANDATORY Validation Checkpoint 3 (Tests) - STOP HERE:
- Run
yarn test to ensure all tests pass
- Run
yarn type-check to verify TypeScript compilation
- Verify core tests use only plain data (no JSX)
- Verify framework-specific tests remain in React/Vue
- STOP AND USE AskUserQuestion tool to ask developer for validation:
- Present test results (number of tests passing)
- Ask: "Phase 3 complete. All tests migrated and passing. Should I proceed to Phase 4 (Update CHANGELOG and verify builds)?"
- Options: "Yes, proceed to finalization" / "No, fix issues first"
- DO NOT PROCEED to Phase 4 until developer selects "Yes, proceed to finalization"
- If developer selects "No", fix issues and ask again
Important Notes:
- Tests with framework-specific rendering behavior (e.g., empty children) should stay in framework test files
- Vue uses slots for children, so the render helper must convert
children prop to slots.default
- React renders empty for
null children, Vue renders comment nodes <!---->
- Vue tests should include
commonTestsSuiteVTL to match React's commonTestsSuiteRTL structure
- Core
setup() function should return aliases if needed (e.g., const div = badge;) for commonTestsSuite compatibility
Phase 4: Update Package Exports
Verify React package already exports component
Phase 5: Update CHANGELOG
IMPORTANT: Complete this phase ONCE for the entire component family after all components are migrated.
Add entry under [Unreleased]:
For single component:
### Added
- `@lumx/vue`:
- Create the `<Component>` component
### Changed
- `@lumx/core`:
- Moved `<Component>` from `@lumx/react`
For component family:
### Added
- `@lumx/vue`:
- Create the `<Component>` component family (`<Component>`, `<SubComponent1>`, `<SubComponent2>`, etc.)
### Changed
- `@lumx/core`:
- Moved `<Component>` component family from `@lumx/react` (`<Component>`, `<SubComponent1>`, `<SubComponent2>`, etc.)
Example for Table:
### Added
- `@lumx/vue`:
- Create the `Table` component family (`Table`, `TableBody`, `TableCell`, `TableHeader`, `TableRow`)
### Changed
- `@lumx/core`:
- Moved `Table` component family from `@lumx/react` (`Table`, `TableBody`, `TableCell`, `TableHeader`, `TableRow`)
Phase 6: Final Build Verification
IMPORTANT: After completing Phases 1-5 for ALL components in the family, perform final verification.
-
Build packages:
yarn build:core
yarn build:react
yarn build:vue
-
Final smoke test:
- Run full test suite:
yarn test
- Verify all builds succeed
- Check Storybook for any console errors
- Verify all components in the family work together correctly
-
Validate React/Vue parity:
-
CRITICAL: Check that all tests and stories are properly matched between React and Vue
-
For each component in the family:
Stories validation:
Tests validation:
-
Verify every .test.tsx file in React has a corresponding .test.ts file in Vue
-
Read test files and compare test structure:
- Core tests are imported and run in both React and Vue
- Framework-specific
describe blocks exist in both (React/Vue)
commonTestsSuiteRTL (React) has equivalent commonTestsSuiteVTL (Vue)
- React-specific tests have Vue-specific equivalents where appropriate
-
Check commonTestsSuite configurations match:
- Verify
baseClassName, forwardClassName, forwardAttributes, forwardRef are present
- Check if React has
applyTheme config - Vue should have it too
-
Example validation:
describe(`<${Component.displayName}>`, () => {
BaseComponentTests({ render, screen });
describe('React', () => {
});
commonTestsSuiteRTL(setup, {
});
});
describe('<Component />', () => {
BaseComponentTests({ render, screen });
describe('Vue', () => {
});
commonTestsSuiteVTL(setup, {
});
});
-
Fix any discrepancies found:
- Missing Vue stories → Create thin wrapper
.stories.tsx calling core setup() with Vue components
- Missing Vue tests → Add them following the React test structure
- Missing
applyTheme config → Add to Vue test
- Missing Vue-specific tests → Add equivalent tests for Vue behavior
-
Run tests again after fixes:
yarn test
Phase 7: Add Vue Documentation
IMPORTANT: After all components are migrated and verified, add Vue documentation to the site-demo.
Goal: Create Vue demo files and update the documentation MDX page to include both React and Vue frameworks.
-
Check for existing documentation:
- Verify that a documentation page exists at
packages/site-demo/content/product/components/<component>/index.mdx
- If no documentation exists, the vue-docs skill will report this and you should note it for later
-
Run the vue-docs skill:
-
Manual verification:
- After the vue-docs skill completes, verify:
- All Vue demo files were created
- MDX page includes
frameworks: ['react', 'vue']
- All DemoBlocks reference both frameworks
- PropTable includes both React and Vue docs
Note: This phase must be completed for every component migration. If documentation doesn't exist, create a task or issue to add it later.
Key Patterns to Follow
Props Type Inheritance Pattern
IMPORTANT: Use the new type utilities for cleaner and more maintainable props definitions.
Core Type Definition:
- Core defines
PropsToOverride = 'ref' | 'onClick' | 'onChange' | 'onKeyPress'
- These are generic JSX props that need framework-specific implementations
React Props Pattern:
import { ReactToJSX } from '@lumx/react/utils/type/ReactToJSX';
export interface ComponentProps extends GenericProps, ReactToJSX<UIProps, 'inputId' | 'label'> {
children?: React.ReactNode;
onChange?(param: Type): void;
}
Vue Props Pattern:
import { VueToJSXProps } from '../../utils/VueToJSX';
export type ComponentProps = VueToJSXProps<UIProps, 'inputId' | 'label'>;
Benefits:
- ✅ No need to manually list
ref, onClick, onChange, onKeyPress in every Omit
- ✅ Consistent pattern across all components
- ✅ Easier maintenance - update
PropsToOverride once to affect all components
- ❌ Old pattern (deprecated):
Omit<UIProps, 'children' | 'ref' | 'onClick' | 'onChange' | 'onKeyPress' | 'inputId' | 'label'>
Export Files (index.ts) - React and Vue
IMPORTANT: The index.ts files in React and Vue should export the same items in a similar structure, with only framework-specific differences.
React index.ts pattern:
export { Badge, type BadgeProps } from './Badge';
export { BadgeWrapper, type BadgeWrapperProps } from './BadgeWrapper';
export { CLASSNAME, COMPONENT_NAME, DEFAULT_PROPS } from '@lumx/core/js/components/Badge';
Vue index.ts pattern:
export { default as Badge, type BadgeProps } from './Badge';
export { default as BadgeWrapper, type BadgeWrapperProps } from './BadgeWrapper';
Key differences:
- Vue uses
default as ComponentName for component exports (because Vue component files use default export)
- React uses direct named exports (because React component files use named exports)
- Vue does NOT export
CLASSNAME, COMPONENT_NAME, or DEFAULT_PROPS — these are internal constants meant for core/React only
- Both export the same component types and props
Core Component Structure
import type { JSXElement, LumxClassName, HasTheme, HasClassName, CommonRef } from '../../types';
import { classNames } from '../../utils';
import { InputLabel } from '../InputLabel';
import { InputHelper } from '../InputHelper';
export interface ComponentProps extends HasTheme, HasClassName, HasAriaDisabled {
helper?: string;
inputId: string;
label?: JSXElement;
}
export const COMPONENT_NAME = 'Component';
export const CLASSNAME: LumxClassName<typeof COMPONENT_NAME> = 'lumx-component';
export const DEFAULT_PROPS: Partial<ComponentProps> = {};
export const Component = (props: ComponentProps) => {
const { label, inputId, helper, } = props;
return (
<div className={/* ... */}>
{/* Component structure */}
{label && InputLabel({ htmlFor: inputId, children: label })}
{helper && InputHelper({ id: `${inputId}-helper`, children: helper })}
</div>
);
};
React Wrapper Structure
import React from 'react';
import {
Component as UI,
ComponentProps as UIProps,
CLASSNAME,
COMPONENT_NAME,
} from '@lumx/core/js/components/Component';
import { useId, useTheme, useDisableStateProps, useMergeRefs } from '@lumx/react/...';
import { ReactToJSX } from '@lumx/react/utils/type/ReactToJSX';
export interface ComponentProps extends GenericProps, ReactToJSX<UIProps, 'inputId' | 'label'> {
children?: React.ReactNode;
}
export const Component = forwardRef<ComponentProps, HTMLDivElement>((props, ref) => {
const { isAnyDisabled, disabledStateProps, otherProps } = useDisableStateProps(props);
const defaultTheme = useTheme() || Theme.light;
const { children, id, inputRef } = otherProps;
const localInputRef = React.useRef<HTMLInputElement>(null);
const generatedInputId = useId();
const inputId = id || generatedInputId;
return UI({
ref,
label: children,
inputId,
inputRef: useMergeRefs(inputRef, localInputRef),
theme: defaultTheme,
isDisabled: isAnyDisabled,
inputProps: {
...inputProps,
...disabledStateProps,
readOnly: inputProps.readOnly || isAnyDisabled,
},
...otherProps,
});
});
Vue Wrapper Structure
import { computed, defineComponent, toRaw, useAttrs } from 'vue';
import {
Component as ComponentUI,
type ComponentProps as UIProps,
} from '@lumx/core/js/components/Component';
import { useClassName } from '../../composables/useClassName';
import { useTheme, useDisableStateProps, useId } from '../../composables/...';
import { keysOf, VueToJSXProps } from '../../utils/VueToJSX';
import { JSXElement } from '@lumx/core/js/types';
export type ComponentProps = VueToJSXProps<UIProps, 'inputId' | 'label'>;
export const emitSchema = {
change: () => ,
};
const Component = defineComponent(
(props: ComponentProps, { emit, slots }) => {
const attrs = useAttrs();
const className = useClassName(() => props.class);
const defaultTheme = useTheme();
const generatedInputId = useId();
const inputId = computed(() => props.id || generatedInputId);
const { isAnyDisabled, disabledStateProps, otherProps } = useDisableStateProps(
computed(() => ({ ...props, ...attrs })),
);
const handleChange = () => {
if (isAnyDisabled.value) return;
event.stopImmediatePropagation();
emit('change', );
};
return () => {
const { linkAs, ...rest } = otherProps.value;
return (
<ComponentUI
{...rest}
linkAs={toRaw(linkAs)}
className={className.value}
theme={props.theme || defaultTheme}
inputId={inputId.value}
isDisabled={isAnyDisabled.value}
onChange={handleChange}
label={(props.label || slots.default?.()) as JSXElement}
inputProps={{
...props.inputProps,
...disabledStateProps.value,
readOnly: isAnyDisabled.value,
}}
/>
);
};
},
{
name: 'LumxComponent',
inheritAttrs: false,
props: keysOf<ComponentProps>()(),
emits: emitSchema,
},
);
export default Component;
Common Pitfalls
- 🛑 NEVER skip validation checkpoints! - This is the #1 most critical pitfall!
- ✅ ALWAYS use AskUserQuestion tool at each checkpoint
- ✅ WAIT for user approval before proceeding to next phase
- ❌ NEVER continue to next phase without explicit user approval
- ❌ NEVER assume "the user will validate later" - validate NOW
- 🛑 ALWAYS update PropsToOverride when adding new callbacks to core!
- ✅ Check if core UI defines new callback props (e.g.,
onCustomEvent, onSpecialAction)
- ✅ If new callbacks exist, update
/packages/lumx-core/src/js/types/jsx/PropsToOverride.ts
- ✅ Add the new callback to the PropsToOverride type union (e.g.,
'ref' | 'onClick' | 'onChange' | 'onKeyPress' | 'onCustomEvent')
- ❌ NEVER leave callback props in core without adding them to PropsToOverride
- This ensures React and Vue can provide their own framework-specific implementations
- 🛑 NEVER put JSX in
args or withCombinations values — All JSX must live in render functions. JSX in args (including args.children, args.before, args.after, args.badge) causes errors in Vue storybook tests. Combination rows/sections/cols values are merged into args at runtime, so they have the same restriction.
- ✅
render: (args: any) => <Badge {...args}><Icon icon={mdiHeart} /></Badge>
- ✅
args: { color: 'red', isDisabled: true } (serializable data only)
- ❌
args: { children: <Icon icon={mdiHeart} /> } (JSX in args)
- ❌
rows: { 'With icon': { children: <Icon icon={mdiHeart} /> } } (JSX in combinations)
- 🛑 Define stories as individual
const variables — This enables cross-referencing between stories (e.g., reusing a render function, composing multiple renders). Return a flat object: return { meta, StoryA, StoryB, ... }.
- ✅
const WithIcon = { render: (args) => ... }; const AllTypography = { render: WithIcon.render, ... };
- ❌ Inline objects in the return statement that can't reference each other
- 🛑 Vue stories MUST provide a
render override when the Vue component uses slots AND the core meta.render passes content via args — When each core story defines its own render that directly renders JSX children (the new default pattern), Vue usually doesn't need a render override. But when the core meta.render receives slot-like content via args (e.g., label, before, after), the Vue stories must map props to slots.
- ✅ Default slot:
render: ({ label, ...args }: any) => <Flag {...args}>{label}</Flag>
- ✅ Named slots:
render: ({ label, before, after, ...args }: any) => (<Toolbar {...args}>{{ default: label ? () => label : undefined, before: before ? () => before : undefined }}</Toolbar>)
- ❌ No render override when Vue component uses slots and core render passes content via args
- How to check: Read the Vue component's
.tsx file. If it accesses slots (e.g., slots.default?.(), slots.before?.()) for any of the content props used in the core stories, a render override may be required.
- Core stories USE JSX in render functions — but only with injected components from the
components parameter. Never import from @lumx/react or @lumx/vue in core stories.
- ✅ Core stories:
render: (args) => <Badge {...args}><Icon icon={mdiHeart} /></Badge> (JSX in render, using Icon from components param)
- ❌ Core stories:
args: { children: <Icon icon={mdiHeart} /> } (JSX in args)
- ❌ Core stories:
import { Icon } from '@lumx/react' (hardcoded framework import)
- Core tests still use plain data only (no JSX) — the JSX approach applies to stories only
- ✅ Core tests:
children: '30', use SetupOptions pattern with default export
- ❌ Core tests:
children: <Icon /> or JSX render functions
- DO NOT add NOTE comments or explanatory comments - Don't add meta-commentary like "NOTE: X is not migrated because..." or "This test is framework-specific". Keep generated code clean.
- Don't add/remove stories - Migrate existing stories only, keep the same set of stories
- 🛑 ALWAYS destructure
children out of args in story render functions — When a render function provides its own inline JSX children, it must destructure children from the args to prevent the inherited children value (from meta.args) from leaking via {...args} onto the component. In Vue, spreading children as a prop on a DOM element causes "Failed setting prop children" warnings.
- ✅
render: ({ children, ...args }: any) => <Link {...args}>Link <Icon icon={mdiEarth} /> with icon</Link>
- ❌
render: (args: any) => <Link {...args}>Link <Icon icon={mdiEarth} /> with icon</Link> (children from meta.args leaks as a DOM prop)
- Don't use
Children.count() in core - This is React-specific
- Always use functional calls in core UI -
InputLabel({ ... }) not <InputLabel ... />
- Add stopImmediatePropagation - Prevent event bubbling in Vue wrapper (when handling events)
- Use JSX in Vue wrapper -
return (<Component />) not function calls
- Set correct component name - Vue:
'LumxComponent', not 'Component'
- Use
.tsx extension for ALL story files — core, React, and Vue stories all use JSX (Vue stories need .tsx when they provide a render override with JSX)
- No
.vue template files for stories — all story rendering is handled by JSX in core. Do NOT create Stories/*.vue files or use withRender.
- Vue tests must mimic React tests - Include the same structure with
commonTestsSuiteVTL
- Vue
index.ts should export components and types only - Do NOT export CLASSNAME, COMPONENT_NAME, or DEFAULT_PROPS from Vue index files. Only export components (using default as syntax) and types (props, enums)
- Always check for existing core implementation first - Before creating UI/Stories/Tests in core, verify they don't already exist. If they do, reuse them and only make changes after user approval
- Use ReactToJSX and VueToJSXProps type utilities - Don't manually list all props to omit; use the new type utilities that automatically handle PropsToOverride
- 🛑 Always use
useClassName composable for merging class prop with className attr — Core JSX components pass className (React convention) when rendering Vue sub-components. Since className is not a declared Vue prop, it lands in $attrs. Use useClassName(() => props.class) to merge both sources with classNames.join(). Always pass a getter (() => props.class), not props.class directly — the plain string loses reactivity. useClassName is built on useAttrFallback — a generic composable that falls back to any $attrs value when a Vue prop is absent (e.g., tabIndex from core lands as attrs.tabindex in Vue). Use useAttrFallback directly for other React-named attrs besides className.
- ✅
const className = useClassName(() => props.class); then className={className.value}
- ✅
const tabIndex = useAttrFallback(() => attrs.tabindex, 'tabIndex'); (for non-className attrs)
- ❌
className={props.class} (ignores attrs.className from core parent components)
- ❌
const className = useClassName(props.class); (loses reactivity — captures stale value at setup time)
- 🛑 Use
toRaw() on props that accept Vue component references — Props like linkAs accept a Vue component object (e.g., a custom RouterLink). When this component object flows through Vue's reactivity system (props → computed → spread in useDisableStateProps), it becomes a reactive proxy. Passing a reactive component to JSX causes: [Vue warn]: Vue received a Component that was made a reactive object. Use toRaw() to unwrap reactivity before passing to the core UI component.
- ✅
const { linkAs, ...rest } = otherProps.value; <CoreUI {...rest} linkAs={toRaw(linkAs)} />
- ❌
<CoreUI {...otherProps.value} /> (linkAs is still a reactive proxy)
- When does this apply? Only for props whose value can be a component reference (function or object), like
linkAs. Props like as on Text/Heading/FlexBox only accept string tag names ('span', 'p', 'h1', 'div'), which are primitives immune to reactivity — no toRaw() needed for those.
- Affected components:
Link, Button, IconButton, Thumbnail — any component with a linkAs prop.
- Import:
import { toRaw } from 'vue';
Single Component vs Component Family
Single Component (e.g., Switch, Divider)
- One component file in the folder
- Migration is straightforward: Phases 0-6 sequentially
- CHANGELOG entry lists one component
Component Family (e.g., Table, Badge)
- Multiple component files in the same folder
- Phase 0 is critical: Must analyze dependencies and determine order
- Phases 1-4 repeat per component in the migration order
- Phases 5-6 run once for the entire family
- CHANGELOG entry lists all components in the family
- Common patterns:
- Parent defines
constants.ts → migrate parent first
- Sub-components import from constants → migrate after parent
- Sub-components are independent → can migrate in any order
Reference Components
Single Components
- Checkbox: Full implementation with intermediate state
- Switch: Recently migrated, good reference for binary components
- Divider: Simple component without complex dependencies
- Link: Good reference for stories with
const pattern, render reuse across stories, and withCombinations
- Text: Good reference for sharing
render across stories (WithIcon.render reused by AllTypography and AllColors)
Component Families
- Badge: Best reference for the new stories pattern —
const variables, no JSX in args, story render composition (AllColors composes WithText.render, WithIcon.render, WithThumbnail.render), and FlexBox for layout
- Toolbar: Good reference for components with slot-like props — each story self-contains its JSX, Vue side provides
render override for slot mapping
- Table (not yet migrated): Parent
Table + sub-components TableBody, TableCell, TableHeader, TableRow
- Button: Good example of event handling with stopImmediatePropagation
Files Created/Modified Checklist
IMPORTANT: This checklist applies to EACH component in the family. Complete all phases for one component before moving to the next (following the migration order from Phase 0).
Note: For sub-components (e.g., BadgeWrapper alongside Badge, or TableRow alongside Table), replace <Component> with the parent folder name (e.g., Badge, Table), and use <SubComponent>.tsx instead of index.tsx in core.
Note: For parent components with constants.ts, create the constants file in core during the first component migration.
Phase 1: UI Extraction & Implementation (Per Component)
Phase 2: Stories Migration
Step 1: Core Stories
Step 2: React Stories
Step 3: Vue Stories
Phase 3: Tests Migration
Phase 4-6: Finalization
Phase 7: Vue Documentation
Migration Workflow for Component Families
When migrating a component family (e.g., Table, TableRow, TableCell, etc.):
- Complete Phase 0 for the entire family
- For each component in migration order:
- Complete Phase 1 (UI Extraction) → Checkpoint
- Complete Phase 2 (Stories) → Checkpoint
- Complete Phase 3 (Tests) → Checkpoint
- Complete Phase 4 (Package Exports)
- After all components are migrated:
- Complete Phase 5 (CHANGELOG) once for the entire family
- Complete Phase 6 (Final Build Verification)
- Complete Phase 7 (Vue Documentation)
Example Timeline for Table Family:
Phase 0: Discovery & Planning (all components)
├─ Component 1: Table
│ ├─ Phase 1: UI Extraction → ✓
│ ├─ Phase 2: Stories → ✓
│ ├─ Phase 3: Tests → ✓
│ └─ Phase 4: Package Exports → ✓
├─ Component 2: TableBody
│ ├─ Phase 1: UI Extraction → ✓
│ ├─ Phase 2: Stories → ✓
│ ├─ Phase 3: Tests → ✓
│ └─ Phase 4: Package Exports → ✓
├─ ... (repeat for TableCell, TableHeader, TableRow)
├─ Phase 5: CHANGELOG (entire family)
└─ Phase 6: Final Build Verification (entire family)
Success Criteria
After Phase 0 (Discovery & Planning)
After Phase 1 (UI Extraction - Per Component)
After Phase 2 (Stories)
After Step 1 (Core Stories):
After Step 2 (React Stories - Checkpoint 2a):
After Step 3 (Vue Stories - Checkpoint 2b):
After Phase 3 (Tests)
Final Success Criteria