ComposeKit Docs
Contributing

Creating a Plugin

A ComposeKit plugin is a self-contained package under packages/@bplcp/<name>/ that exports a React component and a registration function. This guide walks through creating one end-to-end — from the package skeleton to wiring it into the playground and gallery.

Tip — Use the /create-plugin Claude command to scaffold everything automatically. Pass name [Title] [description] [group] [version] as arguments and it will generate all files, wire up all configs, and run the checklist for you.

Step 0 — Derive names & check shadcn

From the kebab-case name (e.g. date-picker) derive the following identifiers used throughout:

  • Title — PascalCase, e.g. DatePicker
  • ComponentName — ComposeKit{Title}, e.g. ComposeKitDatePicker
  • camel — camelCase, e.g. datePicker
  • version — read from packages/@bplcp/composekit/package.json

Check whether a shadcn primitive exists at packages/@bplcp/ds/shadcn/components/ui/<name>.tsx. If it does, copy it (and utils.ts) to src/<name>/shadcn/ and never edit those copies. Otherwise, build with plain Tailwind classes.

Also scan 2–3 existing similar plugins for patterns before writing any code:packages/@bplcp/tabs/src/ (structural), packages/@bplcp/stepper/src/ (no shadcn),packages/@bplcp/input/src/ (shadcn-wrapped form).

Step 1 — Create the package

Create packages/@bplcp/<name>/ with this structure:

directory structure
packages/@bplcp/<name>/
├── src/
│   ├── index.ts
│   └── <name>/
│       ├── <name>.component.tsx    ← main React component
│       ├── <name>.plugin.ts        ← plugin registration
│       ├── <name>.types.ts         ← ISomething interface + prop types
│       └── shadcn/                 ← (only if shadcn primitive exists)
├── package.json
├── tsconfig.json
├── webpack.config.js
├── babel.config.js
├── postcss.config.js
├── tailwind.config.js
├── schema.json
└── docs.md
package.json / tsconfig.json / babel.config.js / postcss.config.js

Copy verbatim from packages/@bplcp/stepper/ — only change name and version in package.json.

package.json
// packages/@bplcp/<name>/package.json
// Copy from packages/@bplcp/stepper/package.json — same scripts, same devDependencies.
// Only change: name and version.
{
  "name": "@bplcp/<name>",
  "version": "<version>"
}
tailwind.config.js
tailwind.config.js
// tailwind.config.js
module.exports = {
  content: ['./src/**/*.{ts,tsx}'],
  corePlugins: { preflight: false },   // ← always disable preflight in component packages
  theme: { extend: {} },
  plugins: []
};
src/index.ts
src/index.ts
// src/index.ts
export { ComposeKit<Title> } from './<name>/<name>.component';
export { <name>Plugin }      from './<name>/<name>.plugin';
Types
src/<name>/<name>.types.ts
// src/<name>/<name>.types.ts
import type { CSSProperties } from 'react';

export interface IWorkflow {
    workflowRef: string;
    initialVars?: { key: string; value: string }[];
}

export interface I<Title> {
    // component-specific props here
    className?: string;
    style?: CSSProperties;
    events?: {
        onChange?: { workflows?: IWorkflow[] };
    };
    onChange?: (value: any) => void;
    onMount?: (el: HTMLElement) => void;
    onUnmount?: () => void;
}
Component
src/<name>/<name>.component.tsx
// src/<name>/<name>.component.tsx
import React, { useEffect, useRef } from 'react';
import { workflowService } from '@bplcp/core-services';
import type { I<Title> } from './<name>.types';

export const ComposeKit<Title> = React.memo(
  React.forwardRef<HTMLDivElement, I<Title>>(({
    className = '',
    style = {},
    events,
    onChange,
    onMount,
    onUnmount,
  }, forwardedRef) => {
    const innerRef    = useRef<HTMLDivElement>(null);
    const resolvedRef = (forwardedRef as React.RefObject<HTMLDivElement>) ?? innerRef;

    useEffect(() => {
      if (resolvedRef.current) onMount?.(resolvedRef.current);
      return () => { onUnmount?.(); };
    }, []);

    const handleChange = (value: any) => {
      onChange?.(value);
      const workflows = events?.onChange?.workflows;
      workflows?.length && workflowService.execute(workflows);
    };

    return (
      <div ref={resolvedRef} className={className} style={style}>
        {/* component JSX */}
      </div>
    );
  })
);

export default ComposeKit<Title>;
Plugin registration
src/<name>/<name>.plugin.ts
// src/<name>/<name>.plugin.ts
import { CoreServices } from '@bplcp/core-services';

export function <name>Plugin(services: CoreServices) {
  services.pluginRegistry.register({
    name: '@bplcp/<name>',
    component: () => import('./<name>.component').then(m => m.ComposeKit<Title>),
    schema: () => import('../../schema.json'),
  });
}
Shadcn mode (when primitive exists)
src/<name>/shadcn/utils.ts
// If a shadcn primitive exists at:
//   packages/@bplcp/ds/shadcn/components/ui/<name>.tsx
//
// Copy it (and utils.ts) to:
//   src/<name>/shadcn/<name>.tsx
//   src/<name>/shadcn/utils.ts        ← use the @bplcp/core-services version
//
// utils.ts always looks like this:
import { utilsService } from '@bplcp/core-services';

export function cn(...inputs: any[]) {
  return utilsService.twMerge(inputs);
}

// Never edit files inside shadcn/ — they are copies of the upstream shadcn component.
Color rules

Always use CSS custom properties via inline styles. Tailwind classes are fine for layout and spacing only.

component.tsx
// ✅ Correct — use CSS custom properties directly
<div style={{ color: 'var(--primary)', background: 'var(--muted)' }}>

// ❌ Wrong — do not use hsl() wrapper (project uses oklch Tailwind v4 variables)
<div style={{ color: 'hsl(var(--primary))' }}>

// ❌ Wrong — do not use Tailwind color classes for component colors
<div className="text-primary bg-muted">
schema.json

Generate a complete JSON Schema for every prop. The "default" object must be a concrete example that renders without errors when the component is dropped onto the canvas. Omit onMount, onUnmount, and style from props — they are internal or too complex for the schema editor.

schema.json
// schema.json — minimal example
{
  "$schema": "http://json-schema.org",
  "title": "<Title> Configuration",
  "description": "<description>",
  "type": "object",
  "default": {
    // ← concrete example — must render without errors on drag-and-drop
  },
  "required": [],
  "properties": {
    "className": { "title": "CSS Class", "type": "string" },
    "style": {
      "title": "Custom Styles",
      "type": "object",
      "additionalProperties": { "type": "string" }
    }
    // ... component-specific props
  }
}

Step 2 — Wire into composekit package

packages/@bplcp/composekit/src/plugins/<name>.ts
// packages/@bplcp/composekit/src/plugins/<name>.ts
export { <name>Plugin as default } from '@bplcp/<name>';
packages/@bplcp/composekit/webpack.config.js
// packages/@bplcp/composekit/webpack.config.js

// 1. Add to PLUGIN_ENTRIES array:
{ subpath: '<name>', entry: './src/plugins/<name>.ts' },

// 2. Add to alias block:
'@bplcp/<name>': path.resolve(__dirname, '../<name>/src/index.ts'),
packages/@bplcp/composekit/package.json
// packages/@bplcp/composekit/package.json

// Add to "exports":
"./<name>": {
  "types": "./dist/types/plugins/<name>.d.ts",
  "default": "./dist/plugins/<name>.js"
}

// Add to "typesVersions["*"]":
"<name>": ["dist/types/plugins/<name>.d.ts"]
packages/@bplcp/composekit/tsconfig.json
// packages/@bplcp/composekit/tsconfig.json — add to "paths":
"@bplcp/<name>": ["../<name>/src/index.ts"]

Step 3 — Wire into portal (vite.config.ts)

portal/composekit/vite.config.ts
// portal/composekit/vite.config.ts

// Add to resolve.alias:
'@brainpiper/composekit/<name>': path.resolve(pkgs, 'composekit/src/plugins/<name>.ts'),
'@bplcp/<name>':                 path.resolve(pkgs, '<name>/src/index.ts'),

// Add to optimizeDeps.exclude:
'@brainpiper/composekit/<name>',
Playground scope

Add the component to playground.scope.ts so the AI playground can use it in generated code:

portal/composekit/src/playground/playground.scope.ts
// portal/composekit/src/playground/playground.scope.ts
import { ComposeKit<Title> } from '@brainpiper/composekit/<name>';

export const PLAYGROUND_SCOPE = {
    // ... existing components
    ComposeKit<Title>,
};

Step 4 — Docs portal

Add a lazy import and entry to ComponentsSection.tsx, a changelog entry to OverviewSection.tsx, and update the component count and group tags in App.tsx. See existing entries (e.g. stepper) as a reference for placement.

Step 5 — Gallery widget

Create an interactive widget under portal/composekit/src/gallery/widgets/<Title>Widget.tsxand register it as a lazy import in IndividualComponents.tsx. The widget should show 2–3 variants with real, meaningful example data — not lorem ipsum.

gallery/widgets/<Title>Widget.tsx
// portal/composekit/src/gallery/widgets/<Title>Widget.tsx
import React from 'react';
import { ComposeKit<Title> } from '@brainpiper/composekit/<name>';

export default function <Title>Widget() {
  return (
    <div className="widget-card">
      <div className="widget-card-header">
        <h3><Title></h3>
        <p>Short description of the component.</p>
      </div>
      <div className="widget-card-body">
        {/* Show 2–3 variants with real, meaningful data */}
        <ComposeKit<Title> {/* props */} />
      </div>
    </div>
  );
}

Final checklist

  • No test artifacts left anywhere
  • No duplicate lines inserted in modified files
  • schema.json has a "default" object that renders on canvas drag-and-drop
  • Component uses var(--primary) not hsl(var(--primary)) for colors
  • Shadcn files (if any) are in src/<name>/shadcn/ and are not edited
  • All config files updated: composekit webpack, package.json, tsconfig, vite.config
  • docs.md exists with a props table and 2–3 usage examples