@gravityai-dev/design-system
v0.2.2
Published
AI-powered design system with server-driven components
Maintainers
Readme
Gravity Design System
Build React components in Storybook → Auto-generate workflow nodes + layout templates
Two Types of Components:
- Components (
/storybook/components/) - AI-streamable components → workflow nodes - Templates (
/storybook/templates/) - Layout containers → render conversation history
📋 Table of Contents
Getting Started
- Quick Start - 5-minute setup
- Your First Component - Step-by-step tutorial
- Key Features
- How It Works
Components (Workflow Nodes)
- Component Structure
- Creating Components
- ArgTypes Reference
- Styling with Tailwind
- Component Best Practices
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 componentsYour First Component (5 Minutes)
Step 1: Create component files
mkdir -p storybook/components/MyCard
cd storybook/components/MyCardStep 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 clientsComponents 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 componentComponentName.stories.tsx- Storybook stories with argTypesdefaults.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 componentTemplateName.stories.tsx- Storybook storiesREADME.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_INITwith 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
@keyframesin 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:
- Storybook - Uses defaults for demo/preview
- Workflow Editor - Shows defaults in component preview when node config is empty
- 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 stringcontrol: 'object'→ JavaScriptreturn ...→ 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_DATAin 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_DATAfrom 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-widthin 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-nodesThis 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
- Templates are layout only - They provide structure, not content
- History is universal - Same history works for any template
- Templates are stateful - Can maintain local UI state
- Templates interpret history - Each template renders history differently
- 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
Build and Generate:
npm run generate-nodesWhat 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
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.
Restart Server:
cd /path/to/server npm run dev design-systemYour 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
argTypesis defined in stories - Ensure
DEFAULT_DATAconstant exists - Run
npm run generate-nodesagain - Restart the server
Component too wide in workflow?
- Add
min-widthto your CSS - Remove any
max-widthconstraints - Use
width: 100%for fluid behavior
Component not fluid in client?
- Remove fixed
widthvalues from CSS - Use
width: 100%instead - Avoid
max-widthconstraints
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-nodesThis will:
- Bundle components with Vite (React as external, CSS injected into JS)
- Extract metadata from Storybook (argTypes, defaults)
- Generate workflow nodes with minimal template (only componentUrl)
- Save to
dist/components/ComponentName.js
At Runtime:
- Server serves components from
/components/:nameendpoint - Executor sends minimal payload:
{ type, componentUrl, props } - Client loads component via
<script>tag - CSS auto-injected when JS loads
- 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:
- ✅ Be bundled to
dist/components/BookingWidget.js(React as external) - ✅ Served from server at
/components/BookingWidget.js - ✅ Client imports dynamically via URL (no npm install needed)
- ✅ Maintain full state management (selectedRoom, guests)
- ✅ Support all React features and interactions
- ✅ Receive data via props from workflow
- ✅ 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):
- Design system nodes (template: "uiComponent") preserve their inputs after execution
- Track last sent props in
WorkflowContext.lastSentComponentProps - On re-execution, diff current props vs last sent
- Send only changed props to client
Frontend (React Component):
- Receives INIT event → Renders full component
- Receives UPDATE events → Merges changed props into existing component
- Component accumulates state internally (e.g., streaming text)
Example: Streaming Text to AIResponse
Workflow:
InputTrigger → OpenAIStream (callback) → AIResponse (promise)
↓
emits chunks every 100msBackend 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; // FullClient 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
chunkprop - Component accumulates internally
Loading States:
- Node updates
loading: true→loading: false - Only
loadingprop sent on update
Progressive Data:
- Search results arrive incrementally
- Each batch updates
resultsarray - 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
