Initial commit

This commit is contained in:
Dom
2026-03-05 01:20:15 +01:00
commit c0c50e56f0
364 changed files with 62207 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
import type { StorybookConfig } from '@storybook/web-components-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-actions',
'@storybook/addon-controls',
'@storybook/addon-docs',
],
framework: {
name: '@storybook/web-components-vite',
options: {},
},
typescript: {
check: false,
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesFromEnum: true,
propFilter: (prop) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true),
},
},
};
export default config;

View File

@@ -0,0 +1,6 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto+Slab:wght@400;500;600;700&family=Signika:wght@300;400;500;600;700&family=Space+Mono:wght@400;700&display=swap"
rel="stylesheet"
/>

View File

@@ -0,0 +1,17 @@
import type { Preview } from '@storybook/web-components';
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
docs: {
autodocs: 'tag',
},
},
};
export default preview;

View File

@@ -0,0 +1,422 @@
# Vanna Webcomponent Comprehensive Test Suite
This test suite validates all component types and update patterns in the vanna-webcomponent before pruning unused code.
## Overview
The test suite consists of:
- **`test_backend.py`**: Real Python backend that streams all component types
- **`test-comprehensive.html`**: Browser-based test interface with visual validation
- **Two test modes**: Rapid (stress test) and Realistic (with delays)
## Quick Start
### 1. Install Dependencies
```bash
cd submodule/vanna-webcomponent
pip install -r requirements-test.txt
```
### 2. Build the Webcomponent
```bash
npm run build
```
### 3. Start the Test Backend
```bash
# Realistic mode (with delays between components)
python test_backend.py --mode realistic
# Rapid mode (fast stress test)
python test_backend.py --mode rapid
```
The backend will start on `http://localhost:5555` and automatically serve the test page.
### 4. Open Test Interface
Simply open your browser to:
```
http://localhost:5555
```
The test page will load automatically!
### 5. Run the Test
1. Click **"Run Comprehensive Test"** button in the sidebar
2. Watch components render in real-time
3. Monitor the checklist - items check off as components render
4. Watch the console log for any errors
## Test Coverage
### Component Types Tested
The test exercises **all** rich component types with **19 different components**:
#### Primitive Components
- ✓ Text (with markdown)
- ✓ Badge
- ✓ Icon Text
#### Feedback Components
- ✓ Status Card (with all states: pending, running, completed, failed)
- ✓ Progress Display (0% → 50% → 100%)
- ✓ Progress Bar
- ✓ Status Indicator (with pulse animation)
- ✓ Notification (info, success, warning, error levels)
- ✓ Log Viewer (with info, warning, error logs)
#### Data Components
- ✓ Card (with buttons and actions)
- ✓ Task List (with status updates)
-**DataFrame** (tabular data with search/sort/filter/export)
-**Table** (structured data with explicit column definitions)
-**Chart** (Plotly charts: bar, line, scatter)
-**Code Block** (syntax highlighted code: Python, SQL, etc.)
#### Specialized Components
-**Artifact** (HTML/SVG interactive content)
#### Container Components
-**Container** (groups components in rows/columns)
#### Interactive Components
- ✓ Button (single)
- ✓ Button Group (horizontal/vertical)
- ✓ Button actions (click → backend response)
#### UI State Updates
- ✓ Status Bar Update (updates status bar above input)
- ✓ Task Tracker Update (adds/updates tasks in sidebar)
- ✓ Chat Input Update (changes placeholder/state)
### Update Operations Tested
For each component type, the test validates:
1. **Create** (`lifecycle: create`) - Initial component rendering
2. **Update** (`lifecycle: update`) - Incremental property updates
3. **Replace** - Full component replacement
4. **Remove** - Component removal from DOM
### Interactive Features Tested
- **Button Actions**: Clicking buttons sends actions to backend
- **Action Handling**: Backend receives actions and responds with new components
- **Round-trip Communication**: Full interaction loop validation
## Test Modes
### Realistic Mode (Default)
```bash
python test_backend.py --mode realistic
```
- Includes delays between component updates (0.2-0.5s)
- Simulates real conversation flow
- Easier to observe rendering behavior
- **Recommended for initial validation**
### Rapid Mode
```bash
python test_backend.py --mode rapid
```
- Minimal delays (0.05-0.1s)
- Stress tests rendering performance
- Validates no race conditions
- **Use for performance testing**
## Validation Checklist
The test interface provides real-time validation:
### ✅ Visual Checklist
- Automatically checks off components as they render
- Shows 19 component types
- Green checkmark = successfully rendered
### 📊 Metrics
- **Components Rendered**: Total unique component types
- **Updates Processed**: Total number of updates (create + update + replace)
- **Errors**: Console errors detected
### 🔴 Console Monitor
- Real-time console log display
- Errors highlighted in red
- Warnings in yellow
- Info messages in blue
### 🟢 Status Indicators
- **Backend Status**: Green = connected, Red = disconnected
- **Console Status**: Green = no errors, Red = errors detected
## Using for Webcomponent Pruning
The test suite is designed to validate that pruning doesn't break functionality:
### Pruning Workflow
1. **Run baseline test**:
```bash
python test_backend.py --mode realistic
# Browser: Open http://localhost:5555 and run test
# Verify: All 19 components render, 0 errors
```
2. **Identify cruft to remove**:
- Unused imports
- Dead code paths
- Deprecated components
- Development-only utilities
3. **Remove one piece of cruft**:
```bash
# Example: Remove unused import from vanna-chat.ts
# or delete unused utility file
```
4. **Rebuild**:
```bash
npm run build
```
5. **Refresh browser test**:
- Press F5 to reload test page
- Click "Run Comprehensive Test" again
- Check console for errors
- Verify all 12 components still render
6. **If green → continue; if red → investigate**:
- Green (no errors): Commit the change, continue pruning
- Red (errors): Revert change, that code was actually needed
7. **Repeat until clean**: Continue removing cruft until webcomponent is minimal
### What to Prune
Look for these common types of cruft:
- ❌ **Unused imports**: Components imported but never used
- ❌ **Development utilities**: Debug helpers, test mocks in production code
- ❌ **Deprecated components**: Old component versions no longer referenced
- ❌ **Unused CSS**: Styles for removed components
- ❌ **Dead code paths**: Conditional logic that's never executed
- ❌ **Commented code**: Old implementations that are commented out
- ❌ **Storybook-only code**: Utilities only used in stories, not production
### What NOT to Prune
Be careful with these:
- ✅ **Base component renderers**: Even if rarely used, may be needed
- ✅ **ComponentRegistry entries**: Needed for dynamic component lookup
- ✅ **Shadow DOM utilities**: Required for web components
- ✅ **Event handlers**: May be used by runtime events
- ✅ **Type definitions**: Used at compile time even if not runtime
## Customizing the Test
### Add More Component Tests
Edit `test_backend.py` and add new test functions:
```python
async def test_my_component(conversation_id: str, request_id: str, mode: str):
"""Test my custom component."""
my_component = MyComponent(
id=str(uuid.uuid4()),
# ... component properties
)
yield await yield_chunk(my_component, conversation_id, request_id)
await delay(mode)
# Then add to run_comprehensive_test():
async for chunk in test_my_component(conversation_id, request_id, mode):
yield chunk
```
### Modify Test Delays
In `test_backend.py`, adjust the `delay()` function:
```python
async def delay(mode: str, short: float = 0.1, long: float = 0.5):
if mode == "realistic":
await asyncio.sleep(long) # Adjust long delay here
elif mode == "rapid":
await asyncio.sleep(short) # Adjust short delay here
```
### Add Custom Validation
Edit `test-comprehensive.html` and add custom validation logic:
```javascript
// Add to MutationObserver callback
const componentType = node.getAttribute('data-component-type');
if (componentType === 'my_component') {
// Custom validation for my_component
console.log('My component rendered!');
}
```
## Troubleshooting
### Backend won't start
**Error**: `ModuleNotFoundError: No module named 'vanna'`
**Solution**: Make sure vanna is in the Python path:
```bash
cd submodule/vanna-webcomponent
python test_backend.py # Already adds ../vanna/src to sys.path
```
### Frontend shows "Backend not responding"
**Solutions**:
1. Check backend is running: `curl http://localhost:5555/health`
2. Check CORS is enabled (should be by default)
3. Verify port 5555 is not in use: `lsof -i :5555`
### Components not rendering
**Check**:
1. Browser console for errors (F12)
2. Webcomponent is built: `ls dist/`
3. Test HTML is loading: `<script type="module" src="./dist/index.js"></script>`
### Test page is blank
**Solutions**:
1. Check you're serving from the right directory:
```bash
cd submodule/vanna-webcomponent
python -m http.server 8080
```
2. Open correct URL: `http://localhost:8080/test-comprehensive.html`
3. Check browser console for 404 errors
### Checklist not updating
The checklist tracks components by their `data-component-type` attribute. If components don't have this attribute, they won't be tracked.
**Verify**: Open browser DevTools and inspect rendered components for `data-component-type`.
## Advanced Usage
### Run Backend on Different Port
```bash
python test_backend.py --port 8000
```
Then update `test-comprehensive.html`:
```html
<vanna-chat
api-url="http://localhost:8000"
...
></vanna-chat>
```
### Enable Debug Logging
Add to `test_backend.py`:
```python
import logging
logging.basicConfig(level=logging.DEBUG)
```
### Run Type Checking
Validate the backend code with mypy:
```bash
python -m mypy test_backend.py
```
This catches type errors before runtime (e.g., wrong field names in Pydantic models).
### Test Specific Component Only
Modify `run_comprehensive_test()` to only run specific tests:
```python
async def run_comprehensive_test(conversation_id, request_id, mode):
# Comment out tests you don't want to run
async for chunk in test_status_card(conversation_id, request_id, mode):
yield chunk
# async for chunk in test_progress_display(...): # Disabled
# yield chunk
```
## Architecture
### Backend Flow
1. FastAPI receives POST to `/api/vanna/v2/chat_sse`
2. `chat_sse()` creates async generator
3. Generator yields components wrapped in `ChatStreamChunk`
4. Each chunk serialized to SSE format: `data: {json}\n\n`
5. Stream ends with `data: [DONE]\n\n`
### Frontend Flow
1. `<vanna-chat>` web component connects to backend
2. Opens SSE connection to `/api/vanna/v2/chat_sse`
3. Receives chunks, parses JSON
4. `ComponentManager` processes updates
5. `ComponentRegistry` renders HTML elements
6. Elements appended to shadow DOM container
7. MutationObserver detects new components
8. Checklist updates automatically
### Button Action Flow
1. User clicks button in frontend
2. Button's `action` property sent as new message
3. Backend receives message via `/api/vanna/v2/chat_sse` POST
4. `handle_action_message()` processes action
5. Response components streamed back
6. Frontend renders response
## Files
- **`test_backend.py`** - Python FastAPI backend (400 lines)
- **`test-comprehensive.html`** - Browser test interface (500 lines)
- **`requirements-test.txt`** - Python dependencies
- **`TEST_README.md`** - This documentation
## Next Steps
After validating the webcomponent with this test suite:
1. **Run baseline test** - Verify all components work before pruning
2. **Identify cruft** - Find unused code in the webcomponent
3. **Prune iteratively** - Remove one piece at a time, test after each change
4. **Commit clean code** - Once pruned, commit the cleaned webcomponent
5. **Copy to vanna package** - Integrate cleaned webcomponent into vanna Python package
## Support
If you encounter issues with the test suite:
1. Check this README's Troubleshooting section
2. Verify all dependencies are installed
3. Ensure you're in the correct directory
4. Check browser and terminal console output
---
**Happy Testing!** 🧪

View File

@@ -0,0 +1,57 @@
{
"name": "@vanna/webcomponent",
"version": "2.0.2",
"description": "Lit-based web components for Vanna User Agents",
"main": "dist/vanna-components.js",
"scripts": {
"sync-version": "node scripts/sync-version.js",
"dev": "vite",
"build": "npm run sync-version && tsc && vite build",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"vanna",
"ai",
"sql",
"web-components",
"lit",
"chat",
"llm",
"natural-language"
],
"author": "Zain Hoda <zain@vanna.ai>",
"license": "MIT",
"type": "commonjs",
"repository": {
"type": "git",
"url": "https://github.com/vanna-ai/vanna.git",
"directory": "frontends/webcomponent"
},
"homepage": "https://github.com/vanna-ai/vanna",
"bugs": {
"url": "https://github.com/vanna-ai/vanna/issues"
},
"files": [
"dist",
"src"
],
"dependencies": {
"lit": "^3.3.1",
"plotly.js-dist-min": "^3.1.0"
},
"devDependencies": {
"@storybook/addon-actions": "^8.6.14",
"@storybook/addon-controls": "^8.6.14",
"@storybook/addon-docs": "^8.6.14",
"@storybook/addon-essentials": "^8.6.14",
"@storybook/web-components": "^8.6.14",
"@storybook/web-components-vite": "^8.6.14",
"@types/plotly.js-dist-min": "^2.3.4",
"storybook": "^8.6.14",
"typescript": "^5.9.2",
"vite": "^7.1.5"
}
}

View File

@@ -0,0 +1,8 @@
# Test backend requirements for vanna-webcomponent comprehensive testing
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
pydantic>=2.0.0
# Note: The vanna package itself will be imported from ../vanna/src
# No need to install it separately for local testing

View File

@@ -0,0 +1,63 @@
/**
* Sync version from pyproject.toml to package.json
*
* This ensures the webcomponent version always matches the Python package version.
* Single source of truth: pyproject.toml
*
* Usage: node scripts/sync-version.js
*/
const fs = require('fs');
const path = require('path');
// Paths relative to this script
const PYPROJECT_PATH = path.join(__dirname, '../../../pyproject.toml');
const PACKAGE_JSON_PATH = path.join(__dirname, '../package.json');
function extractVersionFromPyproject(content) {
// Match: version = "2.0.0"
const match = content.match(/^version\s*=\s*"([^"]+)"/m);
if (!match) {
throw new Error('Could not find version in pyproject.toml');
}
return match[1];
}
function updatePackageJsonVersion(packageJsonPath, newVersion) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const oldVersion = packageJson.version;
packageJson.version = newVersion;
fs.writeFileSync(
packageJsonPath,
JSON.stringify(packageJson, null, 2) + '\n',
'utf8'
);
return { oldVersion, newVersion };
}
function main() {
try {
// Read pyproject.toml
const pyprojectContent = fs.readFileSync(PYPROJECT_PATH, 'utf8');
const version = extractVersionFromPyproject(pyprojectContent);
// Update package.json
const { oldVersion, newVersion } = updatePackageJsonVersion(PACKAGE_JSON_PATH, version);
if (oldVersion !== newVersion) {
console.log(`✓ Version synced: ${oldVersion}${newVersion}`);
} else {
console.log(`✓ Version already in sync: ${newVersion}`);
}
process.exit(0);
} catch (error) {
console.error(`✗ Version sync failed: ${error.message}`);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,532 @@
import type { Meta, StoryObj } from '@storybook/web-components';
import { ComponentManager, ComponentUpdate } from './rich-component-system';
import { vannaDesignTokens } from '../styles/vanna-design-tokens.js';
const meta: Meta = {
title: 'Rich Components/Buttons',
parameters: {
layout: 'padded',
backgrounds: {
default: 'dark',
values: [
{ name: 'light', value: '#f5f7fa' },
{ name: 'dark', value: 'rgb(11, 15, 25)' },
],
},
},
};
export default meta;
type Story = StoryObj;
const ensureTokenStyles = () => {
if (document.getElementById('vanna-token-style')) {
return;
}
const style = document.createElement('style');
style.id = 'vanna-token-style';
style.textContent = vannaDesignTokens.cssText.replace(/:host/g, '.vanna-tokens');
document.head.appendChild(style);
};
const createContainer = () => {
ensureTokenStyles();
const container = document.createElement('div');
container.className = 'vanna-tokens';
container.style.cssText = `
padding: var(--vanna-space-5, 20px);
max-width: 800px;
margin: 0 auto;
background: var(--vanna-background-default);
border-radius: var(--vanna-border-radius-lg);
box-shadow: var(--vanna-shadow-md);
`;
return container;
};
const createManager = (container: HTMLElement) => new ComponentManager(container);
const renderComponent = (manager: ComponentManager, component: any) => {
const update: ComponentUpdate = {
operation: 'create',
target_id: component.id,
component,
timestamp: new Date().toISOString(),
} as ComponentUpdate;
manager.processUpdate(update);
};
const withDefaults = (component: any) => ({
layout: { position: 'append', size: {}, z_index: 0, classes: [] },
theme: {},
lifecycle: 'create',
...component,
});
const addMockVannaChat = (container: HTMLElement) => {
// Create a mock vanna-chat element with sendMessage method
const mockVannaChat = document.createElement('div');
mockVannaChat.setAttribute('id', 'mock-vanna-chat');
// Store the original querySelector
const originalQuerySelector = document.querySelector.bind(document);
// Override querySelector to return our mock when looking for vanna-chat
document.querySelector = function(selector: string) {
if (selector === 'vanna-chat') {
return mockVannaChat as any;
}
return originalQuerySelector(selector);
} as any;
// Add sendMessage method that logs to console and shows in UI
(mockVannaChat as any).sendMessage = (message: string) => {
console.log('📤 Button clicked - Message:', message);
// Show a visual feedback in the storybook
const feedback = document.createElement('div');
feedback.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #4CAF50;
color: white;
padding: 12px 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
font-family: monospace;
z-index: 10000;
animation: slideIn 0.3s ease-out;
`;
feedback.textContent = `Message sent: ${message}`;
// Add animation
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
`;
document.head.appendChild(style);
document.body.appendChild(feedback);
setTimeout(() => {
feedback.style.opacity = '0';
feedback.style.transition = 'opacity 0.3s ease-out';
setTimeout(() => feedback.remove(), 300);
}, 2000);
};
container.appendChild(mockVannaChat);
return mockVannaChat;
};
export const SingleButtons: Story = {
render: () => {
const container = createContainer();
const manager = createManager(container);
addMockVannaChat(container);
// Add title
const title = document.createElement('h2');
title.textContent = 'Single Button Components';
title.style.cssText = 'margin-bottom: 20px; color: var(--vanna-text-primary);';
container.appendChild(title);
const buttons = [
withDefaults({
id: 'primary-button',
type: 'button',
data: {
label: 'Primary Action',
action: 'primary_action',
variant: 'primary',
size: 'medium',
},
}),
withDefaults({
id: 'secondary-button',
type: 'button',
data: {
label: 'Save Draft',
action: 'save_draft',
variant: 'secondary',
size: 'medium',
icon: '💾',
icon_position: 'left',
},
}),
withDefaults({
id: 'success-button',
type: 'button',
data: {
label: 'Approve',
action: 'approve',
variant: 'success',
size: 'medium',
icon: '✓',
},
}),
withDefaults({
id: 'warning-button',
type: 'button',
data: {
label: 'Caution',
action: 'warning',
variant: 'warning',
size: 'medium',
icon: '⚠️',
},
}),
withDefaults({
id: 'error-button',
type: 'button',
data: {
label: 'Delete',
action: 'delete',
variant: 'error',
size: 'medium',
icon: '🗑️',
},
}),
withDefaults({
id: 'ghost-button',
type: 'button',
data: {
label: 'Ghost Style',
action: 'ghost',
variant: 'ghost',
icon: '👻',
},
}),
withDefaults({
id: 'link-button',
type: 'button',
data: {
label: 'Learn More',
action: 'learn_more',
variant: 'link',
},
}),
withDefaults({
id: 'loading-button',
type: 'button',
data: {
label: 'Processing...',
action: 'loading',
variant: 'primary',
loading: true,
},
}),
withDefaults({
id: 'disabled-button',
type: 'button',
data: {
label: 'Disabled',
action: 'disabled',
variant: 'secondary',
disabled: true,
},
}),
];
buttons.forEach((component) => {
renderComponent(manager, component);
// Add some spacing
const spacer = document.createElement('div');
spacer.style.height = '12px';
container.appendChild(spacer);
});
// Add instruction
const instruction = document.createElement('p');
instruction.textContent = 'Click any button to see the message it sends (wrapped in square brackets)';
instruction.style.cssText = 'margin-top: 20px; color: var(--vanna-text-secondary); font-style: italic;';
container.appendChild(instruction);
return container;
},
};
export const ButtonSizes: Story = {
render: () => {
const container = createContainer();
const manager = createManager(container);
addMockVannaChat(container);
const title = document.createElement('h2');
title.textContent = 'Button Sizes';
title.style.cssText = 'margin-bottom: 20px; color: var(--vanna-text-primary);';
container.appendChild(title);
const sizes = ['small', 'medium', 'large'];
sizes.forEach((size) => {
const button = withDefaults({
id: `button-${size}`,
type: 'button',
data: {
label: `${size.charAt(0).toUpperCase() + size.slice(1)} Button`,
action: `${size}_action`,
variant: 'primary',
size,
icon: '⭐',
},
});
renderComponent(manager, button);
const spacer = document.createElement('div');
spacer.style.height = '12px';
container.appendChild(spacer);
});
return container;
},
};
export const ButtonGroups: Story = {
render: () => {
const container = createContainer();
const manager = createManager(container);
addMockVannaChat(container);
const title = document.createElement('h2');
title.textContent = 'Button Group Components';
title.style.cssText = 'margin-bottom: 20px; color: var(--vanna-text-primary);';
container.appendChild(title);
// Horizontal action group
const actionGroup = withDefaults({
id: 'action-group',
type: 'button_group',
data: {
buttons: [
{
label: 'Accept',
action: 'accept',
variant: 'success',
icon: '✓',
},
{
label: 'Reject',
action: 'reject',
variant: 'error',
icon: '✗',
},
{
label: 'Cancel',
action: 'cancel',
variant: 'secondary',
},
],
orientation: 'horizontal',
spacing: 'medium',
align: 'left',
},
});
const sectionTitle1 = document.createElement('h3');
sectionTitle1.textContent = 'Horizontal Action Group';
sectionTitle1.style.cssText = 'margin: 20px 0 10px 0; color: var(--vanna-text-primary); font-size: 16px;';
container.appendChild(sectionTitle1);
renderComponent(manager, actionGroup);
// Centered navigation
const navigationGroup = withDefaults({
id: 'navigation-group',
type: 'button_group',
data: {
buttons: [
{
label: 'Back',
action: 'back',
variant: 'ghost',
icon: '←',
},
{
label: 'Continue',
action: 'continue',
variant: 'primary',
icon: '→',
icon_position: 'right',
},
],
orientation: 'horizontal',
spacing: 'large',
align: 'center',
},
});
const sectionTitle2 = document.createElement('h3');
sectionTitle2.textContent = 'Centered Navigation';
sectionTitle2.style.cssText = 'margin: 20px 0 10px 0; color: var(--vanna-text-primary); font-size: 16px;';
container.appendChild(sectionTitle2);
renderComponent(manager, navigationGroup);
// Vertical options
const verticalGroup = withDefaults({
id: 'vertical-group',
type: 'button_group',
data: {
buttons: [
{ label: 'Option 1', action: 'option1', variant: 'secondary' },
{ label: 'Option 2', action: 'option2', variant: 'secondary' },
{ label: 'Option 3', action: 'option3', variant: 'secondary' },
],
orientation: 'vertical',
spacing: 'small',
align: 'left',
},
});
const sectionTitle3 = document.createElement('h3');
sectionTitle3.textContent = 'Vertical Options';
sectionTitle3.style.cssText = 'margin: 20px 0 10px 0; color: var(--vanna-text-primary); font-size: 16px;';
container.appendChild(sectionTitle3);
renderComponent(manager, verticalGroup);
// Toolbar
const toolbarGroup = withDefaults({
id: 'toolbar-group',
type: 'button_group',
data: {
buttons: [
{
label: 'New',
action: 'new',
variant: 'primary',
icon: '',
size: 'small',
},
{
label: 'Edit',
action: 'edit',
variant: 'secondary',
icon: '✏️',
size: 'small',
},
{
label: 'Delete',
action: 'delete',
variant: 'error',
icon: '🗑️',
size: 'small',
},
{
label: 'Share',
action: 'share',
variant: 'ghost',
icon: '🔗',
size: 'small',
},
],
orientation: 'horizontal',
spacing: 'small',
align: 'left',
},
});
const sectionTitle4 = document.createElement('h3');
sectionTitle4.textContent = 'Toolbar (Small Buttons)';
sectionTitle4.style.cssText = 'margin: 20px 0 10px 0; color: var(--vanna-text-primary); font-size: 16px;';
container.appendChild(sectionTitle4);
renderComponent(manager, toolbarGroup);
// Full width confirmation
const confirmationGroup = withDefaults({
id: 'confirmation-group',
type: 'button_group',
data: {
buttons: [
{ label: 'Yes', action: 'yes', variant: 'success' },
{ label: 'No', action: 'no', variant: 'error' },
],
orientation: 'horizontal',
spacing: 'medium',
align: 'space-between',
full_width: true,
},
});
const sectionTitle5 = document.createElement('h3');
sectionTitle5.textContent = 'Full Width Confirmation';
sectionTitle5.style.cssText = 'margin: 20px 0 10px 0; color: var(--vanna-text-primary); font-size: 16px;';
container.appendChild(sectionTitle5);
renderComponent(manager, confirmationGroup);
// Add instruction
const instruction = document.createElement('p');
instruction.textContent = 'Click any button in the groups to see the message it sends';
instruction.style.cssText = 'margin-top: 20px; color: var(--vanna-text-secondary); font-style: italic;';
container.appendChild(instruction);
return container;
},
};
export const InteractiveDemo: Story = {
render: () => {
const container = createContainer();
const manager = createManager(container);
addMockVannaChat(container);
const title = document.createElement('h2');
title.textContent = 'Interactive Button Demo';
title.style.cssText = 'margin-bottom: 20px; color: var(--vanna-text-primary);';
container.appendChild(title);
const description = document.createElement('p');
description.textContent = 'This demo shows how buttons send messages with their labels wrapped in square brackets.';
description.style.cssText = 'margin-bottom: 20px; color: var(--vanna-text-secondary);';
container.appendChild(description);
// Simple choice buttons
const choiceGroup = withDefaults({
id: 'choice-group',
type: 'button_group',
data: {
buttons: [
{ label: 'Okay', action: 'okay', variant: 'primary' },
{ label: 'Not now', action: 'not_now', variant: 'secondary' },
{ label: 'Never', action: 'never', variant: 'ghost' },
],
orientation: 'horizontal',
spacing: 'medium',
align: 'center',
},
});
renderComponent(manager, choiceGroup);
const codeExample = document.createElement('pre');
codeExample.textContent = `// When you click "Okay", the message sent is: [Okay]
// When you click "Not now", the message sent is: [Not now]
// When you click "Never", the message sent is: [Never]`;
codeExample.style.cssText = `
margin-top: 20px;
padding: 12px;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
color: #a0aec0;
font-size: 12px;
font-family: 'Courier New', monospace;
overflow-x: auto;
`;
container.appendChild(codeExample);
return container;
},
};

View File

@@ -0,0 +1,564 @@
import type { Meta, StoryObj } from '@storybook/web-components';
import { ComponentManager, ComponentUpdate } from './rich-component-system';
import { vannaDesignTokens } from '../styles/vanna-design-tokens.js';
import { richComponentStyleText } from '../styles/rich-component-styles.js';
const meta: Meta = {
title: 'Rich Components/DataFrame',
parameters: {
layout: 'padded',
backgrounds: {
default: 'light',
values: [
{ name: 'light', value: '#f5f7fa' },
{ name: 'dark', value: 'rgb(11, 15, 25)' },
],
},
},
argTypes: {
theme: {
control: { type: 'select' },
options: ['light', 'dark'],
},
striped: {
control: { type: 'boolean' },
},
bordered: {
control: { type: 'boolean' },
},
compact: {
control: { type: 'boolean' },
},
searchable: {
control: { type: 'boolean' },
},
sortable: {
control: { type: 'boolean' },
},
exportable: {
control: { type: 'boolean' },
},
},
};
export default meta;
type Story = StoryObj;
const ensureTokenStyles = () => {
if (document.getElementById('vanna-token-style')) {
return;
}
const style = document.createElement('style');
style.id = 'vanna-token-style';
style.textContent = vannaDesignTokens.cssText.replace(/:host/g, '.vanna-tokens');
document.head.appendChild(style);
};
const ensureRichComponentStyles = () => {
if (document.getElementById('vanna-rich-component-styles')) {
return;
}
const style = document.createElement('style');
style.id = 'vanna-rich-component-styles';
style.textContent = richComponentStyleText;
document.head.appendChild(style);
};
const createContainer = () => {
ensureTokenStyles();
ensureRichComponentStyles();
const container = document.createElement('div');
container.className = 'vanna-tokens';
container.style.cssText = `
padding: var(--vanna-space-5, 20px);
max-width: 1200px;
margin: 0 auto;
background: var(--vanna-background-default, #0b0f19);
border-radius: var(--vanna-border-radius-lg, 8px);
box-shadow: var(--vanna-shadow-md, 0 4px 6px rgba(0, 0, 0, 0.1));
color: var(--vanna-foreground-default, #ffffff);
`;
// Add some additional DataFrame-specific debugging styles
const additionalStyles = document.createElement('style');
additionalStyles.textContent = `
/* Ensure DataFrame styles are applied with higher specificity */
.vanna-tokens {
font-family: var(--vanna-font-family-default, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif) !important;
}
.vanna-tokens .rich-dataframe {
background: var(--vanna-background-default, #0b0f19) !important;
border: 1px solid var(--vanna-outline-default, #333) !important;
border-radius: var(--vanna-border-radius-lg, 8px) !important;
overflow: hidden !important;
font-family: var(--vanna-font-family-default, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif) !important;
}
.vanna-tokens .dataframe-table {
width: 100% !important;
border-collapse: collapse !important;
font-size: 0.875rem !important;
color: var(--vanna-foreground-default, #ffffff) !important;
font-family: var(--vanna-font-family-default, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif) !important;
}
.vanna-tokens .dataframe-table th {
background: var(--vanna-background-higher, #1a1f2e) !important;
color: var(--vanna-foreground-default, #ffffff) !important;
font-weight: 600 !important;
text-align: left !important;
padding: 12px 16px !important;
border-bottom: 2px solid var(--vanna-outline-default, #333) !important;
font-family: var(--vanna-font-family-default, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif) !important;
}
.vanna-tokens .dataframe-table td {
padding: 12px 16px !important;
border-bottom: 1px solid var(--vanna-outline-dimmer, #222) !important;
color: var(--vanna-foreground-default, #ffffff) !important;
font-family: var(--vanna-font-family-default, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif) !important;
}
.vanna-tokens .dataframe-table.striped tbody tr:nth-child(even) {
background: rgba(255, 255, 255, 0.02) !important;
}
.vanna-tokens .dataframe-header {
padding: 16px 20px !important;
background: var(--vanna-background-higher, #1a1f2e) !important;
border-bottom: 1px solid var(--vanna-outline-default, #333) !important;
font-family: var(--vanna-font-family-default, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif) !important;
}
.vanna-tokens .dataframe-title {
font-family: var(--vanna-font-family-default, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif) !important;
color: var(--vanna-foreground-default, #ffffff) !important;
font-weight: 600 !important;
}
.vanna-tokens .dataframe-description {
font-family: var(--vanna-font-family-default, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif) !important;
color: var(--vanna-foreground-dimmer, #b1bac4) !important;
}
.vanna-tokens .dataframe-actions {
padding: 12px 20px !important;
background: var(--vanna-background-default, #0b0f19) !important;
border-bottom: 1px solid var(--vanna-outline-dimmer, #222) !important;
display: flex !important;
justify-content: space-between !important;
align-items: center !important;
gap: 12px !important;
}
.vanna-tokens .search-input {
width: 100% !important;
padding: 8px 12px !important;
border: 1px solid var(--vanna-outline-default, #333) !important;
border-radius: 6px !important;
background: var(--vanna-background-default, #0b0f19) !important;
color: var(--vanna-foreground-default, #ffffff) !important;
font-size: 0.875rem !important;
font-family: var(--vanna-font-family-default, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif) !important;
}
.vanna-tokens .export-btn {
padding: 8px 12px !important;
border: 1px solid var(--vanna-outline-default, #333) !important;
border-radius: 6px !important;
background: var(--vanna-background-default, #0b0f19) !important;
color: var(--vanna-foreground-default, #ffffff) !important;
cursor: pointer !important;
font-size: 0.875rem !important;
font-family: var(--vanna-font-family-default, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif) !important;
}
`;
document.head.appendChild(additionalStyles);
return container;
};
const createManager = (container: HTMLElement) => new ComponentManager(container);
const renderComponent = (manager: ComponentManager, component: any) => {
const update: ComponentUpdate = {
operation: 'create',
target_id: component.id,
component,
timestamp: new Date().toISOString(),
} as ComponentUpdate;
manager.processUpdate(update);
};
const withDefaults = (component: any) => ({
layout: { position: 'append', size: {}, z_index: 0, classes: [] },
theme: {},
lifecycle: 'create',
timestamp: new Date().toISOString(),
visible: true,
interactive: false,
children: [],
...component,
});
// Sample data sets
const employeeData = [
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', age: 28, city: 'New York', salary: 75000, active: true, department: 'Engineering' },
{ id: 2, name: 'Bob Smith', email: 'bob@example.com', age: 34, city: 'San Francisco', salary: 85000, active: true, department: 'Product' },
{ id: 3, name: 'Carol Davis', email: 'carol@example.com', age: 29, city: 'Chicago', salary: 70000, active: false, department: 'Design' },
{ id: 4, name: 'David Wilson', email: 'david@example.com', age: 42, city: 'Austin', salary: 90000, active: true, department: 'Engineering' },
{ id: 5, name: 'Eve Brown', email: 'eve@example.com', age: 31, city: 'Seattle', salary: 80000, active: true, department: 'Marketing' },
{ id: 6, name: 'Frank Miller', email: 'frank@example.com', age: 38, city: 'Boston', salary: 95000, active: false, department: 'Sales' },
{ id: 7, name: 'Grace Lee', email: 'grace@example.com', age: 26, city: 'Denver', salary: 65000, active: true, department: 'HR' },
{ id: 8, name: 'Henry Taylor', email: 'henry@example.com', age: 33, city: 'Portland', salary: 72000, active: true, department: 'Engineering' },
{ id: 9, name: 'Ivy Chen', email: 'ivy@example.com', age: 27, city: 'Los Angeles', salary: 78000, active: true, department: 'Product' },
{ id: 10, name: 'Jack Anderson', email: 'jack@example.com', age: 35, city: 'Miami', salary: 82000, active: false, department: 'Finance' },
];
const sqlQueryData = [
{ TrackId: 1, Name: 'For Those About To Rock (We Salute You)', AlbumId: 1, MediaTypeId: 1, GenreId: 1, Composer: 'Angus Young, Malcolm Young, Brian Johnson', Milliseconds: 343719, Bytes: 11170334, UnitPrice: 0.99 },
{ TrackId: 2, Name: 'Balls to the Wall', AlbumId: 2, MediaTypeId: 2, GenreId: 1, Composer: null, Milliseconds: 342562, Bytes: 5510424, UnitPrice: 0.99 },
{ TrackId: 3, Name: 'Fast As a Shark', AlbumId: 3, MediaTypeId: 2, GenreId: 1, Composer: 'F. Baltes, S. Kaufman, U. Dirkscneider & W. Hoffman', Milliseconds: 230619, Bytes: 3990994, UnitPrice: 0.99 },
{ TrackId: 4, Name: 'Restless and Wild', AlbumId: 3, MediaTypeId: 2, GenreId: 1, Composer: 'F. Baltes, R.A. Smith-Diesel, S. Kaufman, U. Dirkscneider & W. Hoffman', Milliseconds: 252051, Bytes: 4331779, UnitPrice: 0.99 },
{ TrackId: 5, Name: 'Princess of the Dawn', AlbumId: 3, MediaTypeId: 2, GenreId: 1, Composer: 'Deaffy & R.A. Smith-Diesel', Milliseconds: 375418, Bytes: 6290521, UnitPrice: 0.99 },
{ TrackId: 6, Name: 'Put The Finger On You', AlbumId: 1, MediaTypeId: 1, GenreId: 1, Composer: 'Angus Young, Malcolm Young, Brian Johnson', Milliseconds: 205662, Bytes: 6713451, UnitPrice: 0.99 },
{ TrackId: 7, Name: "Let's Get It Up", AlbumId: 1, MediaTypeId: 1, GenreId: 1, Composer: 'Angus Young, Malcolm Young, Brian Johnson', Milliseconds: 233926, Bytes: 7636561, UnitPrice: 0.99 },
{ TrackId: 8, Name: 'Inject The Venom', AlbumId: 1, MediaTypeId: 1, GenreId: 1, Composer: 'Angus Young, Malcolm Young, Brian Johnson', Milliseconds: 210834, Bytes: 6852860, UnitPrice: 0.99 },
];
export const BasicDataFrame: Story = {
render: (args) => {
const container = createContainer();
const manager = createManager(container);
const component = withDefaults({
id: 'basic-dataframe',
type: 'dataframe',
data: {
data: employeeData.slice(0, 5),
columns: ['id', 'name', 'email', 'age', 'city', 'department'],
title: 'Employee Records',
description: 'Basic employee data with essential information',
row_count: 5,
column_count: 6,
striped: args.striped ?? true,
bordered: args.bordered ?? true,
compact: args.compact ?? false,
searchable: args.searchable ?? false,
sortable: args.sortable ?? false,
exportable: args.exportable ?? false,
column_types: {
id: 'number',
name: 'string',
email: 'string',
age: 'number',
city: 'string',
department: 'string'
}
},
});
renderComponent(manager, component);
return container;
},
args: {
striped: true,
bordered: true,
compact: false,
searchable: false,
sortable: false,
exportable: false,
},
};
export const InteractiveDataFrame: Story = {
render: (args) => {
const container = createContainer();
const manager = createManager(container);
const component = withDefaults({
id: 'interactive-dataframe',
type: 'dataframe',
data: {
data: employeeData,
columns: ['id', 'name', 'email', 'age', 'city', 'salary', 'active', 'department'],
title: 'Interactive Employee Database',
description: 'Full dataset with search, sort, and export functionality',
row_count: employeeData.length,
column_count: 8,
striped: args.striped ?? true,
bordered: args.bordered ?? true,
compact: args.compact ?? false,
searchable: args.searchable ?? true,
sortable: args.sortable ?? true,
exportable: args.exportable ?? true,
max_rows_displayed: 8,
column_types: {
id: 'number',
name: 'string',
email: 'string',
age: 'number',
city: 'string',
salary: 'number',
active: 'boolean',
department: 'string'
}
},
});
renderComponent(manager, component);
return container;
},
args: {
striped: true,
bordered: true,
compact: false,
searchable: true,
sortable: true,
exportable: true,
},
};
export const SQLQueryResults: Story = {
render: (args) => {
const container = createContainer();
const manager = createManager(container);
const component = withDefaults({
id: 'sql-dataframe',
type: 'dataframe',
data: {
data: sqlQueryData,
columns: ['TrackId', 'Name', 'AlbumId', 'MediaTypeId', 'GenreId', 'Composer', 'Milliseconds', 'Bytes', 'UnitPrice'],
title: 'SQL Query Results',
description: 'SELECT * FROM Track LIMIT 8',
row_count: sqlQueryData.length,
column_count: 9,
striped: args.striped ?? true,
bordered: args.bordered ?? true,
compact: args.compact ?? false,
searchable: args.searchable ?? true,
sortable: args.sortable ?? true,
exportable: args.exportable ?? true,
column_types: {
TrackId: 'number',
Name: 'string',
AlbumId: 'number',
MediaTypeId: 'number',
GenreId: 'number',
Composer: 'string',
Milliseconds: 'number',
Bytes: 'number',
UnitPrice: 'number'
}
},
});
renderComponent(manager, component);
return container;
},
args: {
striped: true,
bordered: true,
compact: false,
searchable: true,
sortable: true,
exportable: true,
},
};
export const CompactView: Story = {
render: (args) => {
const container = createContainer();
const manager = createManager(container);
const component = withDefaults({
id: 'compact-dataframe',
type: 'dataframe',
data: {
data: employeeData.slice(0, 6),
columns: ['id', 'name', 'city', 'active'],
title: 'Compact Employee View',
description: 'Space-efficient display with essential columns only',
row_count: 6,
column_count: 4,
striped: args.striped ?? true,
bordered: args.bordered ?? false,
compact: args.compact ?? true,
searchable: args.searchable ?? false,
sortable: args.sortable ?? true,
exportable: args.exportable ?? false,
column_types: {
id: 'number',
name: 'string',
city: 'string',
active: 'boolean'
}
},
});
renderComponent(manager, component);
return container;
},
args: {
striped: true,
bordered: false,
compact: true,
searchable: false,
sortable: true,
exportable: false,
},
};
export const EmptyDataFrame: Story = {
render: () => {
const container = createContainer();
const manager = createManager(container);
const component = withDefaults({
id: 'empty-dataframe',
type: 'dataframe',
data: {
data: [],
columns: [],
title: 'No Data Available',
description: 'This dataset contains no records',
row_count: 0,
column_count: 0,
},
});
renderComponent(manager, component);
return container;
},
};
export const LargeDataset: Story = {
render: (args) => {
const container = createContainer();
const manager = createManager(container);
// Generate a larger dataset
const largeData = Array.from({ length: 50 }, (_, i) => ({
id: i + 1,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
score: Math.floor(Math.random() * 100),
category: ['A', 'B', 'C'][i % 3],
active: Math.random() > 0.3,
created_date: new Date(2024, Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1).toISOString().split('T')[0]
}));
const component = withDefaults({
id: 'large-dataframe',
type: 'dataframe',
data: {
data: largeData,
columns: ['id', 'name', 'email', 'score', 'category', 'active', 'created_date'],
title: 'Large Dataset',
description: '50 records with pagination and search',
row_count: largeData.length,
column_count: 7,
striped: args.striped ?? true,
bordered: args.bordered ?? true,
compact: args.compact ?? false,
searchable: args.searchable ?? true,
sortable: args.sortable ?? true,
exportable: args.exportable ?? true,
max_rows_displayed: 15,
column_types: {
id: 'number',
name: 'string',
email: 'string',
score: 'number',
category: 'string',
active: 'boolean',
created_date: 'date'
}
},
});
renderComponent(manager, component);
return container;
},
args: {
striped: true,
bordered: true,
compact: false,
searchable: true,
sortable: true,
exportable: true,
},
};
export const DataTypesShowcase: Story = {
render: () => {
const container = createContainer();
const manager = createManager(container);
const typesData = [
{
id: 1,
name: 'Alice',
score: 95.5,
active: true,
created: '2024-01-15',
notes: 'Excellent performance',
tags: null
},
{
id: 2,
name: 'Bob',
score: 87.2,
active: false,
created: '2024-02-20',
notes: 'Good but needs improvement',
tags: 'priority,review'
},
{
id: 3,
name: 'Carol',
score: 92.8,
active: true,
created: '2024-03-10',
notes: null,
tags: 'star-performer'
},
];
const component = withDefaults({
id: 'types-dataframe',
type: 'dataframe',
data: {
data: typesData,
columns: ['id', 'name', 'score', 'active', 'created', 'notes', 'tags'],
title: 'Data Types Showcase',
description: 'Demonstrates different column data types and null handling',
row_count: typesData.length,
column_count: 7,
striped: true,
bordered: true,
searchable: true,
sortable: true,
exportable: true,
column_types: {
id: 'number',
name: 'string',
score: 'number',
active: 'boolean',
created: 'date',
notes: 'string',
tags: 'string'
}
},
});
renderComponent(manager, component);
return container;
},
};

View File

@@ -0,0 +1,272 @@
import type { Meta, StoryObj } from '@storybook/web-components';
import { html } from 'lit';
import './plotly-chart';
const meta: Meta = {
title: 'Rich Components/Plotly Chart',
component: 'plotly-chart',
parameters: {
layout: 'padded',
backgrounds: {
default: 'light',
values: [
{ name: 'dark', value: 'rgb(11, 15, 25)' },
{ name: 'light', value: '#f5f7fa' },
],
},
},
argTypes: {
theme: {
control: 'select',
options: ['light', 'dark']
},
loading: { control: 'boolean' },
error: { control: 'text' },
},
};
export default meta;
type Story = StoryObj;
export const LineChart: Story = {
args: {
theme: 'light',
loading: false,
error: '',
},
render: (args) => html`
<div style="max-width: 800px; margin: 0 auto;">
<plotly-chart
theme=${args.theme}
?loading=${args.loading}
error=${args.error}
.data=${[
{
x: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
y: [20, 14, 23, 25, 22, 16],
type: 'scatter',
mode: 'lines+markers',
name: 'Sales',
line: { color: 'rgb(0, 123, 255)' }
}
]}
.layout=${{
xaxis: { title: 'Month' },
yaxis: { title: 'Sales (in thousands)' }
}}>
</plotly-chart>
</div>
`,
};
export const BarChart: Story = {
args: {
theme: 'light',
},
render: (args) => html`
<div style="max-width: 800px; margin: 0 auto;">
<plotly-chart
theme=${args.theme}
.data=${[
{
x: ['Product A', 'Product B', 'Product C', 'Product D'],
y: [45, 32, 28, 35],
type: 'bar',
name: 'Revenue',
marker: {
color: ['rgb(16, 185, 129)', 'rgb(0, 123, 255)', 'rgb(245, 158, 11)', 'rgb(239, 68, 68)']
}
}
]}
.layout=${{
xaxis: { title: 'Products' },
yaxis: { title: 'Revenue ($M)' }
}}>
</plotly-chart>
</div>
`,
};
export const ScatterPlot: Story = {
args: {
theme: 'light',
},
render: (args) => html`
<div style="max-width: 800px; margin: 0 auto;">
<plotly-chart
theme=${args.theme}
.data=${[
{
x: [85, 78, 92, 88, 76, 95, 82, 89, 93, 79],
y: [450, 320, 580, 490, 280, 650, 380, 520, 610, 310],
type: 'scatter',
mode: 'markers',
name: 'Business Units',
marker: {
size: 12,
color: 'rgb(0, 123, 255)',
opacity: 0.7
}
}
]}
.layout=${{
xaxis: { title: 'Customer Satisfaction Score' },
yaxis: { title: 'Revenue ($K)' }
}}>
</plotly-chart>
</div>
`,
};
export const MultipleLines: Story = {
args: {
theme: 'light',
},
render: (args) => html`
<div style="max-width: 800px; margin: 0 auto;">
<plotly-chart
theme=${args.theme}
.data=${[
{
x: ['Q1', 'Q2', 'Q3', 'Q4'],
y: [85, 88, 92, 89],
type: 'scatter',
mode: 'lines+markers',
name: 'User Engagement',
line: { color: 'rgb(16, 185, 129)' }
},
{
x: ['Q1', 'Q2', 'Q3', 'Q4'],
y: [65, 72, 78, 81],
type: 'scatter',
mode: 'lines+markers',
name: 'Conversion Rate',
line: { color: 'rgb(0, 123, 255)' }
},
{
x: ['Q1', 'Q2', 'Q3', 'Q4'],
y: [42, 48, 55, 58],
type: 'scatter',
mode: 'lines+markers',
name: 'Customer Retention',
line: { color: 'rgb(245, 158, 11)' }
}
]}
.layout=${{
xaxis: { title: 'Quarter' },
yaxis: { title: 'Percentage (%)' }
}}>
</plotly-chart>
</div>
`,
};
export const LoadingState: Story = {
args: {
theme: 'light',
loading: true,
},
render: (args) => html`
<div style="max-width: 800px; margin: 0 auto;">
<plotly-chart
theme=${args.theme}
?loading=${args.loading}
.data=${[]}>
</plotly-chart>
</div>
`,
};
export const ErrorState: Story = {
args: {
theme: 'light',
error: 'Failed to load chart data from API',
},
render: (args) => html`
<div style="max-width: 800px; margin: 0 auto;">
<plotly-chart
theme=${args.theme}
error=${args.error}
.data=${[]}>
</plotly-chart>
</div>
`,
};
export const LightTheme: Story = {
args: {
theme: 'light',
},
parameters: {
backgrounds: { default: 'light' }
},
render: (args) => html`
<div style="max-width: 800px; margin: 0 auto;">
<plotly-chart
theme=${args.theme}
.data=${[
{
x: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
y: [20, 14, 23, 25, 22, 16],
type: 'scatter',
mode: 'lines+markers',
name: 'Sales',
line: { color: 'rgb(0, 123, 255)' }
}
]}
.layout=${{
xaxis: { title: 'Month' },
yaxis: { title: 'Sales (in thousands)' }
}}>
</plotly-chart>
</div>
`,
};
export const CustomLayout: Story = {
args: {
theme: 'light',
},
render: (args) => html`
<div style="max-width: 800px; margin: 0 auto;">
<plotly-chart
theme=${args.theme}
.data=${[
{
x: [1, 2, 3, 4, 5],
y: [10, 11, 12, 13, 14],
type: 'scatter',
mode: 'lines',
name: 'Trend A',
line: { color: 'rgb(16, 185, 129)', width: 3 }
},
{
x: [1, 2, 3, 4, 5],
y: [8, 9, 10, 11, 12],
type: 'scatter',
mode: 'lines',
name: 'Trend B',
line: { color: 'rgb(239, 68, 68)', width: 3, dash: 'dash' }
}
]}
.layout=${{
title: {
text: 'Custom Styled Chart',
font: { size: 18 }
},
xaxis: {
title: 'Time Period',
gridcolor: 'rgba(255, 255, 255, 0.1)'
},
yaxis: {
title: 'Value',
gridcolor: 'rgba(255, 255, 255, 0.1)'
},
height: 500,
showlegend: true
}}>
</plotly-chart>
</div>
`,
};

View File

@@ -0,0 +1,205 @@
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { vannaDesignTokens } from '../styles/vanna-design-tokens.js';
import Plotly from 'plotly.js-dist-min';
export interface PlotlyData {
x?: any[];
y?: any[];
type?: any;
mode?: any;
name?: string;
marker?: any;
line?: any;
[key: string]: any;
}
export interface PlotlyLayout {
title?: any;
xaxis?: any;
yaxis?: any;
font?: any;
paper_bgcolor?: string;
plot_bgcolor?: string;
margin?: any;
showlegend?: boolean;
height?: number;
width?: number;
modebar?: any;
[key: string]: any;
}
@customElement('plotly-chart')
export class PlotlyChart extends LitElement {
static styles = [
vannaDesignTokens,
css`
:host {
display: block;
font-family: var(--vanna-font-family-default);
width: 100%;
height: 100%;
}
.plotly-div {
width: 100%;
min-height: 400px;
}
/* Plotly layering fix for Shadow DOM */
.plotly-div,
.plotly-div .js-plotly-plot,
.plotly-div .plot-container,
.plotly-div .svg-container {
position: relative;
width: 100%;
height: 100%;
}
.plotly-div svg.main-svg {
position: absolute;
top: 0;
left: 0;
}
.plotly-div .hoverlayer {
pointer-events: none;
}
.error-message {
padding: var(--vanna-space-4);
color: var(--vanna-accent-negative-default);
text-align: center;
font-style: italic;
}
.loading-message {
padding: var(--vanna-space-4);
color: var(--vanna-foreground-dimmer);
text-align: center;
font-style: italic;
}
`
];
@property({ type: Array }) data: PlotlyData[] = [];
@property({ type: Object }) layout: PlotlyLayout = {};
@property({ type: Object }) config = {};
@property({ type: Boolean }) loading = false;
@property() error = '';
@property() theme: 'light' | 'dark' = 'dark';
private plotlyDiv?: HTMLElement;
private resizeObserver?: ResizeObserver;
firstUpdated() {
this.plotlyDiv = this.shadowRoot?.querySelector('.plotly-div') as HTMLElement;
this._renderChart();
this._setupResizeObserver();
}
disconnectedCallback() {
super.disconnectedCallback();
this.resizeObserver?.disconnect();
}
private _setupResizeObserver() {
if (!this.plotlyDiv) return;
this.resizeObserver = new ResizeObserver(() => {
if (this.plotlyDiv && this.data.length > 0) {
const width = this.plotlyDiv.offsetWidth;
Plotly.relayout(this.plotlyDiv, { width });
}
});
this.resizeObserver.observe(this.plotlyDiv);
}
updated(changedProperties: Map<string | number | symbol, unknown>) {
if (changedProperties.has('data') || changedProperties.has('layout') || changedProperties.has('theme')) {
this._renderChart();
}
}
private _getDefaultLayout(): PlotlyLayout {
const isDark = this.theme === 'dark';
// Start with layout from backend (which may include white background)
const mergedLayout = {
...this.layout,
// Only add font/modebar if not already set by backend
font: this.layout.font || {
family: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
color: isDark ? 'rgb(242, 244, 247)' : 'rgb(17, 24, 39)',
size: 12
},
modebar: this.layout.modebar || {
bgcolor: isDark ? 'rgba(21, 26, 38, 0.8)' : 'rgba(255, 255, 255, 0.8)',
color: isDark ? 'rgb(177, 186, 196)' : 'rgb(75, 85, 99)',
activecolor: isDark ? 'rgb(242, 244, 247)' : 'rgb(17, 24, 39)',
orientation: 'h'
},
// Set explicit dimensions for Shadow DOM compatibility
autosize: false,
width: this.layout.width || undefined,
height: this.layout.height || 400,
};
// If backend didn't set background colors, use transparent
if (!this.layout.paper_bgcolor) {
mergedLayout.paper_bgcolor = 'transparent';
}
if (!this.layout.plot_bgcolor) {
mergedLayout.plot_bgcolor = 'transparent';
}
return mergedLayout;
}
private _getDefaultConfig() {
return {
responsive: true,
displayModeBar: false,
...this.config
};
}
private async _renderChart() {
// Re-query plotlyDiv in case it wasn't available at firstUpdated
if (!this.plotlyDiv) {
this.plotlyDiv = this.shadowRoot?.querySelector('.plotly-div') as HTMLElement;
}
if (!this.plotlyDiv || this.loading || this.error || this.data.length === 0) {
return;
}
try {
const layout = this._getDefaultLayout();
const config = this._getDefaultConfig();
await Plotly.newPlot(this.plotlyDiv, this.data, layout, config);
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to render chart';
}
}
render() {
return html`
${this.loading ? html`
<div class="loading-message">Loading chart...</div>
` : this.error ? html`
<div class="error-message">Error: ${this.error}</div>
` : html`
<div class="plotly-div"></div>
`}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'plotly-chart': PlotlyChart;
}
}

View File

@@ -0,0 +1,187 @@
import type { Meta, StoryObj } from '@storybook/web-components';
import { html } from 'lit';
import './rich-card';
const meta: Meta = {
title: 'Rich Components/Rich Card',
component: 'rich-card',
parameters: {
layout: 'padded',
backgrounds: {
default: 'light',
values: [
{ name: 'dark', value: 'rgb(11, 15, 25)' },
{ name: 'light', value: '#f5f7fa' },
],
},
},
argTypes: {
title: { control: 'text' },
subtitle: { control: 'text' },
content: { control: 'text' },
icon: { control: 'text' },
status: {
control: 'select',
options: ['info', 'success', 'warning', 'error']
},
collapsible: { control: 'boolean' },
collapsed: { control: 'boolean' },
},
};
export default meta;
type Story = StoryObj;
export const Default: Story = {
args: {
title: 'Sample Card',
subtitle: 'This is a subtitle',
content: 'This is the content of the card. It can contain any text or HTML.',
status: 'info',
collapsible: false,
collapsed: false,
},
render: (args) => html`
<div style="max-width: 600px; margin: 0 auto;">
<rich-card
title=${args.title}
subtitle=${args.subtitle}
content=${args.content}
icon=${args.icon}
status=${args.status}
?collapsible=${args.collapsible}
?collapsed=${args.collapsed}>
</rich-card>
</div>
`,
};
export const WithIcon: Story = {
args: {
title: 'Card with Icon',
subtitle: 'Featuring an emoji icon',
content: 'This card demonstrates how icons work with the rich card component.',
icon: '🚀',
status: 'success',
collapsible: false,
},
render: (args) => html`
<div style="max-width: 600px; margin: 0 auto;">
<rich-card
title=${args.title}
subtitle=${args.subtitle}
content=${args.content}
icon=${args.icon}
status=${args.status}
?collapsible=${args.collapsible}>
</rich-card>
</div>
`,
};
export const WithActions: Story = {
args: {
title: 'Interactive Card',
subtitle: 'With action buttons',
content: 'This card includes action buttons that can trigger events.',
status: 'info',
collapsible: false,
},
render: (args) => html`
<div style="max-width: 600px; margin: 0 auto;">
<rich-card
title=${args.title}
subtitle=${args.subtitle}
content=${args.content}
status=${args.status}
?collapsible=${args.collapsible}
.actions=${[
{ label: 'Primary Action', action: 'primary', variant: 'primary' },
{ label: 'Secondary Action', action: 'secondary', variant: 'secondary' }
]}
@card-action=${(e: CustomEvent) => {
console.log('Card action:', e.detail.action);
alert(`Action triggered: ${e.detail.action}`);
}}>
</rich-card>
</div>
`,
};
export const Collapsible: Story = {
args: {
title: 'Collapsible Card',
subtitle: 'Click to expand/collapse',
content: 'This content can be hidden by clicking the toggle button in the header.',
status: 'warning',
collapsible: true,
collapsed: false,
},
render: (args) => html`
<div style="max-width: 600px; margin: 0 auto;">
<rich-card
title=${args.title}
subtitle=${args.subtitle}
content=${args.content}
status=${args.status}
?collapsible=${args.collapsible}
?collapsed=${args.collapsed}>
</rich-card>
</div>
`,
};
export const StatusVariants: Story = {
render: () => html`
<div style="max-width: 600px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px;">
<rich-card
title="Info Status"
content="This card shows the info status variant."
status="info">
</rich-card>
<rich-card
title="Success Status"
content="This card shows the success status variant."
status="success">
</rich-card>
<rich-card
title="Warning Status"
content="This card shows the warning status variant."
status="warning">
</rich-card>
<rich-card
title="Error Status"
content="This card shows the error status variant."
status="error">
</rich-card>
</div>
`,
};
export const LightTheme: Story = {
args: {
title: 'Light Theme Card',
subtitle: 'Styled for light backgrounds',
content: 'This card is displayed with the light theme variant.',
icon: '☀️',
status: 'success',
},
parameters: {
backgrounds: { default: 'light' }
},
render: (args) => html`
<div style="max-width: 600px; margin: 0 auto;">
<rich-card
theme="light"
title=${args.title}
subtitle=${args.subtitle}
content=${args.content}
icon=${args.icon}
status=${args.status}>
</rich-card>
</div>
`,
};

View File

@@ -0,0 +1,309 @@
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { vannaDesignTokens } from '../styles/vanna-design-tokens.js';
export interface CardAction {
label: string;
action: string;
variant?: 'primary' | 'secondary';
}
@customElement('rich-card')
export class RichCard extends LitElement {
static styles = [
vannaDesignTokens,
css`
:host {
display: block;
margin-bottom: var(--vanna-space-4);
font-family: var(--vanna-font-family-default);
}
.card {
border: 1px solid var(--vanna-outline-default);
border-radius: var(--vanna-border-radius-lg);
background: var(--vanna-background-default);
box-shadow: var(--vanna-shadow-sm);
overflow: hidden;
transition: box-shadow var(--vanna-duration-200) ease;
}
.card:hover {
box-shadow: var(--vanna-shadow-md);
}
.card-header {
display: flex;
align-items: center;
padding: var(--vanna-space-4) var(--vanna-space-5);
background: var(--vanna-background-higher);
border-bottom: 1px solid var(--vanna-outline-default);
gap: var(--vanna-space-3);
}
.card-header.collapsible {
cursor: pointer;
}
.card-icon {
font-size: 1.25rem;
display: flex;
align-items: center;
}
.card-title-section {
flex: 1;
}
.card-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--vanna-foreground-default);
}
.card-subtitle {
margin: var(--vanna-space-1) 0 0 0;
font-size: 0.875rem;
color: var(--vanna-foreground-dimmer);
}
.card-status {
padding: var(--vanna-space-1) var(--vanna-space-2);
border-radius: var(--vanna-border-radius-md);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.card-status.status-success {
background: #d4edda;
color: #155724;
}
.card-status.status-warning {
background: #fff3cd;
color: #856404;
}
.card-status.status-error {
background: #f8d7da;
color: #721c24;
}
.card-status.status-info {
background: #d1ecf1;
color: #0c5460;
}
.card-toggle {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
color: var(--vanna-foreground-dimmer);
padding: var(--vanna-space-1);
border-radius: var(--vanna-border-radius-sm);
transition: background-color var(--vanna-duration-200) ease;
}
.card-toggle:hover {
background: var(--vanna-background-root);
}
.card-content {
padding: var(--vanna-space-4) var(--vanna-space-5);
line-height: 1.5;
color: var(--vanna-foreground-default);
transition: all var(--vanna-duration-200) ease;
overflow: hidden;
}
.card-content.collapsed {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
}
.card-content h1,
.card-content h2,
.card-content h3 {
margin: var(--vanna-space-2) 0;
font-weight: 600;
}
.card-content h1 {
font-size: 1.5rem;
}
.card-content h2 {
font-size: 1.25rem;
}
.card-content h3 {
font-size: 1.125rem;
}
.card-content p {
margin: var(--vanna-space-2) 0;
}
.card-content ul {
margin: var(--vanna-space-2) 0;
padding-left: var(--vanna-space-5);
}
.card-content li {
margin: var(--vanna-space-1) 0;
}
.card-content code {
background: var(--vanna-background-higher);
padding: var(--vanna-space-1) var(--vanna-space-2);
border-radius: var(--vanna-border-radius-sm);
font-family: monospace;
font-size: 0.875em;
}
.card-content strong {
font-weight: 600;
}
.card-actions {
padding: var(--vanna-space-3) var(--vanna-space-5);
background: var(--vanna-background-root);
border-top: 1px solid var(--vanna-outline-default);
display: flex;
gap: var(--vanna-space-2);
}
.card-action {
padding: var(--vanna-space-2) var(--vanna-space-4);
border-radius: var(--vanna-border-radius-md);
border: 1px solid var(--vanna-outline-default);
background: var(--vanna-background-default);
color: var(--vanna-foreground-default);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all var(--vanna-duration-200) ease;
}
.card-action:hover {
background: var(--vanna-background-higher);
}
.card-action.primary {
background: var(--vanna-accent-primary-default);
color: white;
border-color: var(--vanna-accent-primary-default);
}
.card-action.primary:hover {
background: var(--vanna-accent-primary-stronger);
}
`
];
@property() title = '';
@property() subtitle = '';
@property() content = '';
@property() icon = '';
@property() status: 'info' | 'success' | 'warning' | 'error' = 'info';
@property({ type: Array }) actions: CardAction[] = [];
@property({ type: Boolean }) collapsible = false;
@property({ type: Boolean }) collapsed = false;
@property({ type: Boolean }) markdown = false;
@property() theme: 'light' | 'dark' = 'dark';
private _toggleCollapsed() {
if (this.collapsible) {
this.collapsed = !this.collapsed;
}
}
private _renderMarkdown(text: string): string {
// Simple markdown rendering - basic formatting
return text
.replace(/^### (.*$)/gm, '<h3>$1</h3>')
.replace(/^## (.*$)/gm, '<h2>$1</h2>')
.replace(/^# (.*$)/gm, '<h1>$1</h1>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/^- (.*$)/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\n\n/g, '</p><p>')
.replace(/^(?!<[h|u|l|p])(.+)$/gm, '<p>$1</p>');
}
render() {
const contentHtml = this.markdown
? html`<div class="card-content ${this.collapsed ? 'collapsed' : ''}" .innerHTML=${this._renderMarkdown(this.content)}></div>`
: html`<div class="card-content ${this.collapsed ? 'collapsed' : ''}">${this.content}</div>`;
return html`
<div class="card">
<div class="card-header ${this.collapsible ? 'collapsible' : ''}"
@click=${this._toggleCollapsed}>
${this.icon ? html`<span class="card-icon">${this.icon}</span>` : ''}
<div class="card-title-section">
<h3 class="card-title">${this.title}</h3>
${this.subtitle ? html`<p class="card-subtitle">${this.subtitle}</p>` : ''}
</div>
${this.status ? html`<span class="card-status status-${this.status}">${this.status}</span>` : ''}
${this.collapsible ? html`
<button class="card-toggle">${this.collapsed ? '▶' : '▼'}</button>
` : ''}
</div>
${contentHtml}
${this.actions.length > 0 ? html`
<div class="card-actions">
${this.actions.map(action => html`
<button class="card-action ${action.variant || 'secondary'}"
@click=${() => this._handleAction(action.action)}>
${action.label}
</button>
`)}
</div>
` : ''}
</div>
`;
}
private async _handleAction(action: string) {
console.log('🔘 Card action button clicked (rich-card)');
console.log(' Action:', action);
// Dispatch event for any listeners
this.dispatchEvent(new CustomEvent('card-action', {
detail: { action },
bubbles: true,
composed: true
}));
// Also directly send to vanna-chat
const vannaChat = document.querySelector('vanna-chat') as any;
if (vannaChat && typeof vannaChat.sendMessage === 'function') {
console.log(' Found vanna-chat, sending message...');
try {
const success = await vannaChat.sendMessage(action);
if (success) {
console.log(' ✅ Action sent successfully');
} else {
console.error(' ❌ Failed to send action');
}
} catch (error) {
console.error(' ❌ Error sending action:', error);
}
} else {
console.warn(' ⚠️ vanna-chat component not found or sendMessage not available');
}
}
}
declare global {
interface HTMLElementTagNameMap {
'rich-card': RichCard;
}
}

View File

@@ -0,0 +1,353 @@
import type { Meta, StoryObj } from '@storybook/web-components';
import { ComponentManager, ComponentUpdate } from './rich-component-system';
import { vannaDesignTokens } from '../styles/vanna-design-tokens.js';
const meta: Meta = {
title: 'Rich Components/Component System',
parameters: {
layout: 'padded',
backgrounds: {
default: 'light',
values: [
{ name: 'light', value: '#f5f7fa' },
{ name: 'dark', value: 'rgb(11, 15, 25)' },
],
},
},
};
export default meta;
type Story = StoryObj;
const ensureTokenStyles = () => {
if (document.getElementById('vanna-token-style')) {
return;
}
const style = document.createElement('style');
style.id = 'vanna-token-style';
style.textContent = vannaDesignTokens.cssText.replace(/:host/g, '.vanna-tokens');
document.head.appendChild(style);
};
const createContainer = () => {
ensureTokenStyles();
const container = document.createElement('div');
container.className = 'vanna-tokens';
container.style.cssText = `
padding: var(--vanna-space-5, 20px);
max-width: 800px;
margin: 0 auto;
background: var(--vanna-background-default);
border-radius: var(--vanna-border-radius-lg);
box-shadow: var(--vanna-shadow-md);
`;
return container;
};
const createManager = (container: HTMLElement) => new ComponentManager(container);
const renderComponent = (manager: ComponentManager, component: any) => {
const update: ComponentUpdate = {
operation: 'create',
target_id: component.id,
component,
timestamp: new Date().toISOString(),
} as ComponentUpdate;
manager.processUpdate(update);
};
const withDefaults = (component: any) => ({
layout: { position: 'append', size: {}, z_index: 0, classes: [] },
theme: {},
lifecycle: 'create',
...component,
});
export const NotificationComponents: Story = {
render: () => {
const container = createContainer();
const manager = createManager(container);
const components = [
withDefaults({
id: 'info-notification',
type: 'notification',
data: {
message: 'This is an informational message',
title: 'Information',
level: 'info',
dismissible: true,
actions: [],
},
}),
withDefaults({
id: 'success-notification',
type: 'notification',
data: {
message: 'Operation completed successfully!',
title: 'Success',
level: 'success',
dismissible: true,
actions: [
{ label: 'View Details', action: 'view', variant: 'primary' },
{ label: 'Dismiss', action: 'dismiss', variant: 'secondary' },
],
},
}),
withDefaults({
id: 'warning-notification',
type: 'notification',
data: {
message: 'Please review the configuration before proceeding',
title: 'Warning',
level: 'warning',
dismissible: true,
actions: [],
},
}),
withDefaults({
id: 'error-notification',
type: 'notification',
data: {
message: 'Failed to connect to the database. Please check your connection.',
title: 'Connection Error',
level: 'error',
dismissible: true,
actions: [
{ label: 'Retry', action: 'retry', variant: 'primary' },
{ label: 'Cancel', action: 'cancel', variant: 'secondary' },
],
},
}),
];
components.forEach((component) => renderComponent(manager, component));
return container;
},
};
export const StatusIndicatorComponents: Story = {
render: () => {
const container = createContainer();
const manager = createManager(container);
const statuses = [
{ status: 'loading', message: 'Processing your request...', pulse: true },
{ status: 'success', message: 'Request completed successfully', pulse: false },
{ status: 'warning', message: 'Operation completed with warnings', pulse: false },
{ status: 'error', message: 'Request failed - please try again', pulse: false },
];
statuses.forEach((statusData, index) => {
const component = withDefaults({
id: `status-${index}`,
type: 'status_indicator',
data: statusData,
});
renderComponent(manager, component);
});
return container;
},
};
export const TextComponents: Story = {
render: () => {
const container = createContainer();
const manager = createManager(container);
const plainText = withDefaults({
id: 'plain-text',
type: 'text',
data: {
content: 'This is a plain text component with some sample content to demonstrate text rendering.',
markdown: false,
},
});
const markdownText = withDefaults({
id: 'markdown-text',
type: 'text',
data: {
content: `# Rich Components Demo\n\nThis is a **markdown** text component with various formatting:\n\n- **Bold text** for emphasis\n- *Italic text* for style\n- Lists for organization\n\n## Features\n\nThe text component supports:\n- Plain text rendering\n- Basic markdown formatting\n- Code syntax highlighting`,
markdown: true,
},
});
[plainText, markdownText].forEach((component) => renderComponent(manager, component));
return container;
},
};
export const DataFrameComponents: Story = {
render: () => {
const container = createContainer();
const manager = createManager(container);
// Sample data for different scenarios
const sampleData = [
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', age: 28, city: 'New York', salary: 75000, active: true },
{ id: 2, name: 'Bob Smith', email: 'bob@example.com', age: 34, city: 'San Francisco', salary: 85000, active: true },
{ id: 3, name: 'Carol Davis', email: 'carol@example.com', age: 29, city: 'Chicago', salary: 70000, active: false },
{ id: 4, name: 'David Wilson', email: 'david@example.com', age: 42, city: 'Austin', salary: 90000, active: true },
{ id: 5, name: 'Eve Brown', email: 'eve@example.com', age: 31, city: 'Seattle', salary: 80000, active: true },
{ id: 6, name: 'Frank Miller', email: 'frank@example.com', age: 38, city: 'Boston', salary: 95000, active: false },
{ id: 7, name: 'Grace Lee', email: 'grace@example.com', age: 26, city: 'Denver', salary: 65000, active: true },
{ id: 8, name: 'Henry Taylor', email: 'henry@example.com', age: 33, city: 'Portland', salary: 72000, active: true },
];
const columns = ['id', 'name', 'email', 'age', 'city', 'salary', 'active'];
// Basic DataFrame
const basicDataFrame = withDefaults({
id: 'basic-dataframe',
type: 'dataframe',
data: {
data: sampleData.slice(0, 5),
columns: columns,
title: 'Employee Records',
description: 'Sample employee data with various fields',
row_count: 5,
column_count: columns.length,
column_types: {
id: 'number',
name: 'string',
email: 'string',
age: 'number',
city: 'string',
salary: 'number',
active: 'boolean'
}
},
});
// Large DataFrame with all features
const fullDataFrame = withDefaults({
id: 'full-dataframe',
type: 'dataframe',
data: {
data: sampleData,
columns: columns,
title: 'Complete Employee Database',
description: 'Full dataset with search, sort, and export functionality',
row_count: sampleData.length,
column_count: columns.length,
searchable: true,
sortable: true,
filterable: true,
exportable: true,
striped: true,
bordered: true,
max_rows_displayed: 6,
column_types: {
id: 'number',
name: 'string',
email: 'string',
age: 'number',
city: 'string',
salary: 'number',
active: 'boolean'
}
},
});
// Empty DataFrame
const emptyDataFrame = withDefaults({
id: 'empty-dataframe',
type: 'dataframe',
data: {
data: [],
columns: [],
title: 'Empty Dataset',
description: 'No data available to display',
row_count: 0,
column_count: 0,
},
});
// Compact DataFrame
const compactDataFrame = withDefaults({
id: 'compact-dataframe',
type: 'dataframe',
data: {
data: sampleData.slice(0, 4),
columns: ['id', 'name', 'city', 'active'],
title: 'Compact View',
description: 'Space-efficient display with essential columns only',
row_count: 4,
column_count: 4,
compact: true,
searchable: false,
exportable: false,
column_types: {
id: 'number',
name: 'string',
city: 'string',
active: 'boolean'
}
},
});
[basicDataFrame, fullDataFrame, emptyDataFrame, compactDataFrame].forEach((component) => {
renderComponent(manager, component);
});
return container;
},
};
export const SQLQueryDataFrame: Story = {
render: () => {
const container = createContainer();
const manager = createManager(container);
// SQL query result simulation
const sqlResultData = [
{ TrackId: 1, Name: 'For Those About To Rock (We Salute You)', AlbumId: 1, MediaTypeId: 1, GenreId: 1, Composer: 'Angus Young, Malcolm Young, Brian Johnson', Milliseconds: 343719, Bytes: 11170334, UnitPrice: 0.99 },
{ TrackId: 2, Name: 'Balls to the Wall', AlbumId: 2, MediaTypeId: 2, GenreId: 1, Composer: null, Milliseconds: 342562, Bytes: 5510424, UnitPrice: 0.99 },
{ TrackId: 3, Name: 'Fast As a Shark', AlbumId: 3, MediaTypeId: 2, GenreId: 1, Composer: 'F. Baltes, S. Kaufman, U. Dirkscneider & W. Hoffman', Milliseconds: 230619, Bytes: 3990994, UnitPrice: 0.99 },
{ TrackId: 4, Name: 'Restless and Wild', AlbumId: 3, MediaTypeId: 2, GenreId: 1, Composer: 'F. Baltes, R.A. Smith-Diesel, S. Kaufman, U. Dirkscneider & W. Hoffman', Milliseconds: 252051, Bytes: 4331779, UnitPrice: 0.99 },
{ TrackId: 5, Name: 'Princess of the Dawn', AlbumId: 3, MediaTypeId: 2, GenreId: 1, Composer: 'Deaffy & R.A. Smith-Diesel', Milliseconds: 375418, Bytes: 6290521, UnitPrice: 0.99 },
];
const sqlColumns = ['TrackId', 'Name', 'AlbumId', 'MediaTypeId', 'GenreId', 'Composer', 'Milliseconds', 'Bytes', 'UnitPrice'];
const sqlDataFrame = withDefaults({
id: 'sql-dataframe',
type: 'dataframe',
data: {
data: sqlResultData,
columns: sqlColumns,
title: 'Query Results',
description: 'SELECT * FROM Track LIMIT 5',
row_count: sqlResultData.length,
column_count: sqlColumns.length,
searchable: true,
sortable: true,
exportable: true,
column_types: {
TrackId: 'number',
Name: 'string',
AlbumId: 'number',
MediaTypeId: 'number',
GenreId: 'number',
Composer: 'string',
Milliseconds: 'number',
Bytes: 'number',
UnitPrice: 'number'
}
},
});
renderComponent(manager, sqlDataFrame);
return container;
},
};

View File

@@ -0,0 +1,252 @@
import type { Meta, StoryObj } from '@storybook/web-components';
import { html } from 'lit';
import './rich-progress-bar';
const meta: Meta = {
title: 'Rich Components/Rich Progress Bar',
component: 'rich-progress-bar',
parameters: {
layout: 'padded',
backgrounds: {
default: 'light',
values: [
{ name: 'dark', value: 'rgb(11, 15, 25)' },
{ name: 'light', value: '#f5f7fa' },
],
},
},
argTypes: {
value: { control: { type: 'range', min: 0, max: 1, step: 0.01 } },
label: { control: 'text' },
description: { control: 'text' },
showPercentage: { control: 'boolean' },
status: {
control: 'select',
options: ['info', 'success', 'warning', 'error']
},
animated: { control: 'boolean' },
indeterminate: { control: 'boolean' },
},
};
export default meta;
type Story = StoryObj;
export const Default: Story = {
args: {
value: 0.65,
label: 'Processing',
showPercentage: true,
status: 'info',
animated: false,
indeterminate: false,
},
render: (args) => html`
<div style="max-width: 500px; margin: 0 auto;">
<rich-progress-bar
.value=${args.value}
label=${args.label}
description=${args.description}
?showPercentage=${args.showPercentage}
status=${args.status}
?animated=${args.animated}
?indeterminate=${args.indeterminate}>
</rich-progress-bar>
</div>
`,
};
export const WithDescription: Story = {
args: {
value: 0.4,
label: 'Installing dependencies',
description: 'Downloading and installing npm packages for the project. This may take a few minutes.',
showPercentage: true,
status: 'info',
animated: true,
indeterminate: false,
},
render: (args) => html`
<div style="max-width: 500px; margin: 0 auto;">
<rich-progress-bar
.value=${args.value}
label=${args.label}
description=${args.description}
?showPercentage=${args.showPercentage}
status=${args.status}
?animated=${args.animated}
?indeterminate=${args.indeterminate}>
</rich-progress-bar>
</div>
`,
};
export const Animated: Story = {
args: {
value: 0.75,
label: 'Uploading files',
showPercentage: true,
status: 'info',
animated: true,
indeterminate: false,
},
render: (args) => html`
<div style="max-width: 500px; margin: 0 auto;">
<rich-progress-bar
.value=${args.value}
label=${args.label}
description=${args.description}
?showPercentage=${args.showPercentage}
status=${args.status}
?animated=${args.animated}
?indeterminate=${args.indeterminate}>
</rich-progress-bar>
</div>
`,
};
export const Indeterminate: Story = {
args: {
value: 0,
label: 'Loading...',
description: 'Please wait while we process your request',
showPercentage: false,
status: 'info',
animated: false,
indeterminate: true,
},
render: (args) => html`
<div style="max-width: 500px; margin: 0 auto;">
<rich-progress-bar
.value=${args.value}
label=${args.label}
description=${args.description}
?showPercentage=${args.showPercentage}
status=${args.status}
?animated=${args.animated}
?indeterminate=${args.indeterminate}>
</rich-progress-bar>
</div>
`,
};
export const StatusVariants: Story = {
render: () => html`
<div style="max-width: 500px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px;">
<rich-progress-bar
.value=${0.8}
label="Info Status"
status="info"
showPercentage>
</rich-progress-bar>
<rich-progress-bar
.value=${1.0}
label="Success Status"
status="success"
showPercentage>
</rich-progress-bar>
<rich-progress-bar
.value=${0.6}
label="Warning Status"
status="warning"
showPercentage>
</rich-progress-bar>
<rich-progress-bar
.value=${0.3}
label="Error Status"
status="error"
showPercentage>
</rich-progress-bar>
</div>
`,
};
export const Minimal: Story = {
args: {
value: 0.45,
showPercentage: false,
status: 'info',
animated: false,
indeterminate: false,
},
render: (args) => html`
<div style="max-width: 500px; margin: 0 auto;">
<rich-progress-bar
.value=${args.value}
?showPercentage=${args.showPercentage}
status=${args.status}
?animated=${args.animated}
?indeterminate=${args.indeterminate}>
</rich-progress-bar>
</div>
`,
};
export const MultipleSteps: Story = {
render: () => html`
<div style="max-width: 500px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px;">
<rich-progress-bar
.value=${1.0}
label="Step 1: Initialize"
description="Project initialization completed"
status="success"
showPercentage>
</rich-progress-bar>
<rich-progress-bar
.value=${1.0}
label="Step 2: Download dependencies"
description="All packages downloaded successfully"
status="success"
showPercentage>
</rich-progress-bar>
<rich-progress-bar
.value=${0.7}
label="Step 3: Build project"
description="Compiling TypeScript and bundling assets"
status="info"
animated
showPercentage>
</rich-progress-bar>
<rich-progress-bar
.value=${0}
label="Step 4: Deploy"
description="Waiting for build to complete"
status="info"
showPercentage>
</rich-progress-bar>
</div>
`,
};
export const LightTheme: Story = {
args: {
value: 0.55,
label: 'Light Theme Progress',
description: 'Progress bar styled for light backgrounds',
showPercentage: true,
status: 'success',
animated: true,
},
parameters: {
backgrounds: { default: 'light' }
},
render: (args) => html`
<div style="max-width: 500px; margin: 0 auto;">
<rich-progress-bar
theme="light"
.value=${args.value}
label=${args.label}
description=${args.description}
?showPercentage=${args.showPercentage}
status=${args.status}
?animated=${args.animated}>
</rich-progress-bar>
</div>
`,
};

View File

@@ -0,0 +1,202 @@
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { vannaDesignTokens } from '../styles/vanna-design-tokens.js';
@customElement('rich-progress-bar')
export class RichProgressBar extends LitElement {
static styles = [
vannaDesignTokens,
css`
:host {
display: block;
margin-bottom: var(--vanna-space-4);
font-family: var(--vanna-font-family-default);
}
.progress-container {
padding: var(--vanna-space-4);
border: 1px solid var(--vanna-outline-default);
border-radius: var(--vanna-border-radius-lg);
background: var(--vanna-background-default);
box-shadow: var(--vanna-shadow-sm);
transition: box-shadow var(--vanna-duration-200) ease;
}
.progress-container:hover {
box-shadow: var(--vanna-shadow-md);
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--vanna-space-3);
}
.progress-label {
font-weight: 500;
color: var(--vanna-foreground-default);
}
.progress-percentage {
font-size: 0.875rem;
color: var(--vanna-foreground-dimmer);
font-weight: 600;
}
.progress-track {
height: 12px;
background: var(--vanna-background-root);
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--vanna-outline-default);
position: relative;
}
.progress-fill {
height: 100%;
background: var(--vanna-accent-primary-default);
border-radius: 6px;
transition: width var(--vanna-duration-300) ease;
position: relative;
overflow: hidden;
}
.progress-fill.animated {
animation: progressPulse 2s ease-in-out infinite;
}
.progress-fill.animated::after {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.2),
transparent
);
animation: progressShimmer 1.5s infinite;
}
@keyframes progressPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.8; }
}
@keyframes progressShimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.progress-fill.status-success {
background: var(--vanna-accent-positive-default);
}
.progress-fill.status-warning {
background: var(--vanna-accent-warning-default);
}
.progress-fill.status-error {
background: var(--vanna-accent-negative-default);
}
.progress-fill.status-info {
background: var(--vanna-accent-primary-default);
}
/* Indeterminate progress animation */
.progress-fill.indeterminate {
background: linear-gradient(
90deg,
transparent 0%,
var(--vanna-accent-primary-default) 50%,
transparent 100%
);
background-size: 200% 100%;
animation: indeterminateProgress 2s linear infinite;
width: 100% !important;
}
@keyframes indeterminateProgress {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Text content for description */
.progress-description {
margin-top: var(--vanna-space-2);
font-size: 0.875rem;
color: var(--vanna-foreground-dimmer);
line-height: 1.4;
}
`
];
@property({ type: Number }) value = 0;
@property() label = '';
@property() description = '';
@property({ type: Boolean }) showPercentage = true;
@property() status: 'info' | 'success' | 'warning' | 'error' = 'info';
@property({ type: Boolean }) animated = false;
@property({ type: Boolean }) indeterminate = false;
@property() theme: 'light' | 'dark' = 'dark';
private get percentage(): number {
if (this.indeterminate) return 100;
return Math.round(Math.max(0, Math.min(1, this.value)) * 100);
}
private get progressClasses(): string {
const classes = ['progress-fill'];
if (this.animated) {
classes.push('animated');
}
if (this.indeterminate) {
classes.push('indeterminate');
}
if (this.status) {
classes.push(`status-${this.status}`);
}
return classes.join(' ');
}
render() {
return html`
<div class="progress-container">
${this.label || this.showPercentage ? html`
<div class="progress-header">
${this.label ? html`<span class="progress-label">${this.label}</span>` : ''}
${this.showPercentage && !this.indeterminate ? html`
<span class="progress-percentage">${this.percentage}%</span>
` : ''}
</div>
` : ''}
<div class="progress-track">
<div
class="${this.progressClasses}"
style="width: ${this.indeterminate ? '100' : this.percentage}%">
</div>
</div>
${this.description ? html`
<div class="progress-description">${this.description}</div>
` : ''}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'rich-progress-bar': RichProgressBar;
}
}

View File

@@ -0,0 +1,270 @@
import type { Meta, StoryObj } from '@storybook/web-components';
import { html } from 'lit';
import './rich-task-list';
const meta: Meta = {
title: 'Rich Components/Rich Task List',
component: 'rich-task-list',
parameters: {
layout: 'padded',
backgrounds: {
default: 'light',
values: [
{ name: 'dark', value: 'rgb(11, 15, 25)' },
{ name: 'light', value: '#f5f7fa' },
],
},
},
argTypes: {
title: { control: 'text' },
tasks: { control: 'object' },
showProgress: { control: 'boolean' },
showTimestamps: { control: 'boolean' },
},
};
export default meta;
type Story = StoryObj;
const sampleTasks = [
{
id: '1',
title: 'Initialize project setup',
description: 'Setting up the basic project structure and dependencies',
status: 'completed',
progress: 1.0,
timestamp: '2024-01-15 10:30:00'
},
{
id: '2',
title: 'Configure database connection',
description: 'Establishing secure connection to PostgreSQL database',
status: 'completed',
progress: 1.0,
timestamp: '2024-01-15 10:45:00'
},
{
id: '3',
title: 'Implement user authentication',
description: 'Building JWT-based authentication system',
status: 'running',
progress: 0.7,
timestamp: '2024-01-15 11:00:00'
},
{
id: '4',
title: 'Create API endpoints',
description: 'Developing RESTful API for user management',
status: 'pending',
timestamp: '2024-01-15 11:30:00'
},
{
id: '5',
title: 'Write unit tests',
description: 'Comprehensive test coverage for all modules',
status: 'pending',
}
];
export const Default: Story = {
args: {
title: 'Development Tasks',
tasks: sampleTasks,
showProgress: true,
showTimestamps: false,
},
render: (args) => html`
<div style="max-width: 600px; margin: 0 auto;">
<rich-task-list
title=${args.title}
.tasks=${args.tasks}
?showProgress=${args.showProgress}
?showTimestamps=${args.showTimestamps}>
</rich-task-list>
</div>
`,
};
export const WithTimestamps: Story = {
args: {
title: 'Build Pipeline',
tasks: sampleTasks,
showProgress: true,
showTimestamps: true,
},
render: (args) => html`
<div style="max-width: 600px; margin: 0 auto;">
<rich-task-list
title=${args.title}
.tasks=${args.tasks}
?showProgress=${args.showProgress}
?showTimestamps=${args.showTimestamps}>
</rich-task-list>
</div>
`,
};
export const WithoutProgress: Story = {
args: {
title: 'Simple Task List',
tasks: [
{ id: '1', title: 'Review code changes', status: 'completed' },
{ id: '2', title: 'Update documentation', status: 'running' },
{ id: '3', title: 'Deploy to staging', status: 'pending' },
{ id: '4', title: 'Run integration tests', status: 'failed' },
],
showProgress: false,
showTimestamps: false,
},
render: (args) => html`
<div style="max-width: 600px; margin: 0 auto;">
<rich-task-list
title=${args.title}
.tasks=${args.tasks}
?showProgress=${args.showProgress}
?showTimestamps=${args.showTimestamps}>
</rich-task-list>
</div>
`,
};
export const AllStatuses: Story = {
args: {
title: 'Task Status Examples',
tasks: [
{
id: '1',
title: 'Completed Task',
description: 'This task has been successfully completed',
status: 'completed',
progress: 1.0,
},
{
id: '2',
title: 'Running Task',
description: 'This task is currently in progress',
status: 'running',
progress: 0.6,
},
{
id: '3',
title: 'Pending Task',
description: 'This task is waiting to be started',
status: 'pending',
},
{
id: '4',
title: 'Failed Task',
description: 'This task encountered an error',
status: 'failed',
progress: 0.3,
},
],
showProgress: true,
showTimestamps: false,
},
render: (args) => html`
<div style="max-width: 600px; margin: 0 auto;">
<rich-task-list
title=${args.title}
.tasks=${args.tasks}
?showProgress=${args.showProgress}
?showTimestamps=${args.showTimestamps}>
</rich-task-list>
</div>
`,
};
export const EmptyList: Story = {
args: {
title: 'No Tasks',
tasks: [],
showProgress: true,
showTimestamps: false,
},
render: (args) => html`
<div style="max-width: 600px; margin: 0 auto;">
<rich-task-list
title=${args.title}
.tasks=${args.tasks}
?showProgress=${args.showProgress}
?showTimestamps=${args.showTimestamps}>
</rich-task-list>
</div>
`,
};
export const ErrorStates: Story = {
args: {
title: 'Error Handling Examples',
tasks: [
{
id: '1',
title: 'Database Connection Failed',
description: 'Could not establish connection to the database server. Check network connectivity and credentials.',
status: 'failed',
progress: 0.1,
timestamp: '2024-01-15 10:15:00'
},
{
id: '2',
title: 'API Authentication Error',
description: 'Invalid API key or expired token. Please refresh your credentials.',
status: 'failed',
progress: 0.0,
timestamp: '2024-01-15 10:20:00'
},
{
id: '3',
title: 'File Processing Error',
description: 'Unable to process uploaded file. File may be corrupted or in an unsupported format.',
status: 'failed',
progress: 0.45,
timestamp: '2024-01-15 10:25:00'
},
{
id: '4',
title: 'Network Timeout',
description: 'Request timed out after 30 seconds. This may be due to high server load.',
status: 'failed',
progress: 0.8,
timestamp: '2024-01-15 10:30:00'
},
],
showProgress: true,
showTimestamps: true,
},
render: (args) => html`
<div style="max-width: 600px; margin: 0 auto;">
<rich-task-list
title=${args.title}
.tasks=${args.tasks}
?showProgress=${args.showProgress}
?showTimestamps=${args.showTimestamps}>
</rich-task-list>
</div>
`,
};
export const LightTheme: Story = {
args: {
title: 'Light Theme Task List',
tasks: sampleTasks.slice(0, 3),
showProgress: true,
showTimestamps: true,
},
parameters: {
backgrounds: { default: 'light' }
},
render: (args) => html`
<div style="max-width: 600px; margin: 0 auto;">
<rich-task-list
theme="light"
title=${args.title}
.tasks=${args.tasks}
?showProgress=${args.showProgress}
?showTimestamps=${args.showTimestamps}>
</rich-task-list>
</div>
`,
};

View File

@@ -0,0 +1,272 @@
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { vannaDesignTokens } from '../styles/vanna-design-tokens.js';
export interface TaskItem {
id: string;
title: string;
description?: string;
status: 'pending' | 'running' | 'completed' | 'failed';
progress?: number;
timestamp?: string;
}
@customElement('rich-task-list')
export class RichTaskList extends LitElement {
static styles = [
vannaDesignTokens,
css`
:host {
display: block;
margin-bottom: var(--vanna-space-4);
font-family: var(--vanna-font-family-default);
}
.task-list {
border: 1px solid var(--vanna-outline-default);
border-radius: var(--vanna-border-radius-lg);
background: var(--vanna-background-default);
box-shadow: var(--vanna-shadow-sm);
overflow: hidden;
transition: box-shadow var(--vanna-duration-200) ease;
}
.task-list:hover {
box-shadow: var(--vanna-shadow-md);
}
.task-list-header {
padding: var(--vanna-space-4) var(--vanna-space-5);
background: var(--vanna-background-higher);
border-bottom: 1px solid var(--vanna-outline-default);
}
.task-list-title {
margin: 0 0 var(--vanna-space-3) 0;
font-size: 1rem;
font-weight: 600;
color: var(--vanna-foreground-default);
}
.task-list-progress {
display: flex;
align-items: center;
gap: var(--vanna-space-3);
}
.progress-text {
font-size: 0.875rem;
color: var(--vanna-foreground-dimmer);
min-width: fit-content;
}
.progress-bar {
flex: 1;
height: 6px;
background: var(--vanna-background-root);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--vanna-accent-primary-default);
border-radius: 3px;
transition: width var(--vanna-duration-300) ease;
}
.progress-fill.animated {
animation: progressPulse 2s ease-in-out infinite;
}
@keyframes progressPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.progress-fill.status-success {
background: var(--vanna-accent-positive-default);
}
.progress-fill.status-warning {
background: var(--vanna-accent-warning-default);
}
.progress-fill.status-error {
background: var(--vanna-accent-negative-default);
}
.task-list-items {
padding: var(--vanna-space-2);
}
.task-item {
display: flex;
align-items: flex-start;
gap: var(--vanna-space-3);
padding: var(--vanna-space-3);
border-radius: var(--vanna-border-radius-md);
transition: background-color var(--vanna-duration-200) ease;
}
.task-item:hover {
background: var(--vanna-background-root);
}
.task-item.status-completed {
opacity: 0.7;
}
.task-item.status-failed {
background: rgba(239, 68, 68, 0.1);
}
.task-icon {
font-size: 1rem;
margin-top: 0.125rem;
}
.task-content {
flex: 1;
min-width: 0;
}
.task-title {
font-weight: 500;
color: var(--vanna-foreground-default);
margin-bottom: var(--vanna-space-1);
}
.task-description {
font-size: 0.875rem;
color: var(--vanna-foreground-dimmer);
margin-bottom: var(--vanna-space-2);
}
.task-progress {
display: flex;
align-items: center;
gap: var(--vanna-space-2);
margin-bottom: var(--vanna-space-2);
}
.task-progress-bar {
flex: 1;
height: 4px;
background: var(--vanna-background-root);
border-radius: 2px;
overflow: hidden;
}
.task-progress-fill {
height: 100%;
background: var(--vanna-accent-primary-default);
border-radius: 2px;
transition: width var(--vanna-duration-300) ease;
}
.task-progress-text {
font-size: 0.75rem;
color: var(--vanna-foreground-dimmer);
min-width: fit-content;
}
.task-timestamp {
font-size: 0.75rem;
color: var(--vanna-foreground-dimmest);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.task-list-header {
padding-left: var(--vanna-space-4);
padding-right: var(--vanna-space-4);
}
.task-list-progress {
flex-direction: column;
align-items: stretch;
gap: var(--vanna-space-2);
}
}
`
];
@property() title = '';
@property({ type: Array }) tasks: TaskItem[] = [];
@property({ type: Boolean }) showProgress = true;
@property({ type: Boolean }) showTimestamps = false;
@property() theme: 'light' | 'dark' = 'dark';
private get completedTasks(): number {
return this.tasks.filter(task => task.status === 'completed').length;
}
private get progressPercentage(): number {
return this.tasks.length > 0 ? (this.completedTasks / this.tasks.length) * 100 : 0;
}
private getStatusIcon(status: string): string {
const icons = {
'pending': '⏳',
'running': '🔄',
'completed': '✅',
'failed': '❌'
};
return icons[status as keyof typeof icons] || '⏳';
}
private renderTask(task: TaskItem) {
const statusIcon = this.getStatusIcon(task.status);
return html`
<div class="task-item status-${task.status}" data-task-id="${task.id}">
<div class="task-icon">${statusIcon}</div>
<div class="task-content">
<div class="task-title">${task.title}</div>
${task.description ? html`
<div class="task-description">${task.description}</div>
` : ''}
${task.progress !== null && task.progress !== undefined ? html`
<div class="task-progress">
<div class="task-progress-bar">
<div class="task-progress-fill" style="width: ${task.progress * 100}%"></div>
</div>
<span class="task-progress-text">${Math.round(task.progress * 100)}%</span>
</div>
` : ''}
${this.showTimestamps && task.timestamp ? html`
<div class="task-timestamp">${task.timestamp}</div>
` : ''}
</div>
</div>
`;
}
render() {
return html`
<div class="task-list">
<div class="task-list-header">
<h3 class="task-list-title">${this.title}</h3>
${this.showProgress ? html`
<div class="task-list-progress">
<span class="progress-text">${this.completedTasks}/${this.tasks.length} completed</span>
<div class="progress-bar">
<div class="progress-fill" style="width: ${this.progressPercentage}%"></div>
</div>
</div>
` : ''}
</div>
<div class="task-list-items">
${this.tasks.map(task => this.renderTask(task))}
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'rich-task-list': RichTaskList;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
import type { Meta, StoryObj } from '@storybook/web-components';
import { html } from 'lit';
import './vanna-message';
const meta: Meta = {
title: 'Components/VannaMessage',
component: 'vanna-message',
parameters: {
layout: 'centered',
},
argTypes: {
content: { control: 'text' },
type: {
control: 'select',
options: ['user', 'assistant'],
},
timestamp: { control: 'number' },
},
};
export default meta;
type Story = StoryObj;
export const UserMessage: Story = {
args: {
content: 'Hello! Can you help me analyze my data?',
type: 'user',
timestamp: Date.now(),
},
render: (args) => html`
<div style="width: 400px;">
<vanna-message
.content=${args.content}
.type=${args.type}
.timestamp=${args.timestamp}>
</vanna-message>
</div>
`,
};
export const AssistantMessage: Story = {
args: {
content: 'Of course! I\'d be happy to help you analyze your data. Could you please tell me more about the type of data you have and what insights you\'re looking for?',
type: 'assistant',
timestamp: Date.now(),
},
render: (args) => html`
<div style="width: 400px;">
<vanna-message
.content=${args.content}
.type=${args.type}
.timestamp=${args.timestamp}>
</vanna-message>
</div>
`,
};
export const LongMessage: Story = {
args: {
content: 'This is a very long message that demonstrates how the component handles longer text content. It should wrap properly and maintain good readability while staying within the maximum width constraints. The message can contain multiple sentences and paragraphs of information that the AI assistant might provide in response to complex queries.',
type: 'assistant',
timestamp: Date.now(),
},
render: (args) => html`
<div style="width: 400px;">
<vanna-message
.content=${args.content}
.type=${args.type}
.timestamp=${args.timestamp}>
</vanna-message>
</div>
`,
};
export const Conversation: Story = {
render: () => html`
<div style="width: 400px;">
<vanna-message
content="What's the total revenue for Q4?"
type="user"
.timestamp=${Date.now() - 120000}>
</vanna-message>
<vanna-message
content="I'll help you calculate the total revenue for Q4. Let me query your database for this information."
type="assistant"
.timestamp=${Date.now() - 60000}>
</vanna-message>
<vanna-message
content="The total revenue for Q4 is $2,450,000. This represents a 15% increase compared to Q3."
type="assistant"
.timestamp=${Date.now()}>
</vanna-message>
</div>
`,
};

View File

@@ -0,0 +1,221 @@
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { vannaDesignTokens } from '../styles/vanna-design-tokens.js';
@customElement('vanna-message')
export class VannaMessage extends LitElement {
static styles = [
vannaDesignTokens,
css`
:host {
display: block;
padding: 0 var(--vanna-space-2);
margin-bottom: var(--vanna-space-4);
font-family: var(--vanna-font-family-default);
animation: fade-in-up 0.25s ease-out;
}
:host(:last-of-type) {
margin-bottom: 0;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message {
position: relative;
padding: var(--vanna-space-4) var(--vanna-space-5);
border-radius: var(--vanna-chat-bubble-radius);
word-wrap: break-word;
line-height: 1.6;
display: flex;
flex-direction: column;
gap: var(--vanna-space-2);
max-width: min(85%, 580px);
transition: transform var(--vanna-duration-200) ease, box-shadow var(--vanna-duration-200) ease;
backdrop-filter: blur(8px);
}
.message.assistant {
background: var(--vanna-background-root);
border: 1px solid var(--vanna-outline-dimmer);
color: var(--vanna-foreground-default);
box-shadow: var(--vanna-shadow-sm);
border-radius: var(--vanna-chat-bubble-radius) var(--vanna-chat-bubble-radius) var(--vanna-chat-bubble-radius) var(--vanna-space-2);
}
.message.user {
margin-left: auto;
max-width: min(80%, 500px);
background: linear-gradient(135deg, var(--vanna-accent-primary-stronger) 0%, var(--vanna-accent-primary-default) 100%);
color: white;
box-shadow: var(--vanna-shadow-md);
border-radius: var(--vanna-chat-bubble-radius) var(--vanna-chat-bubble-radius) var(--vanna-space-2) var(--vanna-chat-bubble-radius);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.message:hover {
transform: translateY(-1px);
}
.message.assistant:hover {
box-shadow: var(--vanna-shadow-md);
border-color: var(--vanna-outline-hover);
}
.message.user:hover {
box-shadow: var(--vanna-shadow-lg);
}
.message-content {
margin: 0;
font-size: 15px;
letter-spacing: 0.01em;
white-space: pre-wrap;
font-weight: 400;
}
.message-content a {
color: inherit;
font-weight: 500;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
opacity: 0.9;
}
.message-content code {
font-family: var(--vanna-font-family-mono);
background: var(--vanna-background-higher);
padding: 2px 6px;
border-radius: var(--vanna-border-radius-sm);
font-size: 13px;
border: 1px solid var(--vanna-outline-dimmer);
}
.message.user .message-content code {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
}
.message-timestamp {
display: inline-flex;
align-items: center;
gap: var(--vanna-space-1);
font-size: 11px;
letter-spacing: 0.05em;
margin-top: var(--vanna-space-2);
font-family: var(--vanna-font-family-default);
opacity: 0.7;
font-weight: 500;
}
.message-timestamp::before {
content: '';
width: 3px;
height: 3px;
border-radius: var(--vanna-border-radius-full);
background: currentColor;
opacity: 0.8;
}
.message.assistant .message-timestamp {
align-self: flex-start;
color: var(--vanna-foreground-dimmest);
}
.message.assistant .message-timestamp::before {
background: var(--vanna-accent-primary-default);
}
.message.user .message-timestamp {
align-self: flex-end;
color: rgba(255, 255, 255, 0.8);
}
.message.user .message-timestamp::before {
background: rgba(255, 255, 255, 0.8);
}
:host([theme="dark"]) .message.assistant {
background: var(--vanna-background-higher);
border: 1px solid var(--vanna-outline-default);
color: var(--vanna-foreground-default);
box-shadow: var(--vanna-shadow-md);
}
:host([theme="dark"]) .message.assistant .message-content code {
background: var(--vanna-background-highest);
border-color: var(--vanna-outline-default);
}
:host([theme="dark"]) .message.assistant .message-timestamp {
color: var(--vanna-foreground-dimmest);
}
:host([theme="dark"]) .message.assistant .message-timestamp::before {
background: var(--vanna-accent-primary-default);
}
:host([theme="dark"]) .message.user {
background: linear-gradient(135deg, var(--vanna-accent-primary-stronger) 0%, var(--vanna-accent-primary-default) 100%);
color: white;
box-shadow: var(--vanna-shadow-lg);
}
:host([theme="dark"]) .message.user .message-content code {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.25);
}
:host([theme="dark"]) .message.user .message-timestamp {
color: rgba(255, 255, 255, 0.8);
}
:host([theme="dark"]) .message.user .message-timestamp::before {
background: rgba(255, 255, 255, 0.8);
}
@media (max-width: 600px) {
.message {
max-width: 100%;
}
.message.user {
max-width: 100%;
}
}
`
];
@property() content = '';
@property() type: 'user' | 'assistant' = 'user';
@property({ type: Number }) timestamp = Date.now();
@property({ reflect: true }) theme = 'light';
private formatTimestamp(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
});
}
render() {
return html`
<div class="message ${this.type}">
<div class="message-content">${this.content}</div>
<div class="message-timestamp">
${this.formatTimestamp(this.timestamp)}
</div>
</div>
`;
}
}

View File

@@ -0,0 +1,268 @@
import type { Meta, StoryObj } from '@storybook/web-components';
import { html } from 'lit';
import './vanna-progress-tracker';
const meta: Meta = {
title: 'Components/VannaProgressTracker',
component: 'vanna-progress-tracker',
parameters: {
layout: 'centered',
backgrounds: {
default: 'light',
values: [
{ name: 'dark', value: 'rgb(11, 15, 25)' },
{ name: 'light', value: '#ffffff' },
],
},
},
argTypes: {
title: { control: 'text' },
theme: {
control: 'select',
options: ['dark', 'light'],
description: 'Theme variant'
},
},
};
export default meta;
type Story = StoryObj;
export const Empty: Story = {
args: {
title: 'Agent Progress',
},
render: (args) => html`
<div style="width: 350px; height: 400px;">
<vanna-progress-tracker .title=${args.title}></vanna-progress-tracker>
</div>
`,
};
export const WithTasks: Story = {
args: {
title: 'Agent Progress',
theme: 'light',
},
render: (args) => {
setTimeout(() => {
const tracker = document.querySelector('vanna-progress-tracker') as any;
if (tracker) {
tracker.addItem('Analyze database schema', 'Examining table structure');
tracker.addItem('Generate SQL query', 'Based on user request');
tracker.addItem('Execute query', 'Running against production DB');
tracker.addItem('Format results', 'Creating visualization');
// Update first item to in_progress
const items = tracker.shadowRoot?.querySelectorAll('.progress-item');
if (items?.[0]) {
tracker.updateItem(tracker.items[0].id, 'in_progress', 'Scanning tables...');
}
}
}, 100);
return html`
<div style="width: 350px; height: 400px; background: ${args.theme === 'light' ? '#ffffff' : 'rgb(11, 15, 25)'}; padding: 20px;">
<vanna-progress-tracker .title=${args.title} theme=${args.theme}></vanna-progress-tracker>
</div>
`;
},
};
export const WithTasksLight: Story = {
args: {
title: 'Agent Progress',
theme: 'light',
},
render: (args) => {
setTimeout(() => {
const tracker = document.querySelector('vanna-progress-tracker') as any;
if (tracker) {
tracker.addItem('Analyze database schema', 'Examining table structure');
tracker.addItem('Generate SQL query', 'Based on user request');
tracker.addItem('Execute query', 'Running against production DB');
tracker.addItem('Format results', 'Creating visualization');
// Update first item to in_progress
const items = tracker.shadowRoot?.querySelectorAll('.progress-item');
if (items?.[0]) {
tracker.updateItem(tracker.items[0].id, 'in_progress', 'Scanning tables...');
}
}
}, 100);
return html`
<div style="width: 350px; height: 400px; background: ${args.theme === 'light' ? '#ffffff' : 'rgb(11, 15, 25)'}; padding: 20px;">
<vanna-progress-tracker .title=${args.title} theme=${args.theme}></vanna-progress-tracker>
</div>
`;
},
};
export const MixedStatuses: Story = {
args: {
title: 'Data Analysis Pipeline',
},
render: (args) => {
setTimeout(() => {
const tracker = document.querySelector('vanna-progress-tracker') as any;
if (tracker) {
const id1 = tracker.addItem('Connect to database', 'Establishing connection');
const id2 = tracker.addItem('Validate credentials', 'Checking access permissions');
const id3 = tracker.addItem('Load data schema', 'Reading table definitions');
const id4 = tracker.addItem('Parse user query', 'Understanding natural language');
const id5 = tracker.addItem('Generate SQL', 'Converting to database query');
const id6 = tracker.addItem('Execute query', 'Running against database');
const id7 = tracker.addItem('Process results', 'Formatting output');
// Simulate different states
tracker.updateItem(id1, 'completed');
tracker.updateItem(id2, 'completed');
tracker.updateItem(id3, 'completed');
tracker.updateItem(id4, 'in_progress', 'Analyzing: "Show me sales by region"');
tracker.updateItem(id5, 'pending');
tracker.updateItem(id6, 'pending');
tracker.updateItem(id7, 'pending');
}
}, 100);
return html`
<div style="width: 350px; height: 400px;">
<vanna-progress-tracker .title=${args.title}></vanna-progress-tracker>
</div>
`;
},
};
export const WithError: Story = {
args: {
title: 'Query Processing',
},
render: (args) => {
setTimeout(() => {
const tracker = document.querySelector('vanna-progress-tracker') as any;
if (tracker) {
const id1 = tracker.addItem('Parse request', 'Understanding user query');
const id2 = tracker.addItem('Generate SQL', 'Creating database query');
const id3 = tracker.addItem('Execute query', 'Running against database');
tracker.addItem('Format results', 'Preparing visualization');
tracker.updateItem(id1, 'completed');
tracker.updateItem(id2, 'completed');
tracker.updateItem(id3, 'error', 'Table "sales_data" does not exist');
// id4 should remain pending due to error
}
}, 100);
return html`
<div style="width: 350px; height: 400px;">
<vanna-progress-tracker .title=${args.title}></vanna-progress-tracker>
</div>
`;
},
};
export const MultipleErrors: Story = {
args: {
title: 'Error Scenarios',
},
render: (args) => {
setTimeout(() => {
const tracker = document.querySelector('vanna-progress-tracker') as any;
if (tracker) {
const id1 = tracker.addItem('Connect to database', 'Establishing connection');
const id2 = tracker.addItem('Validate schema', 'Checking table structure');
const id3 = tracker.addItem('Parse SQL query', 'Analyzing syntax');
tracker.addItem('Execute query', 'Running database command');
tracker.addItem('Process results', 'Formatting output');
tracker.updateItem(id1, 'error', 'Connection timeout - database unreachable');
tracker.updateItem(id2, 'error', 'Invalid credentials provided');
tracker.updateItem(id3, 'error', 'Syntax error in SQL query');
// Other items remain pending
}
}, 100);
return html`
<div style="width: 350px; height: 400px;">
<vanna-progress-tracker .title=${args.title}></vanna-progress-tracker>
<p style="font-size: 12px; color: #666; margin-top: 10px;">
Example showing multiple error states with detailed error messages
</p>
</div>
`;
},
};
export const LiveDemo: Story = {
args: {
title: 'Live Progress Demo',
},
render: (args) => {
let tracker: any;
let taskIds: string[] = [];
let currentIndex = 0;
const tasks = [
{ text: 'Initialize AI agent', detail: 'Loading language model' },
{ text: 'Analyze user request', detail: 'Processing natural language' },
{ text: 'Query database schema', detail: 'Understanding data structure' },
{ text: 'Generate SQL query', detail: 'Converting request to SQL' },
{ text: 'Execute query', detail: 'Running against database' },
{ text: 'Process results', detail: 'Formatting data for display' },
{ text: 'Generate visualization', detail: 'Creating charts and graphs' }
];
const runDemo = () => {
if (!tracker) {
tracker = document.querySelector('vanna-progress-tracker');
if (!tracker) {
setTimeout(runDemo, 100);
return;
}
}
// Add all tasks as pending
if (taskIds.length === 0) {
taskIds = tasks.map(task => tracker.addItem(task.text, task.detail));
currentIndex = 0;
}
// Process tasks one by one
if (currentIndex < tasks.length) {
// Mark current as in_progress
tracker.updateItem(taskIds[currentIndex], 'in_progress', `${tasks[currentIndex].detail}...`);
// Complete after 2 seconds, then move to next
setTimeout(() => {
tracker.updateItem(taskIds[currentIndex], 'completed');
currentIndex++;
// Continue with next task
if (currentIndex < tasks.length) {
setTimeout(runDemo, 500);
} else {
// Demo complete - restart after 3 seconds
setTimeout(() => {
tracker.clearItems();
taskIds = [];
currentIndex = 0;
setTimeout(runDemo, 1000);
}, 3000);
}
}, 2000);
}
};
setTimeout(runDemo, 500);
return html`
<div style="width: 350px; height: 400px;">
<vanna-progress-tracker .title=${args.title}></vanna-progress-tracker>
<div style="margin-top: 10px; color: #999; font-size: 12px; text-align: center;">
Watch tasks complete automatically (demo loops)
</div>
</div>
`;
},
};

View File

@@ -0,0 +1,263 @@
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { vannaDesignTokens } from '../styles/vanna-design-tokens.js';
interface ProgressItem {
id: string;
text: string;
status: 'pending' | 'in_progress' | 'completed' | 'error';
detail?: string;
}
@customElement('vanna-progress-tracker')
export class VannaProgressTracker extends LitElement {
static styles = [
vannaDesignTokens,
css`
:host {
display: block;
background: var(--vanna-background-default);
border: 1px solid var(--vanna-outline-default);
border-radius: 0 0 var(--vanna-border-radius-lg) var(--vanna-border-radius-lg);
overflow: hidden;
font-family: var(--vanna-font-family-default);
}
.progress-label {
padding: var(--vanna-space-3) var(--vanna-space-4) var(--vanna-space-2);
display: flex;
align-items: center;
justify-content: space-between;
}
.progress-label-text {
font-size: 11px;
font-weight: 500;
color: var(--vanna-foreground-dimmest);
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0;
}
.progress-summary {
font-size: 10px;
color: var(--vanna-foreground-dimmest);
font-weight: 400;
}
.progress-list {
max-height: 300px;
overflow-y: auto;
padding-top: 0;
}
.progress-item {
padding: var(--vanna-space-3) var(--vanna-space-4);
border-bottom: 1px solid var(--vanna-outline-dimmest);
display: flex;
align-items: flex-start;
gap: var(--vanna-space-3);
transition: background var(--vanna-duration-150) ease;
}
.progress-item:last-child {
border-bottom: none;
}
.progress-item:hover {
background: var(--vanna-background-higher);
}
.progress-item.in_progress {
background: rgba(0, 123, 255, 0.05);
border-left: 3px solid var(--vanna-accent-primary-default);
}
.progress-item.completed {
opacity: 0.7;
}
.progress-item.error {
background: var(--vanna-accent-negative-subtle);
border-left: 3px solid var(--vanna-accent-negative-default);
padding-left: calc(var(--vanna-space-3) - 3px);
}
.progress-item.error .progress-text {
color: var(--vanna-accent-negative-stronger);
}
.progress-item.error .progress-detail {
color: var(--vanna-accent-negative-default);
font-weight: 500;
}
.progress-icon {
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 1px;
}
.progress-icon.pending {
background: var(--vanna-outline-default);
}
.progress-icon.in_progress {
background: var(--vanna-accent-primary-default);
}
.progress-icon.completed {
background: var(--vanna-accent-positive-default);
}
.progress-icon.error {
background: var(--vanna-accent-negative-default);
box-shadow: 0 0 0 2px var(--vanna-accent-negative-subtle);
}
.progress-icon svg {
width: 10px;
height: 10px;
color: white;
}
.progress-icon.error svg {
width: 8px;
height: 8px;
color: white;
}
.spinner-mini {
width: 10px;
height: 10px;
border: 1.5px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.progress-content {
flex: 1;
min-width: 0;
}
.progress-text {
font-size: 13px;
color: var(--vanna-foreground-default);
font-weight: 500;
margin: 0 0 var(--vanna-space-1) 0;
line-height: 1.3;
}
.progress-detail {
font-size: 11px;
color: var(--vanna-foreground-dimmest);
margin: 0;
line-height: 1.3;
}
.empty-state {
padding: var(--vanna-space-6) var(--vanna-space-4);
text-align: center;
color: var(--vanna-foreground-dimmest);
font-size: 12px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`
];
@property() title = 'Progression';
@property() theme = 'light';
@state() private items: ProgressItem[] = [];
addItem(text: string, detail?: string, id?: string): string {
const itemId = id || Date.now().toString();
this.items = [...this.items, {
id: itemId,
text,
status: 'pending',
detail
}];
return itemId;
}
updateItem(id: string, status: ProgressItem['status'], detail?: string) {
this.items = this.items.map(item =>
item.id === id ? { ...item, status, detail } : item
);
}
clearItems() {
this.items = [];
}
private getStatusIcon(status: ProgressItem['status']) {
switch (status) {
case 'pending':
return html``;
case 'in_progress':
return html`<div class="spinner-mini"></div>`;
case 'completed':
return html`
<svg viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
`;
case 'error':
return html`
<svg viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
`;
}
}
private getProgressSummary() {
const completed = this.items.filter(item => item.status === 'completed').length;
const total = this.items.length;
const inProgress = this.items.filter(item => item.status === 'in_progress').length;
if (inProgress > 0) {
return `${completed}/${total} terminé${completed > 1 ? 's' : ''}`;
}
return total > 0 ? `${completed}/${total} terminé${completed > 1 ? 's' : ''}` : '';
}
render() {
return html`
${this.items.length > 0 ? html`
<div class="progress-label">
<span class="progress-label-text">Tâches</span>
<span class="progress-summary">${this.getProgressSummary()}</span>
</div>
` : ''}
<div class="progress-list">
${this.items.length === 0
? html`<div class="empty-state">Aucune tâche en cours</div>`
: this.items.map(item => html`
<div class="progress-item ${item.status}">
<div class="progress-icon ${item.status}">
${this.getStatusIcon(item.status)}
</div>
<div class="progress-content">
<p class="progress-text">${item.text}</p>
${item.detail ? html`<p class="progress-detail">${item.detail}</p>` : ''}
</div>
</div>
`)
}
</div>
`;
}
}

View File

@@ -0,0 +1,168 @@
import type { Meta, StoryObj } from '@storybook/web-components';
import { html } from 'lit';
import './vanna-status-bar';
const meta: Meta = {
title: 'Components/VannaStatusBar',
component: 'vanna-status-bar',
parameters: {
layout: 'centered',
backgrounds: {
default: 'light',
values: [
{ name: 'dark', value: 'rgb(11, 15, 25)' },
{ name: 'light', value: '#ffffff' },
],
},
},
argTypes: {
status: {
control: 'select',
options: ['idle', 'working', 'error', 'success'],
},
message: { control: 'text' },
detail: { control: 'text' },
theme: {
control: 'select',
options: ['dark', 'light'],
description: 'Theme variant'
},
},
};
export default meta;
type Story = StoryObj;
export const Idle: Story = {
args: {
status: 'idle',
message: '',
detail: '',
},
render: (args) => html`
<div style="width: 500px; padding: 20px;">
<vanna-status-bar
.status=${args.status}
.message=${args.message}
.detail=${args.detail}>
</vanna-status-bar>
<div style="margin-top: 10px; color: #999; font-size: 12px;">
Status bar is hidden when idle
</div>
</div>
`,
};
export const Working: Story = {
args: {
status: 'working',
message: 'Analyzing your database schema...',
detail: 'Step 1 of 3',
theme: 'light',
},
render: (args) => html`
<div style="width: 500px; padding: 20px; background: ${args.theme === 'light' ? '#ffffff' : 'rgb(11, 15, 25)'};">
<vanna-status-bar
.status=${args.status}
.message=${args.message}
.detail=${args.detail}
theme=${args.theme}>
</vanna-status-bar>
</div>
`,
};
export const WorkingLight: Story = {
args: {
status: 'working',
message: 'Analyzing your database schema...',
detail: 'Step 1 of 3',
theme: 'light',
},
render: (args) => html`
<div style="width: 500px; padding: 20px; background: ${args.theme === 'light' ? '#ffffff' : 'rgb(11, 15, 25)'};">
<vanna-status-bar
.status=${args.status}
.message=${args.message}
.detail=${args.detail}
theme=${args.theme}>
</vanna-status-bar>
</div>
`,
};
export const Success: Story = {
args: {
status: 'success',
message: 'Query executed successfully',
detail: '2.3s',
},
render: (args) => html`
<div style="width: 500px; padding: 20px;">
<vanna-status-bar
.status=${args.status}
.message=${args.message}
.detail=${args.detail}>
</vanna-status-bar>
</div>
`,
};
export const Error: Story = {
args: {
status: 'error',
message: 'Failed to connect to database',
detail: 'Connection timeout after 30s',
},
render: (args) => html`
<div style="width: 500px; padding: 20px;">
<vanna-status-bar
.status=${args.status}
.message=${args.message}
.detail=${args.detail}>
</vanna-status-bar>
</div>
`,
};
export const StatusSequence: Story = {
render: () => {
let statusBar: any;
let currentIndex = 0;
const statuses = [
{ status: 'working', message: 'Starting analysis...', detail: 'Initializing' },
{ status: 'working', message: 'Querying database...', detail: 'Step 1 of 3' },
{ status: 'working', message: 'Processing results...', detail: 'Step 2 of 3' },
{ status: 'working', message: 'Generating visualization...', detail: 'Step 3 of 3' },
{ status: 'success', message: 'Analysis complete!', detail: '4.2s total' },
];
const updateStatus = () => {
if (statusBar && currentIndex < statuses.length) {
const current = statuses[currentIndex];
statusBar.status = current.status;
statusBar.message = current.message;
statusBar.detail = current.detail;
currentIndex++;
if (currentIndex < statuses.length) {
setTimeout(updateStatus, 2000);
}
}
};
setTimeout(() => {
statusBar = document.querySelector('vanna-status-bar');
updateStatus();
}, 100);
return html`
<div style="width: 500px; padding: 20px;">
<vanna-status-bar status="idle"></vanna-status-bar>
<div style="margin-top: 10px; color: #999; font-size: 12px;">
Watch the status bar cycle through different states
</div>
</div>
`;
},
};

View File

@@ -0,0 +1,433 @@
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { vannaDesignTokens } from '../styles/vanna-design-tokens.js';
@customElement('vanna-status-bar')
export class VannaStatusBar extends LitElement {
static styles = [
vannaDesignTokens,
css`
:host {
display: block;
background: var(--vanna-background-default);
border: 1px solid var(--vanna-outline-default);
border-radius: var(--vanna-border-radius-lg);
padding: var(--vanna-space-3) var(--vanna-space-4);
margin-bottom: var(--vanna-space-3);
font-family: var(--vanna-font-family-default);
font-size: 13px;
font-weight: 500;
color: var(--vanna-foreground-default);
box-shadow: var(--vanna-shadow-xs);
/* Animation properties */
opacity: 1;
transform: translateY(0) scale(1);
max-height: 200px;
overflow: hidden;
transition:
opacity var(--vanna-duration-300) cubic-bezier(0.4, 0, 0.2, 1),
transform var(--vanna-duration-300) cubic-bezier(0.4, 0, 0.2, 1),
max-height var(--vanna-duration-300) ease,
margin var(--vanna-duration-300) ease,
padding var(--vanna-duration-300) ease,
box-shadow var(--vanna-duration-200) ease;
}
/* Hide when there's no actual content */
:host(.no-content) {
opacity: 0;
transform: translateY(-8px) scale(0.95);
max-height: 0;
margin: 0;
padding: 0;
pointer-events: none;
}
:host(:empty) {
display: none;
}
/* Entrance animation when content appears */
:host(.entering) {
animation: statusEnter var(--vanna-duration-300) ease-out;
}
/* Exit animation when content disappears */
:host(.exiting) {
animation: statusExit var(--vanna-duration-300) ease-in;
}
@keyframes statusEnter {
0% {
opacity: 0;
transform: translateY(-12px) scale(0.9);
max-height: 0;
}
50% {
opacity: 0.8;
transform: translateY(-2px) scale(1.02);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
max-height: 200px;
}
}
@keyframes statusExit {
0% {
opacity: 1;
transform: translateY(0) scale(1);
max-height: 200px;
}
50% {
opacity: 0.5;
transform: translateY(-4px) scale(0.98);
}
100% {
opacity: 0;
transform: translateY(-12px) scale(0.9);
max-height: 0;
}
}
:host([status="working"]) {
background: var(--vanna-accent-primary-default);
border-color: var(--vanna-accent-primary-default);
color: white;
box-shadow:
var(--vanna-shadow-md),
0 0 0 1px rgba(59, 130, 246, 0.2);
}
:host([status="error"]) {
background: var(--vanna-accent-negative-subtle);
border-color: var(--vanna-accent-negative-default);
color: var(--vanna-accent-negative-stronger);
box-shadow: var(--vanna-shadow-sm);
animation: errorShake 0.5s ease-in-out;
}
:host([status="success"]) {
background: var(--vanna-accent-positive-subtle);
border-color: var(--vanna-accent-positive-default);
color: var(--vanna-accent-positive-stronger);
box-shadow: var(--vanna-shadow-sm);
animation: successPulse 0.6s ease-out;
}
@keyframes errorShake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
20%, 40%, 60%, 80% { transform: translateX(4px); }
}
@keyframes successPulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
.status-content {
display: flex;
align-items: center;
gap: var(--vanna-space-3);
animation: contentFadeIn var(--vanna-duration-200) ease-out;
}
@keyframes contentFadeIn {
0% {
opacity: 0;
transform: translateY(4px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: var(--vanna-border-radius-full);
background: var(--vanna-accent-primary-default);
flex-shrink: 0;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5), 0 2px 8px rgba(0, 0, 0, 0.15);
}
.status-indicator.working {
background: white;
animation: workingPulse 1.5s ease-in-out infinite;
}
.status-indicator.error {
background: linear-gradient(45deg, var(--vanna-accent-negative-default), var(--vanna-accent-negative-stronger));
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5), 0 0 8px rgba(239, 68, 68, 0.4);
}
.status-indicator.success {
background: linear-gradient(45deg, var(--vanna-accent-positive-default), var(--vanna-accent-positive-stronger));
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5), 0 0 8px rgba(16, 185, 129, 0.4);
}
.spinner {
width: 16px;
height: 16px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: var(--vanna-border-radius-full);
animation: spin 1s linear infinite, spinnerGlow 2s ease-in-out infinite;
flex-shrink: 0;
}
.status-text {
flex: 1;
font-weight: 600;
line-height: 1.4;
letter-spacing: 0.01em;
}
.status-detail {
font-size: 12px;
color: var(--vanna-foreground-dimmest);
margin-left: var(--vanna-space-4);
opacity: 0.9;
font-weight: 500;
}
.status-actions {
display: flex;
align-items: center;
gap: var(--vanna-space-2);
margin-left: auto;
}
.status-button {
padding: var(--vanna-space-1) var(--vanna-space-2);
border: 1px solid var(--vanna-outline-default);
border-radius: var(--vanna-border-radius-sm);
background: var(--vanna-background-subtle);
color: var(--vanna-foreground-dimmer);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all var(--vanna-duration-150) ease;
}
.status-button:hover {
background: var(--vanna-background-higher);
border-color: var(--vanna-outline-hover);
color: var(--vanna-foreground-default);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.1);
}
}
@keyframes workingPulse {
0%, 100% {
opacity: 1;
transform: scale(1);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.8), 0 2px 8px rgba(255, 255, 255, 0.3);
}
50% {
opacity: 0.9;
transform: scale(1.2);
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.9), 0 4px 12px rgba(255, 255, 255, 0.5);
}
}
@keyframes spinnerGlow {
0%, 100% {
filter: drop-shadow(0 0 2px rgba(21, 168, 168, 0.5));
}
50% {
filter: drop-shadow(0 0 6px rgba(21, 168, 168, 0.8));
}
}
@keyframes errorGlow {
0% {
box-shadow:
var(--vanna-shadow-xl),
0 0 0 2px rgba(239, 68, 68, 0.3),
0 0 20px rgba(239, 68, 68, 0.2);
}
50% {
box-shadow:
var(--vanna-shadow-2xl),
0 0 0 3px rgba(239, 68, 68, 0.4),
0 0 30px rgba(239, 68, 68, 0.3);
}
100% {
box-shadow:
var(--vanna-shadow-xl),
0 0 0 2px rgba(239, 68, 68, 0.3),
0 0 20px rgba(239, 68, 68, 0.2);
}
}
@keyframes successGlow {
0% {
box-shadow:
var(--vanna-shadow-xl),
0 0 0 2px rgba(16, 185, 129, 0.3),
0 0 20px rgba(16, 185, 129, 0.2);
}
50% {
box-shadow:
var(--vanna-shadow-2xl),
0 0 0 3px rgba(16, 185, 129, 0.4),
0 0 30px rgba(16, 185, 129, 0.3);
}
100% {
box-shadow:
var(--vanna-shadow-xl),
0 0 0 2px rgba(16, 185, 129, 0.3),
0 0 20px rgba(16, 185, 129, 0.2);
}
}
/* Dark theme overrides */
:host([theme="dark"]) {
background: var(--vanna-background-higher);
border-color: var(--vanna-outline-default);
}
:host([theme="dark"]) .status-button {
background: var(--vanna-background-highest);
border-color: var(--vanna-outline-default);
}
:host([theme="dark"]) .status-button:hover {
background: var(--vanna-background-highest);
border-color: var(--vanna-outline-hover);
}
`
];
@property() status: 'idle' | 'working' | 'error' | 'success' = 'idle';
@property() message = '';
@property() detail = '';
@property() theme = 'light';
private _previousHasContent = false;
private _enterTimeout: number | null = null;
private _exitTimeout: number | null = null;
private _lastUpdateTime = 0;
disconnectedCallback() {
super.disconnectedCallback();
// Clean up pending animation timeouts when component is removed
if (this._enterTimeout !== null) {
clearTimeout(this._enterTimeout);
this._enterTimeout = null;
}
if (this._exitTimeout !== null) {
clearTimeout(this._exitTimeout);
this._exitTimeout = null;
}
}
updated(_changedProperties: Map<string | number | symbol, unknown>) {
// Update CSS class based on content
const hasContent = Boolean(this.message && this.message.trim());
// Cancel any pending animation timeouts to prevent race conditions
if (this._enterTimeout !== null) {
clearTimeout(this._enterTimeout);
this._enterTimeout = null;
}
if (this._exitTimeout !== null) {
clearTimeout(this._exitTimeout);
this._exitTimeout = null;
}
// Debounce rapid updates to prevent animation jank
const now = Date.now();
const timeSinceLastUpdate = now - this._lastUpdateTime;
const shouldDebounce = timeSinceLastUpdate < 100; // 100ms debounce
// Handle animation classes
if (hasContent !== this._previousHasContent) {
if (hasContent) {
// Content appeared - animate in
this.classList.remove('no-content', 'exiting');
if (!shouldDebounce) {
// Only animate if not rapid-firing
this.classList.add('entering');
// Remove entering class after animation
this._enterTimeout = window.setTimeout(() => {
this.classList.remove('entering');
this._enterTimeout = null;
}, 300);
}
} else {
// Content disappeared - animate out
this.classList.remove('entering');
if (!shouldDebounce) {
// Only animate if not rapid-firing
this.classList.add('exiting');
// Add no-content class after animation
this._exitTimeout = window.setTimeout(() => {
this.classList.remove('exiting');
this.classList.add('no-content');
this._exitTimeout = null;
}, 300);
} else {
// If rapid-firing, skip animation and go straight to no-content
this.classList.add('no-content');
}
}
} else if (!hasContent) {
// Ensure no-content class is applied when no content
this.classList.add('no-content');
}
this._previousHasContent = hasContent;
this._lastUpdateTime = now;
}
render() {
// Only show if there's actual content (message) to display
if (!this.message || !this.message.trim()) {
return html``;
}
return html`
<div class="status-content">
${this.status === 'working'
? html`<div class="spinner"></div>`
: html`<div class="status-indicator ${this.status}"></div>`
}
<span class="status-text">${this.message}</span>
${this.detail ? html`<span class="status-detail">${this.detail}</span>` : ''}
</div>
`;
}
}

View File

@@ -0,0 +1,37 @@
// Log build information when the module loads
console.log(
'%c🎨 AIVANOV Components',
'color: #4CAF50; font-weight: bold; font-size: 14px;'
);
console.log(
`%c📦 Version: ${__BUILD_VERSION__}`,
'color: #2196F3; font-weight: bold;'
);
console.log(
`%c🕐 Built: ${__BUILD_TIME__}`,
'color: #FF9800; font-weight: bold;'
);
console.log(
'%c━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━',
'color: #9E9E9E;'
);
export { VannaChat } from './components/vanna-chat';
export { VannaMessage } from './components/vanna-message';
export { VannaStatusBar } from './components/vanna-status-bar';
export { VannaProgressTracker } from './components/vanna-progress-tracker';
export { PlotlyChart } from './components/plotly-chart';
// Rich component system
export {
ComponentRegistry,
ComponentManager,
CardComponentRenderer,
TaskListComponentRenderer,
ProgressBarComponentRenderer,
NotificationComponentRenderer,
StatusIndicatorComponentRenderer,
TextComponentRenderer
} from './components/rich-component-system';
// Rich component styles are injected automatically by the ComponentManager

View File

@@ -0,0 +1,296 @@
/**
* API client for communicating with Vanna Agents backend
*/
export interface ChatMessage {
id: string;
content: string;
type: 'user' | 'assistant';
timestamp: number;
}
export interface ChatRequest {
message: string;
conversation_id?: string;
user_id?: string;
request_id?: string;
metadata?: Record<string, any>;
}
export interface ChatStreamChunk {
rich: Record<string, any>;
simple?: Record<string, any>;
conversation_id: string;
request_id: string;
timestamp: number;
}
export interface ChatResponse {
chunks: ChatStreamChunk[];
conversation_id: string;
request_id: string;
total_chunks: number;
}
export interface ApiClientConfig {
baseUrl?: string;
sseEndpoint?: string;
wsEndpoint?: string;
pollEndpoint?: string;
timeout?: number;
customHeaders?: Record<string, string>;
}
export class VannaApiClient {
public readonly baseUrl: string;
private sseEndpoint: string;
private wsEndpoint: string;
private pollEndpoint: string;
private timeout: number;
private customHeaders: Record<string, string>;
constructor(config: ApiClientConfig = {}) {
this.baseUrl = config.baseUrl || '';
this.sseEndpoint = config.sseEndpoint || '/api/vanna/v2/chat_sse';
this.wsEndpoint = config.wsEndpoint || '/api/vanna/v2/chat_websocket';
this.pollEndpoint = config.pollEndpoint || '/api/vanna/v2/chat_poll';
this.timeout = config.timeout || 30000;
this.customHeaders = config.customHeaders || {};
console.log('[VannaApiClient] Constructor called with config:', config);
console.log('[VannaApiClient] Endpoint configuration:');
console.log(' - SSE endpoint:', this.sseEndpoint, config.sseEndpoint ? '(custom)' : '(default)');
console.log(' - WS endpoint:', this.wsEndpoint, config.wsEndpoint ? '(custom)' : '(default)');
console.log(' - Poll endpoint:', this.pollEndpoint, config.pollEndpoint ? '(custom)' : '(default)');
console.log(' - Base URL:', this.baseUrl || '(empty)');
}
/**
* Update custom headers (e.g., for authentication)
*/
setCustomHeaders(headers: Record<string, string>) {
this.customHeaders = headers;
}
/**
* Get current custom headers
*/
getCustomHeaders(): Record<string, string> {
return { ...this.customHeaders };
}
/**
* Send message using Server-Sent Events (SSE) streaming
*/
async *streamChat(request: ChatRequest): AsyncGenerator<ChatStreamChunk, void, unknown> {
const url = this.sseEndpoint.startsWith('http')
? this.sseEndpoint
: `${this.baseUrl}${this.sseEndpoint}`;
console.log('[VannaApiClient] SSE streaming to URL:', url);
console.log('[VannaApiClient] SSE endpoint config:', {
baseUrl: this.baseUrl,
sseEndpoint: this.sseEndpoint,
constructedUrl: url
});
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
...this.customHeaders,
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim();
if (data === '[DONE]') {
return;
}
try {
const chunk = JSON.parse(data) as ChatStreamChunk;
yield chunk;
} catch (e) {
console.warn('Failed to parse SSE chunk:', data, e);
}
}
}
}
} finally {
reader.releaseLock();
}
}
/**
* Send message using WebSocket
*/
createWebSocketConnection(): Promise<WebSocket> {
return new Promise((resolve, reject) => {
let wsUrl: string;
if (this.wsEndpoint.startsWith('ws://') || this.wsEndpoint.startsWith('wss://')) {
// Absolute WebSocket URL provided
wsUrl = this.wsEndpoint;
} else {
// Relative path - construct from baseUrl
if (this.baseUrl) {
// Parse baseUrl to extract host and convert http(s) to ws(s)
const baseUrlObj = new URL(this.baseUrl);
const wsProtocol = baseUrlObj.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${wsProtocol}//${baseUrlObj.host}${this.wsEndpoint}`;
} else {
// Fallback to window.location
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}${this.wsEndpoint}`;
}
}
const ws = new WebSocket(wsUrl);
ws.onopen = () => resolve(ws);
ws.onerror = (error) => reject(error);
// Set timeout
setTimeout(() => {
if (ws.readyState === WebSocket.CONNECTING) {
ws.close();
reject(new Error('WebSocket connection timeout'));
}
}, this.timeout);
});
}
/**
* Send message via WebSocket
*/
async sendWebSocketMessage(
ws: WebSocket,
request: ChatRequest
): Promise<AsyncGenerator<ChatStreamChunk, void, unknown>> {
return new Promise((resolve, reject) => {
if (ws.readyState !== WebSocket.OPEN) {
reject(new Error('WebSocket not connected'));
return;
}
async function* generator() {
let isCompleted = false;
const messageQueue: ChatStreamChunk[] = [];
let resolveNext: ((value: IteratorResult<ChatStreamChunk>) => void) | null = null;
const messageHandler = (event: MessageEvent) => {
try {
const chunk = JSON.parse(event.data) as ChatStreamChunk;
if (chunk.rich?.type === 'completion') {
isCompleted = true;
if (resolveNext) {
resolveNext({ done: true, value: undefined });
resolveNext = null;
}
return;
}
if (chunk.rich?.type === 'error') {
ws.removeEventListener('message', messageHandler);
if (resolveNext) {
resolveNext({ done: true, value: undefined });
}
return;
}
if (resolveNext) {
resolveNext({ done: false, value: chunk });
resolveNext = null;
} else {
messageQueue.push(chunk);
}
} catch (e) {
console.warn('Failed to parse WebSocket message:', event.data, e);
}
};
ws.addEventListener('message', messageHandler);
while (!isCompleted) {
if (messageQueue.length > 0) {
yield messageQueue.shift()!;
} else {
await new Promise<IteratorResult<ChatStreamChunk>>((resolve) => {
resolveNext = resolve;
});
}
}
ws.removeEventListener('message', messageHandler);
}
try {
ws.send(JSON.stringify(request));
resolve(generator());
} catch (error) {
reject(error);
}
});
}
/**
* Send message using polling (fallback option)
*/
async sendPollMessage(request: ChatRequest): Promise<ChatResponse> {
const url = this.pollEndpoint.startsWith('http')
? this.pollEndpoint
: `${this.baseUrl}${this.pollEndpoint}`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.customHeaders,
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json() as Promise<ChatResponse>;
}
/**
* Generate unique IDs for conversations and requests
*/
generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
}
}
/**
* Default API client instance
*/
export const apiClient = new VannaApiClient();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,151 @@
import { css } from 'lit';
// AIVANOV design tokens - Analyse de données par IA
export const vannaDesignTokens = css`
:host {
/* AIVANOV Brand Colors */
--vanna-navy: rgb(15, 23, 42);
--vanna-cream: rgb(248, 250, 252);
--vanna-teal: rgb(59, 130, 246);
--vanna-orange: rgb(249, 115, 22);
--vanna-magenta: rgb(191, 19, 99);
/* Color Palette - Light mode (default) */
--vanna-background-root: rgb(255, 255, 255);
--vanna-background-default: rgb(248, 250, 252);
--vanna-background-higher: rgb(241, 245, 249);
--vanna-background-highest: rgb(226, 232, 240);
--vanna-background-subtle: rgb(250, 251, 253);
--vanna-background-lower: rgb(241, 245, 249);
--vanna-foreground-default: rgb(15, 23, 42);
--vanna-foreground-dimmer: rgb(71, 85, 105);
--vanna-foreground-dimmest: rgb(148, 163, 184);
--vanna-accent-primary-default: rgb(59, 130, 246);
--vanna-accent-primary-stronger: rgb(37, 99, 235);
--vanna-accent-primary-strongest: rgb(29, 78, 216);
--vanna-accent-primary-subtle: rgba(59, 130, 246, 0.08);
--vanna-accent-primary-hover: rgb(37, 99, 235);
--vanna-accent-positive-default: rgb(34, 197, 94);
--vanna-accent-positive-stronger: rgb(22, 163, 74);
--vanna-accent-positive-subtle: rgba(34, 197, 94, 0.08);
--vanna-accent-negative-default: rgb(239, 68, 68);
--vanna-accent-negative-stronger: rgb(220, 38, 38);
--vanna-accent-negative-subtle: rgba(239, 68, 68, 0.08);
--vanna-accent-warning-default: rgb(245, 158, 11);
--vanna-accent-warning-stronger: rgb(217, 119, 6);
--vanna-accent-warning-subtle: rgba(245, 158, 11, 0.08);
/* Outline/Border colors */
--vanna-outline-default: rgb(226, 232, 240);
--vanna-outline-dimmer: rgb(241, 245, 249);
--vanna-outline-dimmest: rgb(248, 250, 252);
--vanna-outline-hover: rgb(59, 130, 246);
/* Typography */
--vanna-font-family-default: "Space Grotesk", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
--vanna-font-family-serif: "Roboto Slab", ui-serif, Georgia, serif;
--vanna-font-family-mono: "Space Mono", ui-monospace, SFMono-Regular, "SF Mono", Monaco, Inconsolata, "Roboto Mono", "Ubuntu Mono", monospace;
/* Spacing scale */
--vanna-space-0: 0px;
--vanna-space-1: 4px;
--vanna-space-2: 8px;
--vanna-space-3: 12px;
--vanna-space-4: 16px;
--vanna-space-5: 20px;
--vanna-space-6: 24px;
--vanna-space-7: 28px;
--vanna-space-8: 32px;
--vanna-space-10: 40px;
--vanna-space-12: 48px;
--vanna-space-16: 64px;
/* Border radius */
--vanna-border-radius-sm: 6px;
--vanna-border-radius-md: 10px;
--vanna-border-radius-lg: 14px;
--vanna-border-radius-xl: 20px;
--vanna-border-radius-2xl: 24px;
--vanna-border-radius-full: 9999px;
/* Shadows - Preline-inspired */
--vanna-shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--vanna-shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
--vanna-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--vanna-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
--vanna-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
--vanna-shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
/* Animation durations */
--vanna-duration-75: 75ms;
--vanna-duration-100: 100ms;
--vanna-duration-150: 150ms;
--vanna-duration-200: 200ms;
--vanna-duration-300: 300ms;
--vanna-duration-500: 500ms;
--vanna-duration-700: 700ms;
/* Z-index scale */
--vanna-z-dropdown: 1000;
--vanna-z-sticky: 1020;
--vanna-z-fixed: 1030;
--vanna-z-modal: 1040;
--vanna-z-popover: 1050;
--vanna-z-tooltip: 1060;
/* Chat-specific tokens */
--vanna-chat-bubble-radius: 18px;
--vanna-chat-bubble-radius-sm: 12px;
--vanna-chat-spacing: 16px;
--vanna-chat-avatar-size: 40px;
}
/* Dark theme overrides */
:host([theme="dark"]) {
--vanna-background-root: rgb(9, 11, 17);
--vanna-background-default: rgb(15, 18, 25);
--vanna-background-higher: rgb(24, 29, 39);
--vanna-background-highest: rgb(31, 39, 51);
--vanna-background-subtle: rgb(17, 21, 28);
--vanna-background-lower: rgb(6, 8, 12);
--vanna-foreground-default: rgb(248, 250, 252);
--vanna-foreground-dimmer: rgb(203, 213, 225);
--vanna-foreground-dimmest: rgb(148, 163, 184);
--vanna-accent-primary-default: rgb(96, 165, 250);
--vanna-accent-primary-stronger: rgb(59, 130, 246);
--vanna-accent-primary-strongest: rgb(37, 99, 235);
--vanna-accent-primary-subtle: rgba(96, 165, 250, 0.12);
--vanna-accent-primary-hover: rgb(96, 165, 250);
--vanna-accent-positive-default: rgb(74, 222, 128);
--vanna-accent-positive-stronger: rgb(34, 197, 94);
--vanna-accent-positive-subtle: rgba(74, 222, 128, 0.12);
--vanna-accent-negative-default: rgb(248, 113, 113);
--vanna-accent-negative-stronger: rgb(239, 68, 68);
--vanna-accent-negative-subtle: rgba(248, 113, 113, 0.12);
--vanna-accent-warning-default: rgb(251, 191, 36);
--vanna-accent-warning-stronger: rgb(245, 158, 11);
--vanna-accent-warning-subtle: rgba(251, 191, 36, 0.12);
--vanna-outline-default: rgb(51, 65, 85);
--vanna-outline-dimmer: rgb(31, 41, 55);
--vanna-outline-dimmest: rgb(17, 24, 39);
--vanna-outline-hover: rgb(96, 165, 250);
--vanna-shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.6);
--vanna-shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.5), 0 1px 2px -1px rgba(0, 0, 0, 0.5);
--vanna-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.4);
--vanna-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
--vanna-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
--vanna-shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
}
`;

View File

@@ -0,0 +1,4 @@
/// <reference types="vite/client" />
declare const __BUILD_TIME__: string;
declare const __BUILD_VERSION__: string;

View File

@@ -0,0 +1,598 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vanna Webcomponent - Comprehensive Test</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: 300px;
background: white;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.sidebar-header {
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.sidebar-header h1 {
font-size: 18px;
margin-bottom: 8px;
}
.sidebar-header p {
font-size: 12px;
opacity: 0.9;
}
.controls {
padding: 20px;
border-bottom: 1px solid #e0e0e0;
}
.control-group {
margin-bottom: 15px;
}
.control-group label {
display: block;
font-size: 12px;
font-weight: 600;
color: #666;
margin-bottom: 5px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
button {
width: 100%;
padding: 10px 15px;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
button:hover {
background: #5568d3;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
}
button:active {
transform: translateY(0);
}
button.secondary {
background: #f0f0f0;
color: #333;
}
button.secondary:hover {
background: #e0e0e0;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
select {
width: 100%;
padding: 8px 12px;
border: 1px solid #e0e0e0;
border-radius: 6px;
font-size: 14px;
background: white;
cursor: pointer;
}
.status-section {
padding: 20px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background: #f8f9fa;
border-radius: 6px;
margin-bottom: 10px;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #ccc;
}
.status-dot.success {
background: #10b981;
animation: pulse 2s infinite;
}
.status-dot.error {
background: #ef4444;
animation: pulse 2s infinite;
}
.status-dot.warning {
background: #f59e0b;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.status-text {
font-size: 13px;
font-weight: 500;
color: #333;
}
.console-monitor {
padding: 20px;
border-top: 1px solid #e0e0e0;
}
.console-monitor h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
color: #333;
}
.console-log {
background: #1e1e1e;
color: #d4d4d4;
padding: 12px;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 11px;
max-height: 200px;
overflow-y: auto;
}
.console-log .error {
color: #f48771;
}
.console-log .warning {
color: #dcdcaa;
}
.console-log .info {
color: #4fc1ff;
}
.checklist {
padding: 20px;
border-top: 1px solid #e0e0e0;
}
.checklist h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
color: #333;
}
.checklist-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
font-size: 12px;
color: #666;
}
.checklist-item .check {
width: 16px;
height: 16px;
border: 2px solid #e0e0e0;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
}
.checklist-item.checked .check {
background: #10b981;
border-color: #10b981;
color: white;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-container {
flex: 1;
overflow: hidden;
}
vanna-chat {
width: 100%;
height: 100%;
}
.metrics {
padding: 10px 20px;
background: white;
border-top: 1px solid #e0e0e0;
display: flex;
gap: 20px;
font-size: 12px;
color: #666;
}
.metric {
display: flex;
gap: 5px;
}
.metric strong {
color: #333;
}
</style>
</head>
<body>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-header">
<h1>Component Test Suite</h1>
<p>Comprehensive validation for webcomponent pruning</p>
</div>
<!-- Controls -->
<div class="controls">
<div class="control-group">
<label>Test Mode</label>
<select id="mode-select">
<option value="realistic">Realistic (with delays)</option>
<option value="rapid">Rapid (fast)</option>
</select>
</div>
<div class="control-group">
<button id="start-test">Run Comprehensive Test</button>
</div>
<div class="control-group">
<button class="secondary" id="clear-chat">Clear Chat</button>
</div>
</div>
<!-- Status -->
<div class="status-section">
<div class="status-indicator">
<div class="status-dot" id="backend-status"></div>
<div class="status-text" id="backend-text">Checking backend...</div>
</div>
<div class="status-indicator">
<div class="status-dot" id="console-status"></div>
<div class="status-text" id="console-text">No errors detected</div>
</div>
</div>
<!-- Component Checklist -->
<div class="checklist">
<h3>Component Rendering</h3>
<div class="checklist-item" data-component="text">
<div class="check"></div>
<span>Text Component</span>
</div>
<div class="checklist-item" data-component="status_card">
<div class="check"></div>
<span>Status Card</span>
</div>
<div class="checklist-item" data-component="progress_display">
<div class="check"></div>
<span>Progress Display</span>
</div>
<div class="checklist-item" data-component="card">
<div class="check"></div>
<span>Card</span>
</div>
<div class="checklist-item" data-component="task_list">
<div class="check"></div>
<span>Task List</span>
</div>
<div class="checklist-item" data-component="progress_bar">
<div class="check"></div>
<span>Progress Bar</span>
</div>
<div class="checklist-item" data-component="notification">
<div class="check"></div>
<span>Notification</span>
</div>
<div class="checklist-item" data-component="status_indicator">
<div class="check"></div>
<span>Status Indicator</span>
</div>
<div class="checklist-item" data-component="badge">
<div class="check"></div>
<span>Badge</span>
</div>
<div class="checklist-item" data-component="icon_text">
<div class="check"></div>
<span>Icon Text</span>
</div>
<div class="checklist-item" data-component="button">
<div class="check"></div>
<span>Button</span>
</div>
<div class="checklist-item" data-component="button_group">
<div class="check"></div>
<span>Button Group</span>
</div>
<div class="checklist-item" data-component="dataframe">
<div class="check"></div>
<span>DataFrame</span>
</div>
<div class="checklist-item" data-component="chart">
<div class="check"></div>
<span>Chart</span>
</div>
<div class="checklist-item" data-component="artifact">
<div class="check"></div>
<span>Artifact</span>
</div>
<div class="checklist-item" data-component="log_viewer">
<div class="check"></div>
<span>Log Viewer</span>
</div>
<!-- Note: code_block, table, container not supported by webcomponent -->
</div>
<!-- Console Monitor -->
<div class="console-monitor">
<h3>Console Log</h3>
<div class="console-log" id="console-log">
<div class="info">Monitoring console for errors...</div>
</div>
</div>
</div>
<!-- Main Content -->
<div class="main-content">
<div class="chat-container">
<vanna-chat
id="vanna-chat"
api-url="http://localhost:5555"
placeholder="Type /test to run comprehensive test..."
></vanna-chat>
</div>
<div class="metrics">
<div class="metric">
<strong>Components Rendered:</strong>
<span id="component-count">0</span>
</div>
<div class="metric">
<strong>Updates Processed:</strong>
<span id="update-count">0</span>
</div>
<div class="metric">
<strong>Errors:</strong>
<span id="error-count">0</span>
</div>
</div>
</div>
<!-- Load webcomponent -->
<script type="module" src="/static/vanna-components.js"></script>
<script type="module">
// State
let componentCount = 0;
let updateCount = 0;
let errorCount = 0;
const seenComponents = new Set();
// Elements
const vannaChat = document.getElementById('vanna-chat');
const modeSelect = document.getElementById('mode-select');
const startTestBtn = document.getElementById('start-test');
const clearChatBtn = document.getElementById('clear-chat');
const consoleLog = document.getElementById('console-log');
const backendStatus = document.getElementById('backend-status');
const backendText = document.getElementById('backend-text');
const consoleStatus = document.getElementById('console-status');
const consoleText = document.getElementById('console-text');
// Check backend health
async function checkBackend() {
try {
const response = await fetch('http://localhost:5555/health');
const data = await response.json();
backendStatus.className = 'status-dot success';
backendText.textContent = `Backend ready (${data.mode} mode)`;
} catch (error) {
backendStatus.className = 'status-dot error';
backendText.textContent = 'Backend not responding';
addConsoleLog('error', `Backend health check failed: ${error.message}`);
}
}
// Console monitoring
function addConsoleLog(type, message) {
const timestamp = new Date().toLocaleTimeString();
const logEntry = document.createElement('div');
logEntry.className = type;
logEntry.textContent = `[${timestamp}] ${message}`;
consoleLog.appendChild(logEntry);
consoleLog.scrollTop = consoleLog.scrollHeight;
// Update error status
if (type === 'error') {
errorCount++;
consoleStatus.className = 'status-dot error';
consoleText.textContent = `${errorCount} error(s) detected`;
document.getElementById('error-count').textContent = errorCount;
}
}
// Override console methods
const originalError = console.error;
const originalWarn = console.warn;
const originalLog = console.log;
console.error = function(...args) {
addConsoleLog('error', args.join(' '));
originalError.apply(console, args);
};
console.warn = function(...args) {
addConsoleLog('warning', args.join(' '));
originalWarn.apply(console, args);
};
console.log = function(...args) {
const message = args.join(' ');
if (message.includes('ERROR') || message.includes('Error')) {
addConsoleLog('error', message);
} else {
addConsoleLog('info', message);
}
originalLog.apply(console, args);
};
// Monitor window errors
window.addEventListener('error', (event) => {
addConsoleLog('error', `${event.message} at ${event.filename}:${event.lineno}`);
errorCount++;
});
// Monitor component rendering
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1) { // Element node
const componentType = node.getAttribute('data-component-type');
if (componentType) {
// Track component
if (!seenComponents.has(componentType)) {
seenComponents.add(componentType);
componentCount++;
document.getElementById('component-count').textContent = componentCount;
// Check off in checklist
const checklistItem = document.querySelector(`[data-component="${componentType}"]`);
if (checklistItem) {
checklistItem.classList.add('checked');
checklistItem.querySelector('.check').textContent = '✓';
}
}
updateCount++;
document.getElementById('update-count').textContent = updateCount;
}
}
});
});
});
// Start observing when vanna-chat is ready
setTimeout(() => {
const shadowRoot = vannaChat.shadowRoot;
if (shadowRoot) {
const container = shadowRoot.querySelector('.rich-components-container');
if (container) {
observer.observe(container, { childList: true, subtree: true });
addConsoleLog('info', 'Component observer started');
}
}
}, 1000);
// Event listeners
startTestBtn.addEventListener('click', async () => {
const mode = modeSelect.value;
// Update backend mode
try {
await fetch(`http://localhost:5555/health`);
addConsoleLog('info', `Starting comprehensive test in ${mode} mode...`);
// Send test message through chat
vannaChat.dispatchEvent(new CustomEvent('send-message', {
detail: { message: '/test' }
}));
// Alternative: directly trigger if API is exposed
const inputEl = vannaChat.shadowRoot?.querySelector('textarea, input');
if (inputEl) {
inputEl.value = '/test';
const form = vannaChat.shadowRoot?.querySelector('form');
if (form) {
form.dispatchEvent(new Event('submit', { bubbles: true }));
}
}
} catch (error) {
addConsoleLog('error', `Failed to start test: ${error.message}`);
}
});
clearChatBtn.addEventListener('click', () => {
// Reset state
componentCount = 0;
updateCount = 0;
errorCount = 0;
seenComponents.clear();
document.getElementById('component-count').textContent = '0';
document.getElementById('update-count').textContent = '0';
document.getElementById('error-count').textContent = '0';
// Uncheck all checklist items
document.querySelectorAll('.checklist-item').forEach(item => {
item.classList.remove('checked');
item.querySelector('.check').textContent = '';
});
// Clear console
consoleLog.innerHTML = '<div class="info">Console cleared</div>';
consoleStatus.className = 'status-dot';
consoleText.textContent = 'No errors detected';
// Reload page to truly clear (vanna-chat doesn't expose clear method)
location.reload();
});
// Initial backend check
checkBackend();
setInterval(checkBackend, 5000);
// Log startup
addConsoleLog('info', 'Test suite initialized');
addConsoleLog('info', 'Ensure backend is running: python test_backend.py');
</script>
</body>
</html>

View File

@@ -0,0 +1,874 @@
#!/usr/bin/env python3
"""
Comprehensive test backend for vanna-webcomponent validation.
This backend exercises all component types and update patterns to validate
that nothing breaks during webcomponent pruning.
Usage:
python test_backend.py --mode rapid # Fast stress test
python test_backend.py --mode realistic # Realistic conversation flow
"""
import argparse
import asyncio
import json
import sys
import time
import traceback
import uuid
from datetime import datetime
from typing import AsyncGenerator, Dict, Any, Optional
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import os
# Add vanna to path
sys.path.insert(0, "../vanna/src")
from vanna.core.rich_component import RichComponent, ComponentLifecycle
from vanna.components.rich import (
RichTextComponent,
StatusCardComponent,
ProgressDisplayComponent,
ProgressBarComponent,
NotificationComponent,
StatusIndicatorComponent,
ButtonComponent,
ButtonGroupComponent,
CardComponent,
TaskListComponent,
Task,
BadgeComponent,
IconTextComponent,
DataFrameComponent,
ChartComponent,
ArtifactComponent,
LogViewerComponent,
LogEntry,
StatusBarUpdateComponent,
TaskTrackerUpdateComponent,
ChatInputUpdateComponent,
TaskOperation,
)
from vanna.servers.base.models import ChatStreamChunk
# Request/Response models
class ChatRequest(BaseModel):
"""Chat request matching vanna API."""
message: str
conversation_id: Optional[str] = None
request_id: Optional[str] = None
request_context: Dict[str, Any] = {}
class UiComponent(BaseModel):
"""UI component wrapper."""
rich_component: RichComponent
# Test state
test_state: Dict[str, Any] = {
"mode": "realistic",
"component_ids": {}, # Track component IDs for updates
"action_count": 0,
}
async def yield_chunk(component: RichComponent, conversation_id: str, request_id: str) -> ChatStreamChunk:
"""Convert component to ChatStreamChunk."""
return ChatStreamChunk(
rich=component.serialize_for_frontend(),
simple=None,
conversation_id=conversation_id,
request_id=request_id,
timestamp=time.time(),
)
async def delay(mode: str, short: float = 0.1, long: float = 0.5):
"""Add delay based on mode."""
if mode == "realistic":
await asyncio.sleep(long)
elif mode == "rapid":
await asyncio.sleep(short)
async def test_text_component(conversation_id: str, request_id: str, mode: str) -> AsyncGenerator[ChatStreamChunk, None]:
"""Test text component with markdown."""
text_id = str(uuid.uuid4())
test_state["component_ids"]["text"] = text_id
# Create with comprehensive markdown
text = RichTextComponent(
id=text_id,
content="""# Test Text Component
This component demonstrates **markdown rendering** with various formatting:
## Formatting Examples
- **Bold text** for emphasis
- *Italic text* for style
- `inline code` for snippets
- ~~Strikethrough~~ for deletions
### Lists
1. First ordered item
2. Second ordered item
3. Third ordered item
### Code Block
```python
def hello():
return "Markdown works!"
```
> Blockquote to test quote rendering
This validates that markdown is properly parsed and displayed.""",
markdown=True,
)
yield await yield_chunk(text, conversation_id, request_id)
await delay(mode)
# Update with simpler markdown
text_updated = text.update(content="""# Updated Text Component
Text has been **successfully updated** with new markdown content!
- Update operation works ✓
- Markdown still renders ✓""")
yield await yield_chunk(text_updated, conversation_id, request_id)
await delay(mode)
async def test_status_card(conversation_id: str, request_id: str, mode: str) -> AsyncGenerator[ChatStreamChunk, None]:
"""Test status card with all states."""
card_id = str(uuid.uuid4())
test_state["component_ids"]["status_card"] = card_id
# Create - pending
status_card = StatusCardComponent(
id=card_id,
title="Status Card Test",
status="pending",
description="Testing status card component...",
icon="",
collapsible=True,
collapsed=False,
)
yield await yield_chunk(status_card, conversation_id, request_id)
await delay(mode)
# Update to running
status_card_running = status_card.set_status("running", "Processing test...")
yield await yield_chunk(status_card_running, conversation_id, request_id)
await delay(mode)
# Update to completed
status_card_done = status_card.set_status("completed", "Test completed successfully!")
status_card_done.icon = ""
yield await yield_chunk(status_card_done, conversation_id, request_id)
await delay(mode)
async def test_progress_display(conversation_id: str, request_id: str, mode: str) -> AsyncGenerator[ChatStreamChunk, None]:
"""Test progress display component."""
progress_id = str(uuid.uuid4())
test_state["component_ids"]["progress_display"] = progress_id
# Create at 0%
progress = ProgressDisplayComponent(
id=progress_id,
label="Test Progress",
value=0.0,
description="Starting test...",
status="info",
animated=True,
)
yield await yield_chunk(progress, conversation_id, request_id)
await delay(mode, 0.05, 0.3)
# Update to 50%
progress_half = progress.update_progress(0.5, "Halfway there...")
yield await yield_chunk(progress_half, conversation_id, request_id)
await delay(mode, 0.05, 0.3)
# Update to 100%
progress_done = progress.update_progress(1.0, "Complete!")
progress_done.status = "success"
yield await yield_chunk(progress_done, conversation_id, request_id)
await delay(mode)
async def test_card_component(conversation_id: str, request_id: str, mode: str) -> AsyncGenerator[ChatStreamChunk, None]:
"""Test card component with actions."""
card_id = str(uuid.uuid4())
test_state["component_ids"]["card"] = card_id
# Create card with markdown content and buttons
card = CardComponent(
id=card_id,
title="Test Card with Markdown",
content="""# Card Content
This card demonstrates **markdown rendering** within cards:
- Interactive action buttons
- Collapsible sections
- Status indicators
- `Formatted text`
Click the buttons below to test interactivity!""",
icon="🃏",
status="info",
markdown=True,
collapsible=True,
collapsed=False,
actions=[
{"label": "Test Action", "action": "/test-action", "variant": "primary"},
{"label": "Cancel", "action": "/cancel", "variant": "secondary"},
],
)
yield await yield_chunk(card, conversation_id, request_id)
await delay(mode)
# Update card status and content
card_updated = card.update(
status="success",
content="""# Card Updated Successfully!
The card content has been **updated** with:
- New status (success)
- New markdown content
- Same action buttons
✓ Update operation verified""",
markdown=True
)
yield await yield_chunk(card_updated, conversation_id, request_id)
await delay(mode)
async def test_task_list(conversation_id: str, request_id: str, mode: str) -> AsyncGenerator[ChatStreamChunk, None]:
"""Test task list component."""
task_list_id = str(uuid.uuid4())
test_state["component_ids"]["task_list"] = task_list_id
# Create task list
tasks = [
Task(title="Setup development environment", description="Install dependencies and configure tools", status="completed", progress=1.0),
Task(title="Write test suite", description="Create comprehensive component tests", status="in_progress", progress=0.7),
Task(title="Run validation", description="Validate all components render correctly", status="pending"),
Task(title="Prune webcomponent", description="Remove unused code and cruft", status="pending"),
]
task_list = TaskListComponent(
id=task_list_id,
title="Webcomponent Validation Workflow",
tasks=tasks,
show_progress=True,
show_timestamps=True,
)
yield await yield_chunk(task_list, conversation_id, request_id)
await delay(mode)
# Update task statuses
tasks[1].status = "completed"
tasks[1].progress = 1.0
tasks[2].status = "in_progress"
tasks[2].progress = 0.3
task_list_updated = TaskListComponent(
id=task_list_id,
title="Webcomponent Validation Workflow (Updated)",
tasks=tasks,
show_progress=True,
show_timestamps=True,
)
task_list_updated.lifecycle = ComponentLifecycle.UPDATE
yield await yield_chunk(task_list_updated, conversation_id, request_id)
await delay(mode)
async def test_progress_bar(conversation_id: str, request_id: str, mode: str) -> AsyncGenerator[ChatStreamChunk, None]:
"""Test progress bar component."""
bar_id = str(uuid.uuid4())
test_state["component_ids"]["progress_bar"] = bar_id
# Create
bar = ProgressBarComponent(
id=bar_id,
value=0.3,
label="Loading",
status="info",
)
yield await yield_chunk(bar, conversation_id, request_id)
await delay(mode, 0.05, 0.2)
# Update
bar_updated = bar.update(value=0.8, status="success")
yield await yield_chunk(bar_updated, conversation_id, request_id)
await delay(mode)
async def test_notification(conversation_id: str, request_id: str, mode: str) -> AsyncGenerator[ChatStreamChunk, None]:
"""Test notification component."""
for level in ["info", "success", "warning", "error"]:
notif = NotificationComponent(
id=str(uuid.uuid4()),
message=f"This is a {level} notification",
level=level,
title=f"{level.capitalize()} Test",
)
yield await yield_chunk(notif, conversation_id, request_id)
await delay(mode, 0.05, 0.2)
async def test_status_indicator(conversation_id: str, request_id: str, mode: str) -> AsyncGenerator[ChatStreamChunk, None]:
"""Test status indicator component."""
indicator_id = str(uuid.uuid4())
test_state["component_ids"]["status_indicator"] = indicator_id
# Create with pulse
indicator = StatusIndicatorComponent(
id=indicator_id,
status="running",
message="Processing...",
pulse=True,
)
yield await yield_chunk(indicator, conversation_id, request_id)
await delay(mode)
# Update to success
indicator_success = indicator.update(status="success", message="Done!", pulse=False)
yield await yield_chunk(indicator_success, conversation_id, request_id)
await delay(mode)
async def test_badge(conversation_id: str, request_id: str, mode: str) -> AsyncGenerator[ChatStreamChunk, None]:
"""Test badge component."""
badge = BadgeComponent(
id=str(uuid.uuid4()),
text="Test Badge",
variant="primary",
)
yield await yield_chunk(badge, conversation_id, request_id)
await delay(mode)
async def test_icon_text(conversation_id: str, request_id: str, mode: str) -> AsyncGenerator[ChatStreamChunk, None]:
"""Test icon_text component."""
icon_text = IconTextComponent(
id=str(uuid.uuid4()),
icon="🔧",
text="Tool Icon Test",
)
yield await yield_chunk(icon_text, conversation_id, request_id)
await delay(mode)
async def test_buttons(conversation_id: str, request_id: str, mode: str) -> AsyncGenerator[ChatStreamChunk, None]:
"""Test button and button_group components."""
# Single button
button = ButtonComponent(
label="Single Button",
action="/button-test",
variant="primary",
icon="🔘",
)
yield await yield_chunk(button, conversation_id, request_id)
await delay(mode, 0.05, 0.2)
# Button group
button_group = ButtonGroupComponent(
buttons=[
{"label": "Option 1", "action": "/option1", "variant": "primary"},
{"label": "Option 2", "action": "/option2", "variant": "secondary"},
{"label": "Option 3", "action": "/option3", "variant": "success"},
],
orientation="horizontal",
)
yield await yield_chunk(button_group, conversation_id, request_id)
await delay(mode)
async def test_dataframe(conversation_id: str, request_id: str, mode: str) -> AsyncGenerator[ChatStreamChunk, None]:
"""Test dataframe component with sample data."""
dataframe_id = str(uuid.uuid4())
test_state["component_ids"]["dataframe"] = dataframe_id
# Create sample data
sample_data = [
{"id": 1, "name": "Alice", "age": 30, "city": "New York", "salary": 75000},
{"id": 2, "name": "Bob", "age": 25, "city": "San Francisco", "salary": 85000},
{"id": 3, "name": "Charlie", "age": 35, "city": "Chicago", "salary": 70000},
{"id": 4, "name": "Diana", "age": 28, "city": "Boston", "salary": 80000},
{"id": 5, "name": "Eve", "age": 32, "city": "Seattle", "salary": 90000},
]
dataframe = DataFrameComponent.from_records(
records=sample_data,
title="📊 Employee Data",
description="""Sample employee dataset demonstrating **DataFrame** features:
- **Searchable**: Try searching for names or cities
- **Sortable**: Click column headers to sort
- **Exportable**: Export to CSV/Excel
- **Paginated**: Navigate through rows
*5 employees across different cities*""",
id=dataframe_id,
searchable=True,
sortable=True,
exportable=True,
)
yield await yield_chunk(dataframe, conversation_id, request_id)
await delay(mode)
# Update with more data
updated_data = sample_data + [
{"id": 6, "name": "Frank", "age": 29, "city": "Austin", "salary": 78000},
]
dataframe_updated = DataFrameComponent.from_records(
records=updated_data,
title="📊 Employee Data (Updated)",
description="""Dataset **updated** with new employee!
✓ Added Frank from Austin
✓ Now showing 6 employees
✓ Update operation verified""",
id=dataframe_id,
)
dataframe_updated.lifecycle = ComponentLifecycle.UPDATE
yield await yield_chunk(dataframe_updated, conversation_id, request_id)
await delay(mode)
async def test_chart(conversation_id: str, request_id: str, mode: str) -> AsyncGenerator[ChatStreamChunk, None]:
"""Test chart component with Plotly data."""
chart_id = str(uuid.uuid4())
test_state["component_ids"]["chart"] = chart_id
# Create a simple bar chart
chart_data = {
"data": [
{
"x": ["Product A", "Product B", "Product C", "Product D"],
"y": [20, 35, 30, 25],
"type": "bar",
"name": "Sales",
"marker": {"color": "#667eea"},
}
],
"layout": {
"title": "Product Sales",
"xaxis": {"title": "Products"},
"yaxis": {"title": "Sales (units)"},
},
}
chart = ChartComponent(
id=chart_id,
chart_type="bar",
data=chart_data,
title="Sales Chart",
)
yield await yield_chunk(chart, conversation_id, request_id)
await delay(mode)
# Update to line chart
line_chart_data = {
"data": [
{
"x": ["Jan", "Feb", "Mar", "Apr", "May"],
"y": [10, 15, 13, 17, 21],
"type": "scatter",
"mode": "lines+markers",
"name": "Revenue",
"line": {"color": "#10b981", "width": 3},
}
],
"layout": {
"title": "Monthly Revenue Trend",
"xaxis": {"title": "Month"},
"yaxis": {"title": "Revenue ($1000s)"},
},
}
chart_updated = ChartComponent(
id=chart_id,
chart_type="line",
data=line_chart_data,
title="Revenue Chart",
)
chart_updated.lifecycle = ComponentLifecycle.UPDATE
yield await yield_chunk(chart_updated, conversation_id, request_id)
await delay(mode)
async def test_artifact(conversation_id: str, request_id: str, mode: str) -> AsyncGenerator[ChatStreamChunk, None]:
"""Test artifact component with HTML/SVG content."""
artifact_id = str(uuid.uuid4())
test_state["component_ids"]["artifact"] = artifact_id
# Create SVG artifact
svg_content = '''<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
<circle cx="100" cy="100" r="80" fill="#667eea" opacity="0.8"/>
<circle cx="100" cy="100" r="60" fill="#764ba2" opacity="0.6"/>
<circle cx="100" cy="100" r="40" fill="#f093fb" opacity="0.4"/>
<text x="100" y="105" text-anchor="middle" fill="white" font-size="20" font-weight="bold">
Test SVG
</text>
</svg>'''
artifact = ArtifactComponent(
id=artifact_id,
content=svg_content,
artifact_type="svg",
title="SVG Circle Visualization",
description="Concentric circles demonstration",
fullscreen_capable=True,
)
yield await yield_chunk(artifact, conversation_id, request_id)
await delay(mode)
async def test_log_viewer(conversation_id: str, request_id: str, mode: str) -> AsyncGenerator[ChatStreamChunk, None]:
"""Test log viewer component."""
log_id = str(uuid.uuid4())
test_state["component_ids"]["log_viewer"] = log_id
# Create initial log viewer with entries
log_viewer = LogViewerComponent(
id=log_id,
title="System Logs",
entries=[
LogEntry(message="System started", level="info"),
LogEntry(message="Loading configuration...", level="info"),
LogEntry(message="Configuration loaded successfully", level="info"),
],
searchable=True,
auto_scroll=True,
)
yield await yield_chunk(log_viewer, conversation_id, request_id)
await delay(mode, 0.05, 0.3)
# Add warning
log_viewer = log_viewer.add_entry("Memory usage at 75%", level="warning")
yield await yield_chunk(log_viewer, conversation_id, request_id)
await delay(mode, 0.05, 0.3)
# Add error
log_viewer = log_viewer.add_entry("Connection timeout", level="error", data={"host": "api.example.com", "port": 443})
yield await yield_chunk(log_viewer, conversation_id, request_id)
await delay(mode, 0.05, 0.3)
# Add success
log_viewer = log_viewer.add_entry("Reconnected successfully", level="info")
yield await yield_chunk(log_viewer, conversation_id, request_id)
await delay(mode)
async def test_ui_state_updates(conversation_id: str, request_id: str, mode: str) -> AsyncGenerator[ChatStreamChunk, None]:
"""Test UI state update components."""
# Status bar update
status_bar = StatusBarUpdateComponent(
message="Running comprehensive component test...",
status="info",
)
yield await yield_chunk(status_bar, conversation_id, request_id)
await delay(mode, 0.1, 0.3)
# Task tracker - add tasks to sidebar
task1 = Task(
title="Validate Text Components",
description="Test text, markdown, and formatting",
status="completed",
progress=1.0,
)
task_tracker_add1 = TaskTrackerUpdateComponent.add_task(task1)
yield await yield_chunk(task_tracker_add1, conversation_id, request_id)
await delay(mode, 0.1, 0.3)
task2 = Task(
title="Validate Data Components",
description="Test DataFrame, Chart, Code blocks",
status="in_progress",
progress=0.6,
)
task_tracker_add2 = TaskTrackerUpdateComponent.add_task(task2)
yield await yield_chunk(task_tracker_add2, conversation_id, request_id)
await delay(mode, 0.1, 0.3)
task3 = Task(
title="Validate Interactive Components",
description="Test buttons, actions, and UI state",
status="pending",
)
task_tracker_add3 = TaskTrackerUpdateComponent.add_task(task3)
yield await yield_chunk(task_tracker_add3, conversation_id, request_id)
await delay(mode, 0.1, 0.3)
# Update task 2 to completed
task_tracker_update = TaskTrackerUpdateComponent(
operation=TaskOperation.UPDATE_TASK,
task_id=task2.id,
status="completed",
progress=1.0,
)
yield await yield_chunk(task_tracker_update, conversation_id, request_id)
await delay(mode, 0.1, 0.3)
# Update status bar
status_bar_complete = StatusBarUpdateComponent(
message="All components validated successfully!",
status="success",
)
yield await yield_chunk(status_bar_complete, conversation_id, request_id)
await delay(mode, 0.1, 0.3)
# Chat input update - change placeholder
chat_input = ChatInputUpdateComponent(
placeholder="Type a message to test chat input updates...",
disabled=False,
)
yield await yield_chunk(chat_input, conversation_id, request_id)
await delay(mode)
async def run_comprehensive_test(conversation_id: str, request_id: str, mode: str) -> AsyncGenerator[ChatStreamChunk, None]:
"""Run all component tests."""
# Introduction
intro = RichTextComponent(
content=f"""# 🧪 Comprehensive Component Test
**Mode**: {mode}
## Test Coverage
This test validates **16 component types** supported by the webcomponent:
- ✅ Component creation
- ✅ Incremental updates
- ✅ Markdown rendering
- ✅ Interactive actions
- ✅ Data visualization
### Component Categories
1. **Primitive**: Text, Badge, Icon Text
2. **Feedback**: Status Card, Progress, Notifications, Logs
3. **Data**: Card, Task List, DataFrame, Chart, Code
4. **Specialized**: Artifact (SVG/HTML)
5. **Interactive**: Buttons with actions
Watch the sidebar checklist as components render! ➡️""",
markdown=True,
)
yield await yield_chunk(intro, conversation_id, request_id)
await delay(mode)
# Run all tests
async for chunk in test_text_component(conversation_id, request_id, mode):
yield chunk
async for chunk in test_status_card(conversation_id, request_id, mode):
yield chunk
async for chunk in test_progress_display(conversation_id, request_id, mode):
yield chunk
async for chunk in test_card_component(conversation_id, request_id, mode):
yield chunk
async for chunk in test_task_list(conversation_id, request_id, mode):
yield chunk
async for chunk in test_progress_bar(conversation_id, request_id, mode):
yield chunk
async for chunk in test_notification(conversation_id, request_id, mode):
yield chunk
async for chunk in test_status_indicator(conversation_id, request_id, mode):
yield chunk
async for chunk in test_badge(conversation_id, request_id, mode):
yield chunk
async for chunk in test_icon_text(conversation_id, request_id, mode):
yield chunk
async for chunk in test_buttons(conversation_id, request_id, mode):
yield chunk
async for chunk in test_dataframe(conversation_id, request_id, mode):
yield chunk
async for chunk in test_chart(conversation_id, request_id, mode):
yield chunk
async for chunk in test_artifact(conversation_id, request_id, mode):
yield chunk
async for chunk in test_log_viewer(conversation_id, request_id, mode):
yield chunk
# NOTE: Table, Container, and CodeBlock components are defined in vanna Python package
# but NOT supported by the webcomponent (no renderers). Skipping these tests.
# These are candidates for removal from the vanna package.
async for chunk in test_ui_state_updates(conversation_id, request_id, mode):
yield chunk
# Completion message
done = StatusCardComponent(
title="✅ Test Suite Complete",
status="completed",
description=f"""All **16 component types** successfully rendered in **{mode}** mode!
**Validated:**
- Component creation & updates
- Markdown rendering
- Interactive buttons
- Data visualization
- UI state management
Check the sidebar for the complete checklist.""",
icon="",
)
yield await yield_chunk(done, conversation_id, request_id)
async def handle_action_message(message: str, conversation_id: str, request_id: str) -> AsyncGenerator[ChatStreamChunk, None]:
"""Handle button action messages."""
test_state["action_count"] += 1
response = NotificationComponent(
message=f"Action received: {message}",
level="success",
title=f"Action #{test_state['action_count']}",
)
yield await yield_chunk(response, conversation_id, request_id)
# Also show a card with details
card = CardComponent(
title="Action Handler Response",
content=f"Received action: `{message}`\n\nThis confirms button interactivity is working!",
icon="🎯",
status="success",
)
yield await yield_chunk(card, conversation_id, request_id)
# FastAPI app
app = FastAPI(title="Vanna Webcomponent Test Backend")
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount static files (static directory for webcomponent)
static_path = os.path.join(os.path.dirname(__file__), "static")
if os.path.exists(static_path):
app.mount("/static", StaticFiles(directory=static_path), name="static")
@app.post("/api/vanna/v2/chat_sse")
async def chat_sse(chat_request: ChatRequest) -> StreamingResponse:
"""SSE endpoint for streaming chat."""
conversation_id = chat_request.conversation_id or str(uuid.uuid4())
request_id = chat_request.request_id or str(uuid.uuid4())
message = chat_request.message.strip()
async def generate() -> AsyncGenerator[str, None]:
"""Generate SSE stream."""
try:
# Handle button actions
if message.startswith("/") and message != "/test":
async for chunk in handle_action_message(message, conversation_id, request_id):
yield f"data: {chunk.model_dump_json()}\n\n"
# Handle test command or initial message
elif message == "/test" or "test" in message.lower():
async for chunk in run_comprehensive_test(conversation_id, request_id, test_state["mode"]):
yield f"data: {chunk.model_dump_json()}\n\n"
# Default response
else:
response = RichTextComponent(
content=f"You said: {message}\n\nType `/test` to run the comprehensive component test.",
markdown=True,
)
chunk = await yield_chunk(response, conversation_id, request_id)
yield f"data: {chunk.model_dump_json()}\n\n"
yield "data: [DONE]\n\n"
except Exception as e:
error_message = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
print(f"ERROR in chat_sse: {error_message}") # Log to console
error_chunk = {
"type": "error",
"data": {"message": error_message},
"conversation_id": conversation_id,
"request_id": request_id,
}
yield f"data: {json.dumps(error_chunk)}\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@app.get("/health")
async def health():
"""Health check."""
return {"status": "ok", "mode": test_state["mode"]}
@app.get("/")
async def root():
"""Serve test HTML page."""
html_path = os.path.join(os.path.dirname(__file__), "test-comprehensive.html")
if os.path.exists(html_path):
return FileResponse(html_path)
return {
"message": "Vanna Webcomponent Test Backend",
"mode": test_state["mode"],
"endpoints": {
"chat": "POST /api/vanna/v2/chat_sse",
"health": "GET /health",
},
}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Test backend for vanna-webcomponent")
parser.add_argument(
"--mode",
choices=["rapid", "realistic"],
default="realistic",
help="Test mode: rapid (fast) or realistic (with delays)",
)
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to")
parser.add_argument("--port", type=int, default=5555, help="Port to bind to")
args = parser.parse_args()
test_state["mode"] = args.mode
print(f"Starting test backend in {args.mode} mode...")
print(f"Server running at http://{args.host}:{args.port}")
print("Send message '/test' to run comprehensive component test")
import uvicorn
uvicorn.run(app, host=args.host, port=args.port)

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"experimentalDecorators": true,
"useDefineForClassFields": false
},
"include": ["src"]
}

View File

@@ -0,0 +1,24 @@
import { defineConfig } from 'vite';
export default defineConfig({
define: {
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
__BUILD_VERSION__: JSON.stringify(process.env.npm_package_version || '1.0.0'),
},
build: {
outDir: 'dist',
lib: {
entry: 'src/index.ts',
formats: ['es'],
fileName: () => 'vanna-components.js',
},
rollupOptions: {
// Remove external to bundle lit with the components
// external: /^lit/,
},
},
preview: {
port: 9876,
strictPort: true,
},
});