npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

@gravityai-dev/design-system

v0.2.2

Published

AI-powered design system with server-driven components

Readme

Gravity Design System

Build React components in Storybook → Auto-generate workflow nodes + layout templates

Two Types of Components:

  1. Components (/storybook/components/) - AI-streamable components → workflow nodes
  2. Templates (/storybook/templates/) - Layout containers → render conversation history

📋 Table of Contents

Getting Started

Components (Workflow Nodes)

Templates (Layout Containers)

Reference

Examples


Quick Start

Prerequisites

  • Node.js 18+
  • React knowledge
  • TypeScript (optional but recommended)

Install & Setup

cd /path/to/design-system
npm install
npm run storybook  # Preview components

Your First Component (5 Minutes)

Step 1: Create component files

mkdir -p storybook/components/MyCard
cd storybook/components/MyCard

Step 2: Create MyCard.tsx

import React from "react";

export interface MyCardProps {
  title: string;
  description?: string;
}

export default function MyCard({ title, description }: MyCardProps) {
  return (
    <div className="w-full bg-white dark:bg-zinc-900 rounded-xl shadow-lg p-6">
      <h3 className="text-xl font-semibold text-gray-900 dark:text-white">{title}</h3>
      {description && <p className="text-gray-600 dark:text-gray-400 mt-2">{description}</p>}
    </div>
  );
}

Step 3: Create MyCard.stories.tsx

import type { Meta, StoryObj } from "@storybook/react";
import MyCard from "./MyCard";

const meta: Meta<typeof MyCard> = {
  title: "Components/MyCard",
  component: MyCard,
  argTypes: {
    title: {
      control: "text",
      description: "Card title",
    },
    description: {
      control: "text",
      description: "Card description",
    },
  },
};

export default meta;
type Story = StoryObj<typeof MyCard>;

export const Default: Story = {
  args: {
    title: "Hello World",
    description: "This is my first component!",
  },
};

Step 4: Create defaults.ts

export const MyCardDefaults = {
  title: "Hello World",
  description: "This is my first component!",
};

Step 5: Generate workflow node

npm run generate-nodes

Done! Your component is now:

  • Available in Storybook at http://localhost:6006
  • Generated as a workflow node in /src/MyCard/
  • Bundled to /dist/components/MyCard.js
  • Ready to use in workflows

Key Features

  • React + Tailwind v4 - Full React features, utility-first CSS with Shadow DOM isolation
  • Auto-generated nodes - Storybook argTypes → workflow node inputs
  • Dynamic loading - Components served via URL (8KB average bundle size)
  • Design tokens - Modular CSS in /storybook/ingestion/styles/ (colors, typography)
  • Shadow DOM - Complete CSS isolation, no conflicts with client apps

How It Works

Build: Vite bundles React component → dist/components/Card.js (CSS included)
Runtime: Server sends { componentUrl: "/components/Card.js", props: {...} }
Client: Loads component via <script>, renders with props

Benefits: 10x smaller payload, browser caching, full React features, auto-styled

Component Structure

storybook/
├── components/          # AI-streamable components (GENERATED as workflow nodes)
│   ├── Card/
│   │   ├── Card.tsx
│   │   ├── Card.stories.tsx
│   │   └── defaults.ts
│   ├── BookingWidget/
│   ├── AIResponse/
│   └── KenBurnsImage/
│
├── templates/           # Layout templates (NOT generated, used by client app)
│   ├── ChatLayout/
│   │   ├── ChatLayout.tsx
│   │   └── ChatLayout.stories.tsx
│   ├── BookingWidgetLayout/
│   └── KeyService/
│
├── ingestion/           # Component generation system
└── dist/                # Generated bundles
    └── components/      # Bundled JS files served to clients

Components vs Templates

| Aspect | Components | Templates | | ----------------------- | ------------------------------- | ----------------------- | | Location | /storybook/components/ | /storybook/templates/ | | Purpose | AI-streamable UI elements | Layout containers | | Generated as nodes? | ✅ Yes | ❌ No | | Sent by AI? | ✅ Yes | ❌ No | | Loaded by client? | ✅ Yes (dynamically) | ✅ Yes (on init) | | Examples | Card, BookingWidget, AIResponse | ChatLayout, Dashboard |

/components/ - AI-Streamable Components

Purpose: Components that can be dynamically streamed from AI workflows to clients.

Required Files:

  • ComponentName.tsx - React component
  • ComponentName.stories.tsx - Storybook stories with argTypes
  • defaults.ts - Default values for preview

Characteristics:

  • ✅ Generated as workflow nodes (via npm run generate-nodes)
  • ✅ Bundled to /dist/components/ for WebSocket delivery
  • ✅ Self-contained (includes all dependencies, CSS, logic)
  • ✅ Can be mounted/unmounted by AI
  • ✅ Accept props from AI workflow

Examples: Card, BookingWidget, StreamingText, KenBurnsImage

/templates/ - Layout Templates

Purpose: Layout containers that render conversation history and manage template-specific UI state.

Required Files:

  • TemplateName.tsx - React component
  • TemplateName.stories.tsx - Storybook stories
  • README.md - Template documentation (optional)

Characteristics:

  • ❌ NOT generated as workflow nodes (AI doesn't send these)
  • ✅ Bundled to /dist/components/ (loaded by client like components)
  • ✅ Used by client app to render conversation history
  • ✅ Manage template-specific state (scroll position, filters, UI state)
  • ✅ Receive universal history from HistoryManager
  • ✅ Render components from history
  • Loaded automatically from InputTrigger config (no workflow execution needed)

Examples: ChatLayout, BookingWidgetLayout, DashboardLayout, KeyService

Styling: Use Tailwind v4 classes directly - Shadow DOM provides CSS isolation

How Templates Load

Templates are specified in the InputTrigger node configuration and load before workflow execution:

1. Configure in InputTrigger:

{
  template: "BookingWidgetLayout"; // Specify template in node config
}

2. Client connects:

// Client sends workflowId + targetTriggerNode
const sessionParams = {
  workflowId: "wf-htmn4a",
  targetTriggerNode: "inputtrigger9",
};

3. Server loads template:

  • Reads InputTrigger config from WorkflowCache (fast!)
  • Sends COMPONENT_INIT with template
  • Template loads instantly (no workflow execution)

4. User interacts:

  • Template is already loaded
  • User sends message → workflow executes
  • Results stream to already-loaded template

Performance: Templates load in <10ms using WorkflowCache (memory/Redis).

📖 For detailed template documentation, see /storybook/templates/README.md

Example Component

// Card.tsx
import React, { useState } from "react";

export default function Card({ title, description, cta }) {
  const [isExpanded, setIsExpanded] = useState(false);

  return (
    <div className="bg-white rounded-lg shadow-lg p-6">
      <h3 className="text-2xl font-bold">{title}</h3>
      <p className="text-gray-700 mt-2">{description}</p>
      <button
        className="bg-primary-500 text-white px-6 py-3 rounded-lg mt-4"
        onClick={() => setIsExpanded(!isExpanded)}
      >
        {cta}
      </button>
    </div>
  );
}
// Card.stories.tsx
export default {
  title: "Components/Card",
  component: Card,
  argTypes: {
    title: { control: "text", description: "Card title" },
    description: { control: "text", description: "Card description" },
    cta: { control: "text", description: "Button text" },
  },
};

export const Default = {
  args: { title: "Hello", description: "World", cta: "Click me" },
};

✅ ALL React Features Supported

1. All Hooks

// ✅ ALL WORK!
const [count, setCount] = useState(0);
const memoized = useMemo(() => expensive(), [deps]);
const callback = useCallback(() => handler(), []);
const [state, dispatch] = useReducer(reducer, init);
const ref = useRef(null);

2. Custom Hooks

// ✅ WORKS!
const { data, loading } = useCustomHook();
const user = useAuth();
const theme = useTheme();

3. Third-Party Libraries

// ✅ WORKS! (as long as they're installed in design-system package)
import { DatePicker } from "react-datepicker";
import { Button } from "@mui/material";

4. React Context

// ✅ WORKS!
const ThemeContext = createContext();
const theme = useContext(ThemeContext);

5. All React Patterns

// ✅ Everything works - it's just React!
{
  isVisible && <div>Content</div>;
}
{
  items.map((item) => <Item key={item.id} {...item} />);
}
<Suspense fallback={<Loading />}>
  <LazyComponent />
</Suspense>;

2. Styling with Tailwind v4 CSS

All components use Tailwind v4 with Shadow DOM isolation (no prefix needed):

// Use standard Tailwind classes - Shadow DOM prevents conflicts
<div className="w-full h-full min-w-[320px]">
  <h1 className="text-4xl font-bold text-primary-500">Title</h1>
  <p className="text-gray-700 mt-4">Description text</p>
</div>

Design Tokens Available:

  • Colors: text-primary-500, bg-secondary-600 (defined in /storybook/ingestion/styles/colors.css)
  • Typography: font-sans, font-display, text-2xl (defined in /storybook/ingestion/styles/typography.css)
  • Spacing: p-6, m-4, space-y-4
  • Filters: blur-[6px], brightness-110
  • Animations: Define inline with @keyframes in your component

Shadow DOM Isolation:

  • CSS is injected into Shadow DOM, not main document
  • No CSS conflicts with client applications
  • Components are completely self-contained

Custom Animations: Define animations inline in your component using <style> tags:

export default function AnimatedComponent() {
  return (
    <div className="relative w-full h-full">
      <style>{`
        @keyframes fadeIn {
          from { opacity: 0; }
          to { opacity: 1; }
        }
      `}</style>

      <div style={{ animation: "fadeIn 1s ease-out" }}>Content</div>
    </div>
  );
}

This ensures animations are bundled with the component and work in Shadow DOM.

3. Define ArgTypes in Stories (Required)

IMPORTANT: Separate Workflow Inputs from Template Props

Components have two types of props:

  • Workflow Inputs (workflowInput: true) - Data from AI/workflow (e.g., title, description)
  • Template Props (workflowInput: false) - Callbacks/config wired by template (e.g., onClick, editable)

Only props marked with workflowInput: true become workflow node inputs.

// YourComponent.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { YourComponent } from "./YourComponent";

const meta: Meta<typeof YourComponent> = {
  title: "Components/YourComponent",
  component: YourComponent,
  parameters: {
    layout: "centered",
  },
  argTypes: {
    // ✅ Workflow inputs - data from AI
    title: {
      control: "text",
      description: "Component title",
      workflowInput: true, // ← Becomes workflow node input
    },
    description: {
      control: "text",
      description: "Component description",
      workflowInput: true, // ← Becomes workflow node input
    },
    size: {
      control: "select",
      options: ["small", "medium", "large"],
      description: "Component size",
      workflowInput: true, // ← Becomes workflow node input
    },

    // ❌ Template props - NOT workflow inputs
    editable: {
      control: "boolean",
      description: "Whether component is editable",
      workflowInput: false, // ← Template wires this
    },
    onClick: {
      action: "clicked",
      workflowInput: false, // ← Template wires this callback
    },
  },
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
  args: YourComponentDefaults,
};

Rule: If workflowInput is not specified or set to false, the prop will NOT become a workflow input.

4. Create defaults.ts File (Required)

All components must have a defaults.ts file that exports default values for Storybook demos and workflow editor preview.

IMPORTANT: The export must be imported from './defaults' in your stories file for the generator to find it.

Pattern 1: Flat Props (Simple Components)

// YourComponent/defaults.ts
export const YourComponentDefaults = {
  title: "Default Title",
  description: "Default description text",
  size: "medium",
  enabled: true,
};
// YourComponent.stories.tsx
import { YourComponentDefaults } from "./defaults"; // ← Must import from './defaults'

export const Default: Story = {
  args: YourComponentDefaults, // ← Direct reference
};

Pattern 2: Nested Object Props (Complex Components)

// BookingWidget/defaults.ts
export const defaultBookingData: BookingData = {
  service: "Sports Injury Assessment",
  therapist: "Dr. Sarah Mitchell",
  date: "2025-10-28",
  // ... all booking fields
};
// BookingWidget.stories.tsx
import { defaultBookingData } from "./defaults"; // ← Must import from './defaults'

export const Default: Story = {
  args: {
    bookingData: defaultBookingData, // ← Nested under prop name
    editable: true,
  },
};

The generator will extract defaults for all props marked with workflowInput: true.

How Defaults Work:

  1. Storybook - Uses defaults for demo/preview
  2. Workflow Editor - Shows defaults in component preview when node config is empty
  3. Published to Client - Defaults are NOT sent, only explicitly set config values

Example Flow:

1. User drags AIResponse node onto workflow
   → Component preview shows Star Wars defaults from defaults.ts

2. User configures node with actual values
   → Preview updates to show user's values

3. Workflow executes and publishes component
   → Only user's values sent to client (no defaults)

Why This Pattern?

  • ✅ Components look good in Storybook demos
  • ✅ Components look good in workflow editor preview
  • ✅ No unwanted defaults published to production
  • ✅ Standardized across all components

5. Supported Control Types (Auto-Generated Schema)

The generator automatically converts Storybook controls to workflow node inputs:

| Storybook Control | Workflow Input | Generated Schema | Example | | -------------------- | -------------------------------- | ---------------------------------------------- | ------------------------------------------------------------ | | control: 'text' | Text input with template support | type: "string", ui:field: "template" | { control: 'text' } | | control: 'boolean' | Toggle switch | type: "boolean", ui:widget: "toggle" | { control: 'boolean' } | | control: 'number' | Number input | type: "number" | { control: 'number' } | | control: 'range' | Number input with min/max/step | type: "number", minimum, maximum, step | { control: { type: 'range', min: 1, max: 10, step: 0.1 } } | | control: 'select' | Dropdown with enum values | type: "string", enum, enumNames | { control: 'select', options: ['a', 'b'] } | | control: 'object' | JSON editor | type: "object", ui:field: "JSON" | { control: 'object' } |

Select Control Example:

argTypes: {
  direction: {
    control: 'select',
    options: ['left', 'right', 'center'],
    description: 'Alignment direction',
  },
}

This generates a dropdown in the workflow with options: "Left", "Right", "Center"

6. Template Systems - Handlebars vs JavaScript

Two template systems are available based on the control type:

Handlebars Templates (control: 'text')

For string fields, use Handlebars syntax to reference workflow data:

argTypes: {
  progressText: {
    control: 'text',  // ← Handlebars template
    description: 'Progress/thinking message',
  },
}

Syntax:

{{signal.openai26.text}}
{{input.userName}}
{{workflow.variables.userId}}

Generated Schema:

{
  type: "string",
  "ui:field": "template"
}

Use Cases:

  • Simple text interpolation
  • Accessing single values from upstream nodes
  • String concatenation

JavaScript Templates (control: 'object')

For object/array fields, use JavaScript return statements:

argTypes: {
  text: {
    control: 'object',  // ← JavaScript template
    description: 'Main response text',
  },
  questions: {
    control: 'object',  // ← JavaScript template
    description: 'Follow-up questions (array of strings)',
  },
}

Syntax:

return signal.openaistream25.chunk;
return signal.bedrockclaude25.output.questions;
return [signal.question1.text, signal.question2.text, signal.question3.text];

Generated Schema:

{
  type: "object",
  "ui:field": "template"
}

Use Cases:

  • Accessing nested object properties
  • Returning arrays
  • Complex data transformations
  • Conditional logic

Choosing the Right Template System

| Field Type | Control Type | Template System | Example | | --------------- | ------------------- | --------------- | ------------------------------------------- | | Simple text | control: 'text' | Handlebars | {{signal.node.text}} | | Object/Array | control: 'object' | JavaScript | return signal.node.chunk | | Nested data | control: 'object' | JavaScript | return signal.node.output.questions | | Computed values | control: 'object' | JavaScript | return signal.items.filter(x => x.active) |

Real Example: AIResponse Component

// AIResponse.stories.tsx
argTypes: {
  progressText: {
    control: 'text',  // Handlebars
    description: 'Progress/thinking message',
  },
  text: {
    control: 'object',  // JavaScript
    description: 'Main response text',
  },
  questions: {
    control: 'object',  // JavaScript
    description: 'Follow-up questions (array of strings)',
  },
}

In Workflow Configuration:

// progressText (Handlebars)
{
  {
    signal.openai26.text;
  }
}

// text (JavaScript)
return signal.openaistream25.chunk;

// questions (JavaScript)
return signal.bedrockclaude25.output.questions;

Key Difference:

  • control: 'text' → Handlebars {{...}} → Returns string
  • control: 'object' → JavaScript return ... → Returns object/array

Best Practices

Component Patterns & Default Data

All components must show meaningful content in the workflow editor preview. When a component is added to a workflow, it renders with empty/default config before the user fills in values.

Pattern 1: Static Components (Card, Image, Button)

Components with required props that always have data to show.

Example:

interface CardProps {
  title: string; // Required - always has data
  description?: string; // Optional enhancement
  imageUrl?: string; // Optional enhancement
  cta?: string; // Optional enhancement
}

export default function Card({ title, description, imageUrl, cta }: CardProps) {
  return (
    <div className="card">
      <h3>{title}</h3> {/* Always renders */}
      {description && <p>{description}</p>}
      {imageUrl && <img src={imageUrl} />}
      {cta && <button>{cta}</button>}
    </div>
  );
}

How defaults work:

  • Required props get default values from DEFAULT_DATA in stories
  • Node generator extracts these and puts them in configSchema.properties.*.default
  • Component always receives at least the required props with defaults

Pattern 2: Optional Props Components (AIResponse, StreamingText)

Components where ALL props are optional (for streaming/reactive updates).

Example:

interface AIResponseProps {
  progressText?: string; // All optional
  text?: string;
  questions?: string[];
}

export default function AIResponse(props: AIResponseProps) {
  const { progressText, text, questions } = props;

  // Check if ANY data has been received
  const hasData = progressText || text || (questions && questions.length > 0);

  // IMPORTANT: Show default sample data when no props provided
  // This makes the component look good in workflow preview
  if (!hasData) {
    return (
      <div className="ai-response">
        <div className="progress">Searching the Jedi Archives...</div>
        <div className="text">
          The <strong>Star Wars</strong> saga spans nine main films...
        </div>
        <button>What is the correct chronological order?</button>
      </div>
    );
  }

  // Render actual content when data arrives
  return (
    <div className="ai-response">
      {progressText && <div className="progress">{progressText}</div>}
      {text && <div className="text">{text}</div>}
      {questions && questions.map((q) => <button key={q}>{q}</button>)}
    </div>
  );
}

How defaults work:

  • All props are optional, so component may receive NO props
  • Component must render default sample data internally
  • Default sample data should match DEFAULT_DATA from stories
  • When real data arrives, it replaces the defaults

Key Rule for ALL Components

Every component must look good with no user input:

// ✅ GOOD - Shows sample data
if (!hasData) {
  return <div>Sample content from DEFAULT_DATA...</div>;
}

// ❌ BAD - Shows blank/placeholder
if (!hasData) {
  return <div>Waiting for data...</div>;
}

// ❌ BAD - Shows error/empty state
if (!hasData) {
  return <div>No data provided</div>;
}

Why? The workflow editor preview renders components with default/empty config. Users need to see what the component looks like before configuring it.

Keep Props Simple

  • 3-5 props maximum - Too many inputs make the workflow node complex
  • Use sensible defaults
  • Avoid complex objects as props

Bad Example ❌

interface ComplexProps {
  config: {
    layout: "grid" | "list" | "masonry";
    columns: number;
    gap: number;
    responsive: {
      mobile: number;
      tablet: number;
      desktop: number;
    };
  };
  theme: ThemeConfig;
  handlers: EventHandlers;
  // Too complex!
}

Sizing Guidelines

In Workflow Editor:

  • Set min-width in CSS (e.g., 320px for cards, 400px for images)
  • Component auto-sizes based on content
  • Users can resize the workflow node if needed

In Client:

  • Component is fully fluid
  • Fills its container
  • No max-width constraints
/* Perfect for both workflow and client */
.container {
  width: 100%;
  height: 100%;
  min-width: 320px; /* Workflow visibility */
  /* No max-width - stays fluid in client */
}

Templates - Layout Containers

What Are Templates?

Templates are layout components that receive conversation history and decide how to render it. Unlike workflow components (which AI sends), templates are loaded by the client app and interpret the history stream.

Key Concept: State Separation

Two-Layer State Architecture:

┌─────────────────────────────────────────┐
│  CLIENT STATE (Universal)               │
│  - HistoryManager                       │
│  - User messages                        │
│  - AI components (with loaded Component)│
│  - Conversation metadata                │
└─────────────────────────────────────────┘
                 ↓ history prop
┌─────────────────────────────────────────┐
│  TEMPLATE STATE (Local)                 │
│  - Scroll position                      │
│  - UI filters                           │
│  - Layout-specific state                │
│  - How to render history                │
└─────────────────────────────────────────┘

Client State (HistoryManager):

  • Universal across ALL templates
  • Manages conversation timeline
  • Collects user messages and AI components
  • Framework-agnostic (can extract to npm)

Template State (Local):

  • Specific to each template
  • Manages UI/UX behavior
  • Interprets history differently per template
  • React useState/useReducer

Template Contract

All templates MUST extend GravityTemplateProps:

// From GravityTemplate.tsx
interface GravityTemplateProps {
  /** Full conversation history from HistoryManager */
  history: HistoryEntry[];

  /** Callback when user sends message */
  onSend?: (message: string) => void;

  /** Streaming state */
  isStreaming?: boolean;

  /** Current streaming component name */
  streamingComponent?: string | null;

  /** Template-specific props */
  [key: string]: any;
}

History Entry Structure

interface HistoryEntry {
  id: string;
  type: "user_message" | "component";
  role: "user" | "assistant";
  timestamp: string;

  // For user messages
  content?: string;

  // For components
  componentType?: string;
  componentUrl?: string;
  props?: Record<string, any>;
  metadata?: Record<string, any>;
  Component?: any; // Loaded component function
}

Creating a Template

1. Create Template File:

// /storybook/templates/ChatLayout/ChatLayout.tsx
import React, { useEffect, useRef } from "react";
import { useGravityTemplate } from "../GravityTemplate";
import type { GravityTemplateProps } from "../GravityTemplate";

export interface ChatLayoutProps extends GravityTemplateProps {
  placeholder?: string;
  autoScroll?: boolean;
}

export default function ChatLayout(props: ChatLayoutProps) {
  const { history, onSend, isStreaming, placeholder, autoScroll } = props;
  const { renderComponent } = useGravityTemplate(history);

  // Template-specific state
  const messagesEndRef = useRef<HTMLDivElement>(null);

  // Template-specific logic (auto-scroll)
  useEffect(() => {
    if (autoScroll && messagesEndRef.current) {
      messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
    }
  }, [history.length, autoScroll]);

  return (
    <div className="flex flex-col h-screen w-full bg-gray-50">
      {/* Scrollable messages */}
      <div className="flex-1 overflow-y-auto px-4 py-6 space-y-4">
        {history.map((entry) => {
          // User message - chat bubble on right
          if (entry.type === "user_message") {
            return (
              <div key={entry.id} className="flex justify-end">
                <div className="bg-blue-500 text-white rounded-2xl px-4 py-2">
                  <p className="text-sm">{entry.content}</p>
                </div>
              </div>
            );
          }

          // AI component - on left
          if (entry.type === "component") {
            return (
              <div key={entry.id} className="flex justify-start">
                {renderComponent(entry)}
              </div>
            );
          }
        })}
        <div ref={messagesEndRef} />
      </div>

      {/* Fixed input at bottom */}
      <div className="border-t border-gray-200 bg-white px-4 py-4">
        <input
          type="text"
          placeholder={placeholder}
          disabled={isStreaming}
          onKeyDown={(e) => {
            if (e.key === "Enter" && e.currentTarget.value.trim()) {
              onSend?.(e.currentTarget.value.trim());
              e.currentTarget.value = "";
            }
          }}
        />
      </div>
    </div>
  );
}

2. Create Storybook Story:

// ChatLayout.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import ChatLayout from "./ChatLayout";

const meta: Meta<typeof ChatLayout> = {
  title: "Templates/ChatLayout",
  component: ChatLayout,
  parameters: {
    layout: "fullscreen",
  },
};

export default meta;

export const WithHistory: Story = {
  args: {
    history: [
      {
        id: "msg-1",
        type: "user_message",
        role: "user",
        content: "Hello!",
        timestamp: new Date().toISOString(),
      },
      {
        id: "comp-1",
        type: "component",
        role: "assistant",
        componentType: "Card",
        timestamp: new Date().toISOString(),
      },
    ],
    placeholder: "Ask me anything...",
    autoScroll: true,
  },
};

3. Generate Bundle:

npm run generate-nodes

This bundles the template to dist/components/ChatLayout.js (with CSS injected).

Template Examples

ChatLayout - Sequential Timeline:

  • User messages on right
  • AI components on left
  • Auto-scroll to bottom
  • Fixed input at bottom

BookingLayout - Master Widget:

  • User messages in sidebar
  • First component = Master booking widget
  • Subsequent components update the widget
  • Search input in sidebar

DashboardLayout - Widget Grid:

  • Components grouped by widgetId
  • Rendered in grid layout
  • Latest component per widget wins
  • No user message display

Helper Utilities

useGravityTemplate Hook:

const {
  getUserMessages, // Filter to user messages only
  getComponents, // Filter to components only
  getByRole, // Filter by role (user/assistant)
  getLatest, // Get latest entry
  getFirst, // Get first entry
  renderComponent, // Safely render component
} = useGravityTemplate(history);

Example Usage:

// Get only user messages for sidebar
const userMessages = getUserMessages();

// Get first component as master widget
const masterWidget = getComponents()[0];

// Render component safely (handles loading state)
{
  renderComponent(entry);
}

Client Integration

In Client App (e.g., GravityAIClient):

// 1. Initialize HistoryManager
const { history, addUserMessage, addComponent } = useHistoryManager(sessionParams);

// 2. Load template
const chatLayout = await loadComponent("/components/ChatLayout.js", "ChatLayout");

// 3. Render template with history
return h(ComponentRenderer, {
  component: {
    Component: chatLayout,
    name: "ChatLayout",
    props: {
      history: history, // Full history from HistoryManager
      onSend: handleSendMessage,
      isStreaming: isStreaming,
    },
  },
});

Key Principles

  1. Templates are layout only - They provide structure, not content
  2. History is universal - Same history works for any template
  3. Templates are stateful - Can maintain local UI state
  4. Templates interpret history - Each template renders history differently
  5. No workflow nodes - Templates are never sent from AI

Benefits

Template Switching - Change layout without losing history
Flexible Rendering - Each template interprets history differently
Type Safety - TypeScript ensures compliance with GravityTemplateProps
Reusable - Templates work across all AI workflows
Testable - Easy to test with mock history


Generating Workflow Nodes

  1. Build and Generate:

    npm run generate-nodes
  2. What Gets Generated:

    • /src/[ComponentName]/node/ - Node definition and executor
    • /src/[ComponentName]/service/ - Minimal template service (only componentUrl)
    • /src/[ComponentName]/util/ - TypeScript types
    • /dist/components/[ComponentName].js - Bundled component with CSS
  3. What Gets Sent to Client:

    {
      "type": "Card",
      "componentUrl": "/components/Card.js?v=1760073564823",
      "props": {
        "title": "Golf Swing Assessment",
        "description": "...",
        "imageUrl": "...",
        "cta": "..."
      },
      "metadata": { "nodeId": "card2" }
    }

    That's it! No HTML, no CSS, no tokens. Just the URL and props.

  4. Restart Server:

    cd /path/to/server
    npm run dev design-system
  5. Your component is now available in the workflow editor under "Design System" category

Common Patterns

Text-Heavy Components

// 2-3 text props maximum
interface TextComponentProps {
  heading: string;
  body?: string;
  cta?: string;
}

Image Components

// Keep it simple
interface ImageComponentProps {
  src: string;
  alt?: string;
  caption?: string;
}

Interactive Components

// Avoid event handlers in workflow nodes
// Use simple data props instead
interface ButtonProps {
  label: string;
  variant?: "primary" | "secondary";
  size?: "sm" | "md" | "lg";
}

Troubleshooting

Component not showing in workflow?

  • Check that argTypes is defined in stories
  • Ensure DEFAULT_DATA constant exists
  • Run npm run generate-nodes again
  • Restart the server

Component too wide in workflow?

  • Add min-width to your CSS
  • Remove any max-width constraints
  • Use width: 100% for fluid behavior

Component not fluid in client?

  • Remove fixed width values from CSS
  • Use width: 100% instead
  • Avoid max-width constraints

Examples

See existing components for reference:

  • /src/components/Card/ - Simple card with image and text
  • /src/components/Image/KenBurnsImage.tsx - Image with animation

Summary Checklist

DO:

  • ✅ Write any React code - all features supported!
  • ✅ Use all hooks (useState, useEffect, useMemo, custom hooks, etc.)
  • ✅ Use third-party React libraries (if installed in package.json)
  • ✅ Use React Context, Suspense, etc.
  • ✅ Use CSS modules
  • ✅ Define argTypes in stories
  • ✅ Keep props simple (3-5 max recommended)
  • ✅ Use DEFAULT_DATA constant for defaults
  • ✅ Set min-width for workflow visibility
  • ✅ Use control: 'text' for string fields (Handlebars templates)
  • ✅ Use control: 'object' for object/array fields (JavaScript templates)

⚠️ REMEMBER:

  • Components are bundled and sent to React client dynamically
  • No npm install needed on client side
  • Full React features work because client IS React
  • Components update without redeploying client

🔧 Build Command:

npm run generate-nodes

This will:

  1. Bundle components with Vite (React as external, CSS injected into JS)
  2. Extract metadata from Storybook (argTypes, defaults)
  3. Generate workflow nodes with minimal template (only componentUrl)
  4. Save to dist/components/ComponentName.js

At Runtime:

  1. Server serves components from /components/:name endpoint
  2. Executor sends minimal payload: { type, componentUrl, props }
  3. Client loads component via <script> tag
  4. CSS auto-injected when JS loads
  5. Component renders with full React features

📝 Complete Working Example

BookingWidget.tsx (Full React - All Features Supported)

import React, { useState } from "react";
import styles from "./BookingWidget.module.css";

export interface BookingWidgetProps {
  hotelId: string;
  rooms: Array<{ id: number; name: string; price: number; available: boolean }>;
  loading?: boolean;
  onBook?: (roomId: number, guests: number) => void;
}

export default function BookingWidget(props: BookingWidgetProps) {
  const { rooms, loading, onBook } = props;
  const [selectedRoom, setSelectedRoom] = useState<number | null>(null);
  const [guests, setGuests] = useState(1);

  // Inline logic (no custom hooks)
  const availableRooms = rooms.filter((r) => r.available);

  if (loading) {
    return <div className={styles.loading}>Loading rooms...</div>;
  }

  return (
    <div className={styles.container}>
      <h2 className={styles.title}>Select Your Room</h2>

      <div className={styles.roomList}>
        {availableRooms.map((room) => (
          <div
            key={room.id}
            className={selectedRoom === room.id ? styles.roomSelected : styles.room}
            onClick={() => setSelectedRoom(room.id)}
          >
            <h3>{room.name}</h3>
            <p>${room.price}/night</p>
          </div>
        ))}
      </div>

      <div className={styles.guestSelector}>
        <label>Number of Guests:</label>
        <input type="number" min="1" max="10" value={guests} onChange={(e) => setGuests(Number(e.target.value))} />
      </div>

      <button
        className={styles.bookButton}
        disabled={!selectedRoom}
        onClick={() => selectedRoom && onBook?.(selectedRoom, guests)}
      >
        Book Now
      </button>
    </div>
  );
}

BookingWidget.stories.tsx

import type { Meta, StoryObj } from "@storybook/react";
import { BookingWidget } from "./BookingWidget";

const meta: Meta<typeof BookingWidget> = {
  title: "Components/BookingWidget",
  component: BookingWidget,
  argTypes: {
    hotelId: {
      control: "text",
      description: "Hotel identifier",
    },
    rooms: {
      control: "object",
      description: "Available rooms",
    },
    loading: {
      control: "boolean",
      description: "Loading state",
    },
  },
};

export default meta;
type Story = StoryObj<typeof BookingWidget>;

const DEFAULT_DATA = {
  hotelId: "hotel_123",
  rooms: [
    { id: 1, name: "Deluxe Room", price: 200, available: true },
    { id: 2, name: "Suite", price: 350, available: true },
    { id: 3, name: "Presidential", price: 500, available: false },
  ],
  loading: false,
  onBook: (roomId: number, guests: number) => {
    console.log(`Booked room ${roomId} for ${guests} guests`);
  },
};

export const Default: Story = {
  args: DEFAULT_DATA,
};

export const Loading: Story = {
  args: {
    ...DEFAULT_DATA,
    loading: true,
  },
};

Result

After running npm run generate-nodes, this component will:

  1. ✅ Be bundled to dist/components/BookingWidget.js (React as external)
  2. ✅ Served from server at /components/BookingWidget.js
  3. ✅ Client imports dynamically via URL (no npm install needed)
  4. ✅ Maintain full state management (selectedRoom, guests)
  5. ✅ Support all React features and interactions
  6. ✅ Receive data via props from workflow
  7. ✅ Be available as a workflow node in "Design System" category

How It Works at Runtime

// 1. Client receives minimal payload from server
const component = {
  type: 'BookingWidget',
  componentUrl: '/components/BookingWidget.js?v=123',
  props: { hotelId: 'hotel_123', rooms: [...] }
};

// 2. Client constructs full URL from GraphQL endpoint
const baseUrl = config.endpoint.replace('/graphql', '');
const fullUrl = `${baseUrl}${component.componentUrl}`;
// Result: http://localhost:4100/components/BookingWidget.js?v=123

// 3. Load component via script tag
const script = document.createElement('script');
script.src = fullUrl;
document.body.appendChild(script);

// 4. Component registers globally and CSS auto-injects
script.onload = () => {
  const BookingWidget = window.BookingWidget;  // Component available
  // CSS already injected into <head> by the bundle

  // 5. Render with props
  <BookingWidget {...component.props} />  // Full React features work!
};

Key Points

  • 10x smaller payload - No HTML/CSS sent over network
  • Browser caching - Component JS cached by URL
  • Auto-styled - CSS injected when JS loads
  • Environment-agnostic - Client constructs URL from its config
  • Full React - All hooks, state, effects work perfectly

Quick Reference: Components vs Templates

| Feature | Workflow Components | Layout Templates | | ----------------------- | --------------------------- | ------------------------------- | | Location | /storybook/components/ | /storybook/templates/ | | Purpose | AI-streamable UI elements | Layout containers for history | | Generated as Nodes? | ✅ Yes | ❌ No | | Bundled? | ✅ Yes (dist/components/) | ✅ Yes (dist/components/) | | Sent by AI? | ✅ Yes (via workflow) | ❌ No (loaded by client) | | Receives Props? | ✅ From AI workflow | ✅ From client app | | Manages State? | ✅ Local component state | ✅ Template UI state | | Accesses History? | ❌ No | ✅ Yes (via props) | | Examples | Card, ChatInput, Image | ChatLayout, BookingLayout | | State Layer | Component-local | Template-local + HistoryManager | | Extends | React.Component | GravityTemplateProps | | Helper Hook | N/A | useGravityTemplate() |

Key Insight: Components are content, templates are containers. Same history, different rendering.


Reactive Component Updates (Streaming)

Design system components support reactive updates - they can receive incremental prop changes from streaming workflow nodes (like OpenAIStream) without re-rendering the entire component.

How It Works

Architecture:

OpenAIStream chunk 1 → AIResponse executes → INIT (full component)
OpenAIStream chunk 2 → AIResponse executes → UPDATE (only changed props)
OpenAIStream chunk 3 → AIResponse executes → UPDATE (only changed props)

Backend (Workflow Engine):

  1. Design system nodes (template: "uiComponent") preserve their inputs after execution
  2. Track last sent props in WorkflowContext.lastSentComponentProps
  3. On re-execution, diff current props vs last sent
  4. Send only changed props to client

Frontend (React Component):

  1. Receives INIT event → Renders full component
  2. Receives UPDATE events → Merges changed props into existing component
  3. Component accumulates state internally (e.g., streaming text)

Example: Streaming Text to AIResponse

Workflow:

InputTrigger → OpenAIStream (callback) → AIResponse (promise)
                     ↓
                 emits chunks every 100ms

Backend Behavior:

// First execution (all dependencies met)
lastSentComponentProps[airesponse1] = undefined
currentProps = { text: "...", questions: [...] }
→ Send INIT: { type: "AIResponse", props: { text: "...", questions: [...] } }
→ Store: lastSentComponentProps[airesponse1] = { text: "...", questions: [...] }

// Second execution (chunk arrives)
lastSentComponentProps[airesponse1] = { text: "...", questions: [...] }
currentProps = { text: "...", questions: [...], chunk: "Hello" }
→ Diff: { chunk: "Hello" } changed
→ Send UPDATE: { type: "AIResponse", props: { chunk: "Hello" }, metadata: { isUpdate: true } }
→ Store: lastSentComponentProps[airesponse1] = { text: "...", questions: [...], chunk: "Hello" }

// Third execution (chunk arrives)
→ Diff: { chunk: " world" } changed
→ Send UPDATE: { type: "AIResponse", props: { chunk: " world" }, metadata: { isUpdate: true } }

Frontend Behavior:

// AIResponse component
const [accumulatedText, setAccumulatedText] = useState("");

useEffect(() => {
  if (chunk) {
    setAccumulatedText((prev) => prev + chunk); // Accumulate chunks
  }
}, [chunk]);

// Renders: "Hello" → "Hello world" → "Hello world how are you"

Benefits

Efficient - Only sends changed data (not full component spec)
Smooth streaming - Components update incrementally
Bandwidth optimized - 1KB response sends ~1KB total (not 75KB cumulative)
State preserved - Client manages accumulation, server stays lightweight
Automatic - No special code needed, works for any design system component

Implementation Details

Workflow Context Tracking:

interface WorkflowContext {
  // ... other fields
  lastSentComponentProps?: Record<string, Record<string, any>>;
  // Structure: { nodeId: { prop1: value1, prop2: value2 } }
}

Input Preservation:

// In executingState.ts
const isDesignSystemNode = node?.data?.nodeDefinition?.template === "uiComponent";

if (!isDesignSystemNode) {
  updatedInputs = consumeNodeInputs(updatedInputs, event.nodeId, routeTable);
} else {
  // Keep inputs for reactive updates
  logger.debug(`🎨 Preserved inputs for design system node: ${event.nodeId}`);
}

Delta Publishing:

// In publishComponent
const componentData =
  config.isUpdate && config.changedProps
    ? { ...config.component, props: config.changedProps } // Delta
    : config.component; // Full

Client Integration

Handling Updates:

// Client receives event
if (event.metadata.isUpdate) {
  // Merge changed props into existing component
  updateComponentProps(nodeId, event.data.component.props);
} else {
  // Initial render - full component
  renderComponent(nodeId, event.data.component);
}

Use Cases

Streaming Text:

  • OpenAIStream → AIResponse
  • Each chunk updates chunk prop
  • Component accumulates internally

Loading States:

  • Node updates loading: trueloading: false
  • Only loading prop sent on update

Progressive Data:

  • Search results arrive incrementally
  • Each batch updates results array
  • Component shows growing list

Notes

  • Only works for design system nodes (template: "uiComponent")
  • Regular workflow nodes still consume inputs normally
  • Client decides whether to INIT or UPDATE based on component registry
  • Server doesn't track client state - just diffs props