2171 lines
84 KiB
Markdown
2171 lines
84 KiB
Markdown
# MCP App Designer — Phase 3: Design & Build HTML Apps
|
|
|
|
**When to use this skill:** You have a `{service}-api-analysis.md` (specifically the App Candidates section) and optionally a built MCP server, and need to create the visual HTML apps that render in LocalBosses. Each app is a single self-contained HTML file.
|
|
|
|
**What this covers:** Dark theme design specs, 9 app type patterns (including Interactive Data Grid), data visualization primitives, accessibility fundamentals, micro-interactions, bidirectional communication, the exact HTML template with data reception, responsive design, three-state rendering (loading/empty/data), and data flow architecture.
|
|
|
|
**Pipeline position:** Phase 3 of 6 → Input from `mcp-api-analyzer` (Phase 1), can run parallel with `mcp-server-builder` (Phase 2). Output feeds `mcp-localbosses-integrator` (Phase 4).
|
|
|
|
---
|
|
|
|
## 1. Inputs & Outputs
|
|
|
|
**Inputs:**
|
|
- `{service}-api-analysis.md` — App Candidates section (which apps to build, data sources)
|
|
- Tool definitions (from Phase 2 server or analysis doc) — what data shapes to expect
|
|
|
|
**Output:** HTML app files in `{service}-mcp/app-ui/`:
|
|
```
|
|
{service}-mcp/
|
|
└── app-ui/
|
|
├── dashboard.html
|
|
├── contact-grid.html
|
|
├── contact-card.html
|
|
├── contact-creator.html
|
|
├── calendar-view.html
|
|
├── pipeline-kanban.html
|
|
├── activity-timeline.html
|
|
├── data-explorer.html ← Interactive Data Grid (new)
|
|
└── ...
|
|
```
|
|
|
|
Each file is a **single, self-contained HTML file** with all CSS and JS inline. Zero external dependencies.
|
|
|
|
---
|
|
|
|
## 2. Design System — LocalBosses Dark Theme
|
|
|
|
### Color Palette
|
|
|
|
> **WCAG AA Compliance Note:** All text colors must maintain a minimum contrast ratio of **4.5:1** against their background for normal text (under 18px/14px bold), and **3:1** for large text. The secondary text color `#b0b2b8` achieves **5.0:1** on `#1a1d23` and **4.3:1** on `#2b2d31`, meeting AA for normal text. The previous value `#96989d` (3.7:1) failed this requirement and must not be used.
|
|
|
|
| Token | Hex | Usage |
|
|
|-------|-----|-------|
|
|
| `--bg-primary` | `#1a1d23` | Page/body background |
|
|
| `--bg-secondary` | `#2b2d31` | Cards, panels, containers |
|
|
| `--bg-tertiary` | `#232529` | Nested elements, table rows alt |
|
|
| `--bg-hover` | `#35373c` | Hover states on interactive elements |
|
|
| `--bg-input` | `#1e2024` | Form inputs, text areas |
|
|
| `--accent` | `#ff6d5a` | Primary accent, buttons, active states |
|
|
| `--accent-hover` | `#ff8574` | Accent hover state |
|
|
| `--accent-subtle` | `rgba(255, 109, 90, 0.15)` | Accent backgrounds, badges |
|
|
| `--text-primary` | `#dcddde` | Primary text |
|
|
| `--text-secondary` | `#b0b2b8` | Muted/secondary text, labels (WCAG AA 5.0:1 on #1a1d23) |
|
|
| `--text-heading` | `#ffffff` | Headings, emphasis |
|
|
| `--border` | `#3a3c41` | Borders, dividers |
|
|
| `--success` | `#43b581` | Success states, positive metrics |
|
|
| `--warning` | `#faa61a` | Warning states, caution |
|
|
| `--danger` | `#f04747` | Error states, destructive actions |
|
|
| `--info` | `#5865f2` | Info states, links |
|
|
|
|
### Typography
|
|
|
|
```css
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
```
|
|
|
|
| Element | Size | Weight | Color |
|
|
|---------|------|--------|-------|
|
|
| Page title | 18px | 700 | #ffffff |
|
|
| Section heading | 14px | 600 | #ffffff |
|
|
| Body text | 13px | 400 | #dcddde |
|
|
| Small/muted | 12px | 400 | #b0b2b8 |
|
|
| Metric value | 24px | 700 | #ff6d5a |
|
|
| Table header | 11px | 600 | #b0b2b8 (uppercase, letter-spacing: 0.5px) |
|
|
|
|
### Spacing & Layout
|
|
|
|
| Token | Value | Usage |
|
|
|-------|-------|-------|
|
|
| `--gap-xs` | 4px | Tight spacing (icon + label) |
|
|
| `--gap-sm` | 8px | Compact spacing |
|
|
| `--gap-md` | 12px | Standard spacing |
|
|
| `--gap-lg` | 16px | Section spacing |
|
|
| `--gap-xl` | 24px | Major section breaks |
|
|
| `--radius-sm` | 4px | Small elements (badges, chips) |
|
|
| `--radius-md` | 8px | Cards, panels |
|
|
| `--radius-lg` | 12px | Large containers, modals |
|
|
|
|
### Components
|
|
|
|
#### Cards
|
|
```css
|
|
.card {
|
|
background: #2b2d31;
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
border: 1px solid #3a3c41;
|
|
}
|
|
```
|
|
|
|
#### Buttons
|
|
```css
|
|
.btn-primary {
|
|
background: #ff6d5a;
|
|
color: #ffffff;
|
|
border: none;
|
|
padding: 8px 16px;
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
}
|
|
.btn-primary:hover { background: #ff8574; }
|
|
.btn-primary:focus-visible { outline: 2px solid #ff6d5a; outline-offset: 2px; }
|
|
|
|
.btn-secondary {
|
|
background: transparent;
|
|
color: #dcddde;
|
|
border: 1px solid #3a3c41;
|
|
padding: 8px 16px;
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
.btn-secondary:hover { background: #35373c; border-color: #4a4c51; }
|
|
.btn-secondary:focus-visible { outline: 2px solid #ff6d5a; outline-offset: 2px; }
|
|
```
|
|
|
|
#### Status badges
|
|
```css
|
|
.badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
|
.badge-success { background: rgba(67, 181, 129, 0.15); color: #43b581; }
|
|
.badge-warning { background: rgba(250, 166, 26, 0.15); color: #faa61a; }
|
|
.badge-danger { background: rgba(240, 71, 71, 0.15); color: #f04747; }
|
|
.badge-info { background: rgba(88, 101, 242, 0.15); color: #5865f2; }
|
|
.badge-accent { background: rgba(255, 109, 90, 0.15); color: #ff6d5a; }
|
|
.badge-neutral { background: rgba(176, 178, 184, 0.15); color: #b0b2b8; }
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Data Visualization Primitives
|
|
|
|
All visualizations use pure CSS/SVG — zero external dependencies. Copy these snippets into any app template.
|
|
|
|
### 3.1 Line / Area Chart (SVG Polyline)
|
|
|
|
```html
|
|
<!-- Line Chart: pass an array of {x, y} normalized to viewBox -->
|
|
<svg viewBox="0 0 300 100" style="width:100%;height:160px" role="img" aria-label="Line chart showing trend data">
|
|
<!-- Grid lines -->
|
|
<line x1="0" y1="25" x2="300" y2="25" stroke="#3a3c41" stroke-width="0.5" stroke-dasharray="4"/>
|
|
<line x1="0" y1="50" x2="300" y2="50" stroke="#3a3c41" stroke-width="0.5" stroke-dasharray="4"/>
|
|
<line x1="0" y1="75" x2="300" y2="75" stroke="#3a3c41" stroke-width="0.5" stroke-dasharray="4"/>
|
|
<!-- Area fill -->
|
|
<polygon fill="rgba(255,109,90,0.1)" points="0,100 0,70 50,55 100,60 150,30 200,40 250,20 300,15 300,100"/>
|
|
<!-- Line -->
|
|
<polyline fill="none" stroke="#ff6d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
points="0,70 50,55 100,60 150,30 200,40 250,20 300,15"/>
|
|
<!-- Data points -->
|
|
<circle cx="0" cy="70" r="3" fill="#ff6d5a"/>
|
|
<circle cx="150" cy="30" r="3" fill="#ff6d5a"/>
|
|
<circle cx="300" cy="15" r="3" fill="#ff6d5a"/>
|
|
</svg>
|
|
```
|
|
|
|
**JS helper to generate points from data:**
|
|
```javascript
|
|
function makeLinePoints(data, width, height) {
|
|
const max = Math.max(...data.map(d => d.value), 1);
|
|
const step = width / Math.max(data.length - 1, 1);
|
|
return data.map((d, i) => `${i * step},${height - (d.value / max) * (height - 10)}`).join(' ');
|
|
}
|
|
// Usage: <polyline points="${makeLinePoints(data, 300, 100)}"/>
|
|
```
|
|
|
|
### 3.2 Donut / Pie Chart (SVG Circle)
|
|
|
|
```html
|
|
<!-- Donut chart using stroke-dasharray trick -->
|
|
<svg viewBox="0 0 36 36" style="width:120px;height:120px" role="img" aria-label="Donut chart: 72% complete">
|
|
<!-- Background ring -->
|
|
<circle cx="18" cy="18" r="15.9" fill="none" stroke="#2b2d31" stroke-width="3"/>
|
|
<!-- Segment 1: 72% (accent) -->
|
|
<circle cx="18" cy="18" r="15.9" fill="none" stroke="#ff6d5a" stroke-width="3"
|
|
stroke-dasharray="72 28" stroke-dashoffset="25" stroke-linecap="round"/>
|
|
<!-- Segment 2: 28% (muted) -->
|
|
<circle cx="18" cy="18" r="15.9" fill="none" stroke="#3a3c41" stroke-width="3"
|
|
stroke-dasharray="28 72" stroke-dashoffset="53"/>
|
|
<!-- Center label -->
|
|
<text x="18" y="18" text-anchor="middle" dy="0.35em" fill="#ffffff" font-size="8" font-weight="700">72%</text>
|
|
</svg>
|
|
```
|
|
|
|
**JS helper for multi-segment donut:**
|
|
```javascript
|
|
function makeDonutSegments(segments, radius) {
|
|
const circumference = 2 * Math.PI * radius;
|
|
let offset = 25; // Start from top (25% offset = 12 o'clock)
|
|
return segments.map(seg => {
|
|
const dashArray = `${seg.percent} ${100 - seg.percent}`;
|
|
const html = `<circle cx="18" cy="18" r="${radius}" fill="none" stroke="${seg.color}" stroke-width="3" stroke-dasharray="${dashArray}" stroke-dashoffset="${offset}"/>`;
|
|
offset -= seg.percent;
|
|
return html;
|
|
}).join('');
|
|
}
|
|
```
|
|
|
|
### 3.3 Sparklines (Inline SVG)
|
|
|
|
```html
|
|
<!-- Tiny inline sparkline — 80x24px, no axes -->
|
|
<svg viewBox="0 0 100 30" style="width:80px;height:24px;vertical-align:middle" role="img" aria-label="Trend: increasing">
|
|
<polyline fill="none" stroke="#ff6d5a" stroke-width="2" stroke-linecap="round"
|
|
points="0,25 15,20 30,22 45,10 60,15 75,8 90,12 100,5"/>
|
|
</svg>
|
|
|
|
<!-- Green sparkline for positive trends -->
|
|
<svg viewBox="0 0 100 30" style="width:80px;height:24px;vertical-align:middle" role="img" aria-label="Trend: stable">
|
|
<polyline fill="none" stroke="#43b581" stroke-width="2" stroke-linecap="round"
|
|
points="0,20 15,18 30,22 45,16 60,18 75,14 90,16 100,12"/>
|
|
</svg>
|
|
```
|
|
|
|
### 3.4 Progress Bars (CSS-Only)
|
|
|
|
```html
|
|
<!-- Basic progress bar -->
|
|
<div style="background:#232529;border-radius:4px;height:8px;overflow:hidden" role="progressbar" aria-valuenow="72" aria-valuemin="0" aria-valuemax="100" aria-label="Progress: 72%">
|
|
<div style="background:#ff6d5a;height:100%;width:72%;border-radius:4px;transition:width 0.6s ease"></div>
|
|
</div>
|
|
|
|
<!-- Labeled progress bar -->
|
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:8px">
|
|
<span style="font-size:12px;color:#b0b2b8;min-width:80px">Conversion</span>
|
|
<div style="flex:1;background:#232529;border-radius:4px;height:8px;overflow:hidden" role="progressbar" aria-valuenow="45" aria-valuemin="0" aria-valuemax="100">
|
|
<div style="background:#43b581;height:100%;width:45%;border-radius:4px;transition:width 0.6s ease"></div>
|
|
</div>
|
|
<span style="font-size:12px;color:#b0b2b8;min-width:35px;text-align:right">45%</span>
|
|
</div>
|
|
```
|
|
|
|
### 3.5 Horizontal Bar Charts (CSS Flexbox)
|
|
|
|
```html
|
|
<!-- Horizontal bar chart — great for rankings/comparisons -->
|
|
<div style="display:flex;flex-direction:column;gap:8px">
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<span style="font-size:12px;color:#b0b2b8;min-width:80px;text-align:right">Email</span>
|
|
<div style="flex:1;background:#232529;border-radius:4px;height:20px;overflow:hidden">
|
|
<div style="background:#ff6d5a;height:100%;width:82%;border-radius:4px;display:flex;align-items:center;padding-left:8px">
|
|
<span style="font-size:11px;color:#fff;font-weight:600">82%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<span style="font-size:12px;color:#b0b2b8;min-width:80px;text-align:right">Social</span>
|
|
<div style="flex:1;background:#232529;border-radius:4px;height:20px;overflow:hidden">
|
|
<div style="background:#5865f2;height:100%;width:54%;border-radius:4px;display:flex;align-items:center;padding-left:8px">
|
|
<span style="font-size:11px;color:#fff;font-weight:600">54%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<span style="font-size:12px;color:#b0b2b8;min-width:80px;text-align:right">Direct</span>
|
|
<div style="flex:1;background:#232529;border-radius:4px;height:20px;overflow:hidden">
|
|
<div style="background:#43b581;height:100%;width:31%;border-radius:4px;display:flex;align-items:center;padding-left:8px">
|
|
<span style="font-size:11px;color:#fff;font-weight:600">31%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
**JS helper for horizontal bars from data:**
|
|
```javascript
|
|
function renderHorizontalBars(items, colorFn) {
|
|
const max = Math.max(...items.map(d => d.value), 1);
|
|
return items.map(d => {
|
|
const pct = Math.round((d.value / max) * 100);
|
|
const color = colorFn ? colorFn(d) : '#ff6d5a';
|
|
return `
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<span style="font-size:12px;color:#b0b2b8;min-width:80px;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(d.label)}</span>
|
|
<div style="flex:1;background:#232529;border-radius:4px;height:20px;overflow:hidden">
|
|
<div style="background:${color};height:100%;width:${pct}%;border-radius:4px;display:flex;align-items:center;padding-left:8px;min-width:30px">
|
|
<span style="font-size:11px;color:#fff;font-weight:600">${formatNumber(d.value)}</span>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Data Flow: How Data Gets to the App
|
|
|
|
### Architecture
|
|
|
|
```
|
|
User sends message in thread
|
|
│
|
|
▼
|
|
AI calls MCP tool → tool returns result
|
|
│
|
|
├─── structuredContent (MCP protocol) ← typed JSON data from tool
|
|
└─── content (text fallback) ← human-readable text
|
|
│
|
|
▼
|
|
AI generates response + APP_DATA block
|
|
│
|
|
▼
|
|
<!--APP_DATA:{"contacts":[...]}:END_APP_DATA-->
|
|
│
|
|
▼
|
|
LocalBosses chat/route.ts parses APP_DATA
|
|
│
|
|
▼
|
|
Stores in app-data endpoint & sends via postMessage
|
|
│
|
|
▼
|
|
iframe receives data → app renders
|
|
```
|
|
|
|
### MCP `structuredContent` Context
|
|
|
|
> **Important distinction:** The `APP_DATA` block format (`<!--APP_DATA:{...}:END_APP_DATA-->`) is a **LocalBosses-specific** pattern for passing structured data from the AI's text response to the app iframe. It is NOT part of the MCP protocol.
|
|
>
|
|
> In the MCP protocol (spec 2025-06-18+), tools return typed data via `structuredContent` alongside a text fallback in `content`. The flow is:
|
|
>
|
|
> 1. **MCP tool** returns `{ content: [...], structuredContent: { data: [...], meta: {...} } }`
|
|
> 2. **LocalBosses** receives the tool result — the `structuredContent` is the typed data
|
|
> 3. **AI** uses `structuredContent` to generate the `APP_DATA` block in its response text
|
|
> 4. **LocalBosses route.ts** parses `APP_DATA` from the AI's response and sends it to the iframe
|
|
>
|
|
> The app itself doesn't interact with MCP directly — it receives data via `postMessage` or polling, regardless of whether the data originally came from `structuredContent` or was generated by the AI. The apps are a pure rendering layer.
|
|
|
|
### Two data reception methods (apps MUST support both):
|
|
|
|
1. **postMessage** — Primary. Host sends data to iframe.
|
|
2. **Polling** — Fallback. App fetches from `/api/app-data` with exponential backoff.
|
|
|
|
---
|
|
|
|
## 5. The HTML App Template
|
|
|
|
This is the EXACT base template for every app. Copy and customize.
|
|
|
|
```html
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; connect-src 'self'; frame-ancestors 'self';">
|
|
<title>{App Name}</title>
|
|
<style>
|
|
/* ═══ RESET ═══ */
|
|
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
/* ═══ BASE ═══ */
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
background: #1a1d23;
|
|
color: #dcddde;
|
|
padding: 16px;
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
/* ═══ ACCESSIBILITY ═══ */
|
|
/* Screen reader only — visually hidden but available to assistive technology */
|
|
.sr-only {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
padding: 0;
|
|
margin: -1px;
|
|
overflow: hidden;
|
|
clip: rect(0, 0, 0, 0);
|
|
white-space: nowrap;
|
|
border: 0;
|
|
}
|
|
/* Focus visible for keyboard users */
|
|
:focus-visible {
|
|
outline: 2px solid #ff6d5a;
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
/* ═══ LOADING SKELETON ═══ */
|
|
.skeleton {
|
|
background: linear-gradient(90deg, #2b2d31 25%, #35373c 50%, #2b2d31 75%);
|
|
background-size: 200% 100%;
|
|
animation: shimmer 1.5s infinite;
|
|
border-radius: 4px;
|
|
}
|
|
@keyframes shimmer {
|
|
0% { background-position: 200% 0; }
|
|
100% { background-position: -200% 0; }
|
|
}
|
|
.skeleton-line { height: 14px; margin-bottom: 8px; }
|
|
.skeleton-line:last-child { width: 60%; }
|
|
.skeleton-card { height: 80px; margin-bottom: 12px; border-radius: 8px; }
|
|
|
|
/* Respect reduced motion preference */
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.skeleton { animation: none; background: #2b2d31; }
|
|
.row-enter { animation: none !important; opacity: 1 !important; }
|
|
.metric-count { transition: none !important; }
|
|
.cross-fade { transition: none !important; }
|
|
}
|
|
|
|
/* ═══ EMPTY STATE ═══ */
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 48px 24px;
|
|
color: #b0b2b8;
|
|
}
|
|
.empty-state-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; }
|
|
.empty-state-title { font-size: 16px; font-weight: 600; color: #dcddde; margin-bottom: 8px; }
|
|
.empty-state-text { font-size: 13px; max-width: 300px; margin: 0 auto; }
|
|
|
|
/* ═══ HEADER ═══ */
|
|
.app-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 16px;
|
|
padding-bottom: 12px;
|
|
border-bottom: 1px solid #3a3c41;
|
|
}
|
|
.app-title { font-size: 18px; font-weight: 700; color: #ffffff; }
|
|
.app-subtitle { font-size: 12px; color: #b0b2b8; margin-top: 2px; }
|
|
|
|
/* ═══ CARDS ═══ */
|
|
.card {
|
|
background: #2b2d31;
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
border: 1px solid #3a3c41;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.card:hover { border-color: #4a4c51; }
|
|
|
|
/* ═══ METRICS ROW ═══ */
|
|
.metrics-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
|
gap: 12px;
|
|
margin-bottom: 16px;
|
|
}
|
|
.metric-card {
|
|
background: #2b2d31;
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
border: 1px solid #3a3c41;
|
|
}
|
|
.metric-label { font-size: 11px; color: #b0b2b8; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
.metric-value { font-size: 24px; font-weight: 700; color: #ff6d5a; margin-top: 4px; }
|
|
.metric-change { font-size: 11px; margin-top: 2px; }
|
|
.metric-change.up { color: #43b581; }
|
|
.metric-change.down { color: #f04747; }
|
|
|
|
/* ═══ TABLE ═══ */
|
|
.data-table { width: 100%; border-collapse: collapse; }
|
|
.data-table th {
|
|
text-align: left;
|
|
padding: 8px 12px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: #b0b2b8;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
border-bottom: 1px solid #3a3c41;
|
|
}
|
|
.data-table td {
|
|
padding: 10px 12px;
|
|
border-bottom: 1px solid rgba(58, 60, 65, 0.5);
|
|
font-size: 13px;
|
|
}
|
|
.data-table tr:hover td { background: #35373c; }
|
|
|
|
/* ═══ BADGES ═══ */
|
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
|
.badge-success { background: rgba(67, 181, 129, 0.15); color: #43b581; }
|
|
.badge-warning { background: rgba(250, 166, 26, 0.15); color: #faa61a; }
|
|
.badge-danger { background: rgba(240, 71, 71, 0.15); color: #f04747; }
|
|
.badge-info { background: rgba(88, 101, 242, 0.15); color: #5865f2; }
|
|
.badge-accent { background: rgba(255, 109, 90, 0.15); color: #ff6d5a; }
|
|
.badge-neutral { background: rgba(176, 178, 184, 0.15); color: #b0b2b8; }
|
|
|
|
/* ═══ MICRO-INTERACTIONS ═══ */
|
|
/* Staggered row entrance — apply via JS: el.style.animationDelay = `${i * 50}ms` */
|
|
.row-enter {
|
|
animation: fadeSlideIn 0.25s ease-out forwards;
|
|
opacity: 0;
|
|
}
|
|
@keyframes fadeSlideIn {
|
|
from { opacity: 0; transform: translateY(4px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
/* Cross-fade for data updates */
|
|
.cross-fade {
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
/* ═══ UPDATING OVERLAY ═══ */
|
|
/* 4th state: shown over existing data while new data loads */
|
|
.updating-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: rgba(26, 29, 35, 0.6);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 8px;
|
|
z-index: 10;
|
|
}
|
|
.updating-overlay .updating-text {
|
|
font-size: 13px;
|
|
color: #b0b2b8;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.updating-spinner {
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 2px solid #3a3c41;
|
|
border-top-color: #ff6d5a;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
/* ═══ RESPONSIVE ═══ */
|
|
@media (max-width: 400px) {
|
|
body { padding: 12px; }
|
|
.metrics-row { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
|
.app-title { font-size: 16px; }
|
|
.data-table { font-size: 12px; }
|
|
.data-table th, .data-table td { padding: 8px 8px; }
|
|
}
|
|
@media (max-width: 300px) {
|
|
.metrics-row { grid-template-columns: 1fr; }
|
|
body { padding: 8px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app">
|
|
<!-- LOADING STATE (shown by default) -->
|
|
<div id="loading" role="status" aria-label="Loading content">
|
|
<span class="sr-only">Loading content, please wait…</span>
|
|
<div class="app-header">
|
|
<div>
|
|
<div class="skeleton skeleton-line" style="width:140px;height:20px"></div>
|
|
<div class="skeleton skeleton-line" style="width:200px;height:12px;margin-top:6px"></div>
|
|
</div>
|
|
</div>
|
|
<div class="metrics-row">
|
|
<div class="skeleton skeleton-card" style="height:70px"></div>
|
|
<div class="skeleton skeleton-card" style="height:70px"></div>
|
|
<div class="skeleton skeleton-card" style="height:70px"></div>
|
|
</div>
|
|
<div class="skeleton skeleton-card"></div>
|
|
<div class="skeleton skeleton-card"></div>
|
|
<div class="skeleton skeleton-card"></div>
|
|
</div>
|
|
|
|
<!-- EMPTY STATE (hidden by default) — customize per app type -->
|
|
<div id="empty" style="display:none">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">📋</div>
|
|
<div class="empty-state-title">No data yet</div>
|
|
<div class="empty-state-text">Ask me a question in the chat to populate this view with data.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- DATA STATE (hidden by default) -->
|
|
<div id="content" style="display:none;position:relative" aria-live="polite">
|
|
<!-- Populated by render() -->
|
|
<!-- UPDATING OVERLAY — subtle indicator on existing data while new data loads -->
|
|
<div id="updating-overlay" class="updating-overlay" style="display:none" role="status">
|
|
<div class="updating-text">
|
|
<div class="updating-spinner"></div>
|
|
<span>Updating…</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ═══════════════════════════════════════
|
|
// ERROR BOUNDARY — catch render failures
|
|
// ═══════════════════════════════════════
|
|
|
|
window.onerror = function(msg, url, line, col, error) {
|
|
console.error('App error:', msg, 'at line', line);
|
|
try {
|
|
document.getElementById('content').innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">⚠️</div>
|
|
<div class="empty-state-title">Display Error</div>
|
|
<div class="empty-state-text">The app encountered an issue rendering the data. Try sending a new message.</div>
|
|
</div>`;
|
|
showState('data');
|
|
} catch (e) {
|
|
// Last resort — at least show something
|
|
document.body.innerHTML = '<div style="text-align:center;padding:48px;color:#b0b2b8">⚠️ Display error. Try sending a new message.</div>';
|
|
}
|
|
return true; // Prevent default error handling
|
|
};
|
|
|
|
window.addEventListener('unhandledrejection', function(event) {
|
|
console.error('Unhandled promise rejection:', event.reason);
|
|
});
|
|
|
|
// ═══════════════════════════════════════
|
|
// DATA RECEPTION — postMessage + polling
|
|
// ═══════════════════════════════════════
|
|
|
|
let currentData = null;
|
|
|
|
// Trusted origins for postMessage validation
|
|
// Configure for your environment: same-origin + localhost + any custom trusted origins
|
|
const TRUSTED_ORIGINS = [window.location.origin, 'http://localhost:3000', 'http://localhost:3001'];
|
|
|
|
// Method 1: postMessage from host
|
|
window.addEventListener('message', (event) => {
|
|
// Validate origin — allow same-origin, localhost, and configured trusted origins
|
|
if (event.origin && event.origin !== window.location.origin && !TRUSTED_ORIGINS.includes(event.origin)) {
|
|
console.warn('[App] Rejected postMessage from untrusted origin:', event.origin);
|
|
return;
|
|
}
|
|
try {
|
|
const msg = event.data;
|
|
// Handle "updating" state — triggered when user sends a new message
|
|
if (msg.type === 'user_message_sent') {
|
|
if (currentData) showState('updating'); // Show overlay on existing data
|
|
return;
|
|
}
|
|
// Handle multiple message formats
|
|
if (msg.type === 'mcp_app_data' && msg.data) {
|
|
handleData(msg.data);
|
|
} else if (msg.type === 'app_data' && msg.data) {
|
|
handleData(msg.data);
|
|
} else if (msg.type === 'mcp-app-init' && msg.data) {
|
|
handleData(msg.data);
|
|
} else if (typeof msg === 'object' && !msg.type) {
|
|
// Raw data object
|
|
handleData(msg);
|
|
}
|
|
} catch (e) {
|
|
console.error('postMessage handler error:', e);
|
|
}
|
|
});
|
|
|
|
// Method 2: Polling fallback with exponential backoff
|
|
const APP_ID = '{app-id}'; // Replace with actual app ID
|
|
let pollTimer = null;
|
|
let pollCount = 0;
|
|
const POLL_INTERVALS = [3000, 5000, 10000, 30000]; // Exponential backoff
|
|
const MAX_POLLS = 20;
|
|
|
|
async function pollForData() {
|
|
// Don't poll if tab is hidden or max attempts reached
|
|
if (document.hidden) return schedulePoll();
|
|
if (pollCount >= MAX_POLLS) {
|
|
showState('empty');
|
|
document.querySelector('#empty .empty-state-title').textContent = 'Timed Out';
|
|
document.querySelector('#empty .empty-state-text').textContent = 'Data took too long to load. Try sending a new message.';
|
|
return;
|
|
}
|
|
|
|
pollCount++;
|
|
try {
|
|
const res = await fetch(`/api/app-data?app=${APP_ID}&t=${Date.now()}`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data && Object.keys(data).length > 0) {
|
|
handleData(data);
|
|
return; // Stop polling — data received
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Silently fail — polling is a fallback
|
|
}
|
|
schedulePoll();
|
|
}
|
|
|
|
function schedulePoll() {
|
|
if (currentData) return; // Already have data, stop
|
|
const intervalIndex = Math.min(pollCount, POLL_INTERVALS.length - 1);
|
|
pollTimer = setTimeout(pollForData, POLL_INTERVALS[intervalIndex]);
|
|
}
|
|
|
|
// Pause/resume polling on visibility change
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden && !currentData && pollCount < MAX_POLLS) {
|
|
pollForData();
|
|
}
|
|
});
|
|
|
|
// Start polling after short delay (give postMessage a chance first)
|
|
setTimeout(pollForData, 500);
|
|
|
|
// ═══════════════════════════════════════
|
|
// DATA HANDLING
|
|
// ═══════════════════════════════════════
|
|
|
|
function handleData(data) {
|
|
// Deduplicate — don't re-render identical data
|
|
const dataStr = JSON.stringify(data);
|
|
if (dataStr === JSON.stringify(currentData)) return;
|
|
currentData = data;
|
|
|
|
// Stop polling once we have data
|
|
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
|
|
|
// Route to render
|
|
if (!data || (typeof data === 'object' && Object.keys(data).length === 0)) {
|
|
showState('empty');
|
|
} else {
|
|
try {
|
|
render(data);
|
|
} catch (e) {
|
|
console.error('Render error:', e);
|
|
document.getElementById('content').innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">⚠️</div>
|
|
<div class="empty-state-title">Display Error</div>
|
|
<div class="empty-state-text">Could not render the data. Try a different query.</div>
|
|
</div>`;
|
|
showState('data');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════
|
|
// STATE MANAGEMENT
|
|
// ═══════════════════════════════════════
|
|
|
|
function showState(state) {
|
|
document.getElementById('loading').style.display = state === 'loading' ? 'block' : 'none';
|
|
document.getElementById('empty').style.display = state === 'empty' ? 'block' : 'none';
|
|
const content = document.getElementById('content');
|
|
content.style.display = (state === 'data' || state === 'updating') ? 'block' : 'none';
|
|
|
|
// Updating overlay — subtle indicator on existing data while new data loads
|
|
const overlay = document.getElementById('updating-overlay');
|
|
if (overlay) overlay.style.display = state === 'updating' ? 'flex' : 'none';
|
|
|
|
// Focus management: move focus to content when data loads
|
|
if (state === 'data') {
|
|
content.setAttribute('tabindex', '-1');
|
|
content.focus({ preventScroll: true });
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════
|
|
// DATA VALIDATION
|
|
// ═══════════════════════════════════════
|
|
|
|
/**
|
|
* Validate that data contains expected fields.
|
|
* Logs warnings for missing fields instead of crashing.
|
|
* @param {object} data - The data object to validate
|
|
* @param {string[]} requiredFields - Array of field names/paths expected
|
|
* @returns {boolean} - true if all fields present, false if any missing
|
|
*/
|
|
function validateData(data, requiredFields) {
|
|
if (!data || typeof data !== 'object') {
|
|
console.warn('[App] validateData: data is not an object', data);
|
|
return false;
|
|
}
|
|
let valid = true;
|
|
requiredFields.forEach(field => {
|
|
const parts = field.split('.');
|
|
let val = data;
|
|
for (const part of parts) {
|
|
val = val?.[part];
|
|
}
|
|
if (val === undefined || val === null) {
|
|
console.warn(`[App] Missing expected field: "${field}"`, data);
|
|
valid = false;
|
|
}
|
|
});
|
|
return valid;
|
|
}
|
|
|
|
// ═══════════════════════════════════════
|
|
// BIDIRECTIONAL COMMUNICATION
|
|
// ═══════════════════════════════════════
|
|
|
|
/**
|
|
* Send an action from the app back to the host.
|
|
* @param {'refresh'|'navigate'|'tool_call'} action - The action type
|
|
* @param {object} payload - Action-specific data
|
|
*
|
|
* Usage examples:
|
|
* sendToHost('refresh', {});
|
|
* sendToHost('navigate', { app: 'contact-card', params: { id: '123' } });
|
|
* sendToHost('tool_call', { tool: 'delete_contact', args: { id: '123' } });
|
|
*/
|
|
function sendToHost(action, payload) {
|
|
window.parent.postMessage({
|
|
type: 'mcp_app_action',
|
|
action: action,
|
|
payload: payload,
|
|
appId: APP_ID
|
|
}, '*');
|
|
}
|
|
|
|
// ═══════════════════════════════════════
|
|
// RENDER — Customize per app type
|
|
// ═══════════════════════════════════════
|
|
|
|
function render(data) {
|
|
showState('data');
|
|
const el = document.getElementById('content');
|
|
|
|
// === YOUR APP-SPECIFIC RENDERING HERE ===
|
|
el.innerHTML = `
|
|
<div class="app-header">
|
|
<div>
|
|
<div class="app-title">{App Title}</div>
|
|
<div class="app-subtitle">${escapeHtml(data.subtitle || '')}</div>
|
|
</div>
|
|
</div>
|
|
<!-- Render your data here -->
|
|
`;
|
|
}
|
|
|
|
// ═══════════════════════════════════════
|
|
// MICRO-INTERACTIONS
|
|
// ═══════════════════════════════════════
|
|
|
|
/**
|
|
* Apply staggered entrance animation to rows.
|
|
* Call after inserting rows into the DOM.
|
|
* @param {string} selector - CSS selector for the rows
|
|
* @param {number} delayMs - Delay between each row (default 50ms)
|
|
*/
|
|
function staggerRows(selector, delayMs = 50) {
|
|
document.querySelectorAll(selector).forEach((row, i) => {
|
|
row.classList.add('row-enter');
|
|
row.style.animationDelay = `${i * delayMs}ms`;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Animate a number counting up from 0 to its target value.
|
|
* @param {HTMLElement} el - The element containing the number
|
|
* @param {number} target - The target number
|
|
* @param {number} duration - Animation duration in ms (default 600)
|
|
* @param {function} formatter - Formatting function (default formatNumber)
|
|
*/
|
|
function animateCount(el, target, duration = 600, formatter = formatNumber) {
|
|
// Respect reduced motion
|
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
el.textContent = formatter(target);
|
|
return;
|
|
}
|
|
const start = performance.now();
|
|
function step(now) {
|
|
const elapsed = now - start;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
// Ease-out cubic
|
|
const eased = 1 - Math.pow(1 - progress, 3);
|
|
el.textContent = formatter(Math.round(target * eased));
|
|
if (progress < 1) requestAnimationFrame(step);
|
|
}
|
|
requestAnimationFrame(step);
|
|
}
|
|
|
|
/**
|
|
* Smooth cross-fade when updating content.
|
|
* @param {HTMLElement} container - The container to update
|
|
* @param {string} newHtml - The new HTML content
|
|
*/
|
|
function crossFadeUpdate(container, newHtml) {
|
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
container.innerHTML = newHtml;
|
|
return;
|
|
}
|
|
container.style.opacity = '0';
|
|
setTimeout(() => {
|
|
container.innerHTML = newHtml;
|
|
container.style.opacity = '1';
|
|
}, 200);
|
|
}
|
|
|
|
// ═══════════════════════════════════════
|
|
// UTILITIES
|
|
// ═══════════════════════════════════════
|
|
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
return String(text)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function formatNumber(num) {
|
|
if (num == null) return '—';
|
|
if (typeof num !== 'number') num = parseFloat(num);
|
|
if (isNaN(num)) return '—';
|
|
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
|
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
|
return num.toLocaleString();
|
|
}
|
|
|
|
function formatCurrency(num) {
|
|
if (num == null) return '—';
|
|
return '$' + Number(num).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '—';
|
|
try {
|
|
const d = new Date(dateStr);
|
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
} catch { return dateStr; }
|
|
}
|
|
|
|
function formatDateTime(dateStr) {
|
|
if (!dateStr) return '—';
|
|
try {
|
|
const d = new Date(dateStr);
|
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' +
|
|
d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
|
} catch { return dateStr; }
|
|
}
|
|
|
|
function getBadgeClass(status) {
|
|
const s = String(status).toLowerCase();
|
|
if (['active', 'open', 'won', 'completed', 'paid', 'success', 'live'].includes(s)) return 'badge-success';
|
|
if (['pending', 'in progress', 'processing', 'draft'].includes(s)) return 'badge-warning';
|
|
if (['closed', 'lost', 'failed', 'overdue', 'cancelled', 'error'].includes(s)) return 'badge-danger';
|
|
if (['new', 'scheduled', 'upcoming'].includes(s)) return 'badge-info';
|
|
return 'badge-neutral';
|
|
}
|
|
|
|
/**
|
|
* Copy text to clipboard and show brief visual feedback.
|
|
* @param {string} text - Text to copy
|
|
* @param {HTMLElement} [feedbackEl] - Optional element to flash "Copied!"
|
|
*/
|
|
function copyToClipboard(text, feedbackEl) {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
if (feedbackEl) {
|
|
const orig = feedbackEl.textContent;
|
|
feedbackEl.textContent = 'Copied!';
|
|
feedbackEl.style.color = '#43b581';
|
|
setTimeout(() => {
|
|
feedbackEl.textContent = orig;
|
|
feedbackEl.style.color = '';
|
|
}, 1500);
|
|
}
|
|
}).catch(() => {
|
|
// Fallback for older browsers
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.position = 'fixed';
|
|
ta.style.opacity = '0';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
});
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
---
|
|
|
|
## 6. App Type Templates
|
|
|
|
### 6.1 Dashboard
|
|
|
|
**Use when:** Aggregate KPIs, overview metrics, recent activity summary.
|
|
|
|
**Expected data shape:** `{ title?, timeFrame?, metrics: { [key]: number }, recent?: { title, description?, date }[] }`
|
|
|
|
**Empty state:** "Ask me for a performance overview, KPIs, or a metrics summary."
|
|
|
|
```javascript
|
|
function render(data) {
|
|
showState('data');
|
|
const el = document.getElementById('content');
|
|
|
|
// Validate expected shape
|
|
validateData(data, ['metrics']);
|
|
|
|
const metrics = data.metrics || {};
|
|
const recentItems = Array.isArray(data.recent) ? data.recent : [];
|
|
|
|
el.innerHTML = `
|
|
<div class="app-header">
|
|
<div>
|
|
<div class="app-title">${escapeHtml(data.title || '{Service} Dashboard')}</div>
|
|
<div class="app-subtitle">${escapeHtml(data.timeFrame || 'Last 30 days')}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="metrics-row" role="list" aria-label="Key metrics">
|
|
${Object.entries(metrics).map(([key, val]) => `
|
|
<div class="metric-card" role="listitem">
|
|
<div class="metric-label">${escapeHtml(key.replace(/_/g, ' '))}</div>
|
|
<div class="metric-value" data-count="${typeof val === 'number' ? val : ''}">${typeof val === 'number' && key.includes('revenue') ? formatCurrency(val) : formatNumber(val)}</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
|
|
${recentItems.length > 0 ? `
|
|
<div class="card">
|
|
<div style="font-size:14px;font-weight:600;color:#fff;margin-bottom:12px">Recent Activity</div>
|
|
${recentItems.slice(0, 10).map((item, i) => `
|
|
<div class="row-enter" style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid rgba(58,60,65,0.5);animation-delay:${i * 50}ms">
|
|
<div>
|
|
<div style="font-weight:500">${escapeHtml(item.title || item.name || '—')}</div>
|
|
<div style="font-size:12px;color:#b0b2b8">${escapeHtml(item.description || item.type || '')}</div>
|
|
</div>
|
|
<div style="font-size:12px;color:#b0b2b8">${formatDateTime(item.date || item.createdAt)}</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
` : ''}
|
|
`;
|
|
|
|
// Animate metric numbers
|
|
el.querySelectorAll('.metric-value[data-count]').forEach(el => {
|
|
const target = parseFloat(el.dataset.count);
|
|
if (!isNaN(target)) {
|
|
const isCurrency = el.textContent.startsWith('$');
|
|
animateCount(el, target, 600, isCurrency ? formatCurrency : formatNumber);
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
**Dashboard empty state customization:**
|
|
```html
|
|
<div id="empty" style="display:none">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">📊</div>
|
|
<div class="empty-state-title">Dashboard</div>
|
|
<div class="empty-state-text">Ask me for a performance overview, revenue metrics, or a summary of recent activity.</div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
### 6.2 Data Grid
|
|
|
|
**Use when:** Searchable/filterable lists, table views.
|
|
|
|
**Expected data shape:** `{ title?, data|items|contacts|results: object[], meta?: { total, page, pageSize } }`
|
|
|
|
**Empty state:** "Try 'show me all active contacts' or 'list recent invoices.'"
|
|
|
|
```javascript
|
|
function render(data) {
|
|
showState('data');
|
|
const el = document.getElementById('content');
|
|
|
|
const items = Array.isArray(data) ? data : (data.data || data.items || data.contacts || data.results || []);
|
|
const total = data.meta?.total || data.total || items.length;
|
|
|
|
// Validate
|
|
if (!Array.isArray(items)) {
|
|
console.warn('[DataGrid] Expected array for items, got:', typeof items);
|
|
}
|
|
|
|
// Auto-detect columns from first item
|
|
const columns = items.length > 0
|
|
? Object.keys(items[0]).filter(k => !['id', '_id', '__v'].includes(k)).slice(0, 6)
|
|
: [];
|
|
|
|
el.innerHTML = `
|
|
<div class="app-header">
|
|
<div>
|
|
<div class="app-title">${escapeHtml(data.title || 'Results')}</div>
|
|
<div class="app-subtitle">${total} record${total !== 1 ? 's' : ''}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card" style="overflow-x:auto">
|
|
<table class="data-table" role="table" aria-label="${escapeHtml(data.title || 'Data grid')}">
|
|
<thead>
|
|
<tr>${columns.map(col => `<th scope="col">${escapeHtml(col.replace(/_/g, ' '))}</th>`).join('')}</tr>
|
|
</thead>
|
|
<tbody>
|
|
${items.map((item, i) => `
|
|
<tr class="row-enter" style="animation-delay:${i * 50}ms">
|
|
${columns.map(col => {
|
|
const val = item[col];
|
|
if (col === 'status' || col === 'state') {
|
|
return `<td><span class="badge ${getBadgeClass(val)}"><span class="sr-only">Status: </span>${escapeHtml(String(val || '—'))}</span></td>`;
|
|
}
|
|
if (typeof val === 'number' && (col.includes('amount') || col.includes('revenue') || col.includes('price'))) {
|
|
return `<td>${formatCurrency(val)}</td>`;
|
|
}
|
|
if (typeof val === 'string' && val.match(/^\d{4}-\d{2}-\d{2}/)) {
|
|
return `<td>${formatDate(val)}</td>`;
|
|
}
|
|
return `<td>${escapeHtml(String(val ?? '—'))}</td>`;
|
|
}).join('')}
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
}
|
|
```
|
|
|
|
**Data Grid empty state customization:**
|
|
```html
|
|
<div id="empty" style="display:none">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">📋</div>
|
|
<div class="empty-state-title">No records yet</div>
|
|
<div class="empty-state-text">Try "show me all active contacts" or "list recent invoices."</div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
### 6.3 Detail Card
|
|
|
|
**Use when:** Single entity deep-dive (contact, invoice, appointment).
|
|
|
|
**Expected data shape:** `{ data|contact|item: { name?, title?, email?, status?, ...fields } }`
|
|
|
|
**Empty state:** "Ask about a specific record by name or ID to see its details."
|
|
|
|
```javascript
|
|
function render(data) {
|
|
showState('data');
|
|
const el = document.getElementById('content');
|
|
|
|
// Flatten data — support nested formats
|
|
const item = data.data || data.contact || data.item || data;
|
|
const fields = Object.entries(item).filter(([k]) => !['id', '_id', '__v'].includes(k));
|
|
|
|
// Validate
|
|
validateData(item, ['name']);
|
|
|
|
el.innerHTML = `
|
|
<div class="app-header">
|
|
<div>
|
|
<div class="app-title">${escapeHtml(item.name || item.title || 'Details')}</div>
|
|
<div class="app-subtitle">${escapeHtml(item.email || item.type || item.status || '')}</div>
|
|
</div>
|
|
${item.status ? `<span class="badge ${getBadgeClass(item.status)}"><span class="sr-only">Status: </span>${escapeHtml(item.status)}</span>` : ''}
|
|
</div>
|
|
|
|
<div class="card" role="list" aria-label="Record details">
|
|
${fields.map(([key, val], i) => {
|
|
if (val == null || val === '') return '';
|
|
if (typeof val === 'object') val = JSON.stringify(val);
|
|
return `
|
|
<div role="listitem" class="row-enter" style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid rgba(58,60,65,0.3);animation-delay:${i * 50}ms">
|
|
<span style="color:#b0b2b8;font-size:12px;text-transform:capitalize">${escapeHtml(key.replace(/_/g, ' '))}</span>
|
|
<span style="font-weight:500;max-width:60%;text-align:right;word-break:break-word">${escapeHtml(String(val))}</span>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
```
|
|
|
|
**Detail Card empty state customization:**
|
|
```html
|
|
<div id="empty" style="display:none">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">🔍</div>
|
|
<div class="empty-state-title">No details to show</div>
|
|
<div class="empty-state-text">Ask about a specific record by name or ID to see its full details here.</div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
### 6.4 Form / Wizard
|
|
|
|
**Use when:** Multi-step creation or edit flows.
|
|
|
|
**Expected data shape:** `{ title?, description?, fields: { name, label?, type?, required?, placeholder?, options?: {value, label}[] }[] }`
|
|
|
|
**Empty state:** "Tell me what you'd like to create and I'll set up the form."
|
|
|
|
```javascript
|
|
function render(data) {
|
|
showState('data');
|
|
const el = document.getElementById('content');
|
|
|
|
// Validate
|
|
validateData(data, ['fields']);
|
|
|
|
const fields = data.fields || [];
|
|
const title = data.title || 'Create New';
|
|
|
|
el.innerHTML = `
|
|
<div class="app-header">
|
|
<div>
|
|
<div class="app-title">${escapeHtml(title)}</div>
|
|
<div class="app-subtitle">${escapeHtml(data.description || 'Fill in the details below')}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<form id="appForm" onsubmit="return false" aria-label="${escapeHtml(title)}">
|
|
${fields.map((field, i) => `
|
|
<div style="margin-bottom:16px" class="row-enter" style="animation-delay:${i * 50}ms">
|
|
<label for="field-${escapeHtml(field.name)}" style="display:block;font-size:12px;color:#b0b2b8;margin-bottom:4px;text-transform:capitalize">
|
|
${escapeHtml(field.label || field.name)}${field.required ? ' *' : ''}
|
|
</label>
|
|
${field.type === 'select' ? `
|
|
<select id="field-${escapeHtml(field.name)}" name="${escapeHtml(field.name)}" style="width:100%;padding:8px 12px;background:#1e2024;border:1px solid #3a3c41;border-radius:6px;color:#dcddde;font-size:13px" ${field.required ? 'required' : ''} aria-label="${escapeHtml(field.label || field.name)}">
|
|
<option value="">Select...</option>
|
|
${(field.options || []).map(opt => `<option value="${escapeHtml(opt.value || opt)}">${escapeHtml(opt.label || opt)}</option>`).join('')}
|
|
</select>
|
|
` : field.type === 'textarea' ? `
|
|
<textarea id="field-${escapeHtml(field.name)}" name="${escapeHtml(field.name)}" rows="3" style="width:100%;padding:8px 12px;background:#1e2024;border:1px solid #3a3c41;border-radius:6px;color:#dcddde;font-size:13px;resize:vertical" ${field.required ? 'required' : ''} placeholder="${escapeHtml(field.placeholder || '')}" aria-label="${escapeHtml(field.label || field.name)}"></textarea>
|
|
` : `
|
|
<input id="field-${escapeHtml(field.name)}" type="${field.type || 'text'}" name="${escapeHtml(field.name)}" style="width:100%;padding:8px 12px;background:#1e2024;border:1px solid #3a3c41;border-radius:6px;color:#dcddde;font-size:13px" ${field.required ? 'required' : ''} placeholder="${escapeHtml(field.placeholder || '')}" value="${escapeHtml(field.value || '')}" aria-label="${escapeHtml(field.label || field.name)}">
|
|
`}
|
|
</div>
|
|
`).join('')}
|
|
<button class="btn-primary" type="button" onclick="submitForm()" style="width:100%;margin-top:16px;padding:10px 16px">
|
|
${escapeHtml(data.submitLabel || 'Submit')}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Form submit handler — collects values, validates required fields, sends to host
|
|
function submitForm() {
|
|
const form = document.getElementById('appForm');
|
|
if (!form) return;
|
|
const formData = {};
|
|
const fields = form.querySelectorAll('input, select, textarea');
|
|
|
|
// Reset field borders
|
|
fields.forEach(f => { f.style.borderColor = '#3a3c41'; });
|
|
|
|
// Collect values
|
|
fields.forEach(field => {
|
|
if (field.name) formData[field.name] = field.value;
|
|
});
|
|
|
|
// Validate required fields
|
|
const missing = [...fields].filter(f => f.required && !f.value);
|
|
if (missing.length > 0) {
|
|
missing.forEach(f => { f.style.borderColor = '#f04747'; });
|
|
missing[0].focus();
|
|
return;
|
|
}
|
|
|
|
// Send to host for tool execution
|
|
sendToHost('tool_call', {
|
|
tool: 'create_' + APP_ID.split('-').pop(),
|
|
args: formData
|
|
});
|
|
|
|
// Show confirmation state
|
|
showState('empty');
|
|
document.querySelector('#empty .empty-state-icon').textContent = '✅';
|
|
document.querySelector('#empty .empty-state-title').textContent = 'Submitted!';
|
|
document.querySelector('#empty .empty-state-text').textContent = 'Your request has been sent. Check the chat for confirmation.';
|
|
}
|
|
```
|
|
|
|
**Form empty state customization:**
|
|
```html
|
|
<div id="empty" style="display:none">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">✏️</div>
|
|
<div class="empty-state-title">Ready to create</div>
|
|
<div class="empty-state-text">Tell me what you'd like to create and I'll set up the form for you.</div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
### 6.5 Timeline
|
|
|
|
**Use when:** Chronological events, activity feeds, audit logs.
|
|
|
|
**Expected data shape:** `{ title?, events|activities|timeline: { title, description?, date|timestamp, user|actor? }[] }`
|
|
|
|
**Empty state:** "Ask to see recent activity, event history, or an audit log."
|
|
|
|
```javascript
|
|
function render(data) {
|
|
showState('data');
|
|
const el = document.getElementById('content');
|
|
|
|
const events = Array.isArray(data) ? data : (data.events || data.activities || data.timeline || []);
|
|
|
|
// Validate
|
|
if (events.length > 0) validateData(events[0], ['title']);
|
|
|
|
el.innerHTML = `
|
|
<div class="app-header">
|
|
<div>
|
|
<div class="app-title">${escapeHtml(data.title || 'Activity Timeline')}</div>
|
|
<div class="app-subtitle">${events.length} event${events.length !== 1 ? 's' : ''}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="position:relative;padding-left:24px" role="list" aria-label="Timeline events">
|
|
<div style="position:absolute;left:8px;top:0;bottom:0;width:2px;background:#3a3c41" aria-hidden="true"></div>
|
|
${events.map((event, i) => `
|
|
<div style="position:relative;padding-bottom:${i < events.length - 1 ? '20px' : '0'}" role="listitem" class="row-enter" style="animation-delay:${i * 50}ms">
|
|
<div style="position:absolute;left:-20px;top:4px;width:12px;height:12px;border-radius:50%;background:${i === 0 ? '#ff6d5a' : '#3a3c41'};border:2px solid #1a1d23" aria-hidden="true"></div>
|
|
<div class="card" style="margin-left:8px">
|
|
<div style="display:flex;justify-content:space-between;align-items:start">
|
|
<div>
|
|
<div style="font-weight:600;color:#fff">${escapeHtml(event.title || event.type || event.action || '—')}</div>
|
|
<div style="font-size:12px;color:#b0b2b8;margin-top:2px">${escapeHtml(event.description || event.details || '')}</div>
|
|
</div>
|
|
<div style="font-size:11px;color:#b0b2b8;white-space:nowrap;margin-left:12px">${formatDateTime(event.date || event.timestamp || event.createdAt)}</div>
|
|
</div>
|
|
${event.user || event.actor ? `<div style="font-size:12px;color:#b0b2b8;margin-top:6px">by ${escapeHtml(event.user || event.actor)}</div>` : ''}
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
```
|
|
|
|
**Timeline empty state customization:**
|
|
```html
|
|
<div id="empty" style="display:none">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">🕐</div>
|
|
<div class="empty-state-title">No activity yet</div>
|
|
<div class="empty-state-text">Ask to see recent activity, event history, or an audit trail.</div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
### 6.6 Funnel / Pipeline
|
|
|
|
**Use when:** Stage-based progression (sales pipeline, deal stages).
|
|
|
|
**Expected data shape:** `{ title?, stages|pipeline: { name|title, items|deals: { name|title, value|amount?, contact|company? }[] }[] }`
|
|
|
|
**Empty state:** "Ask to see your sales pipeline or a specific deal stage."
|
|
|
|
```javascript
|
|
function render(data) {
|
|
showState('data');
|
|
const el = document.getElementById('content');
|
|
|
|
const stages = Array.isArray(data) ? data : (data.stages || data.pipeline || []);
|
|
|
|
// Validate
|
|
if (stages.length > 0) validateData(stages[0], ['name']);
|
|
|
|
el.innerHTML = `
|
|
<div class="app-header">
|
|
<div>
|
|
<div class="app-title">${escapeHtml(data.title || 'Pipeline')}</div>
|
|
<div class="app-subtitle">${escapeHtml(data.subtitle || '')}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display:flex;gap:12px;overflow-x:auto;padding-bottom:8px" role="list" aria-label="Pipeline stages">
|
|
${stages.map((stage, i) => {
|
|
const items = stage.items || stage.deals || stage.opportunities || [];
|
|
return `
|
|
<div style="min-width:220px;flex:1" role="listitem" aria-label="${escapeHtml(stage.name || stage.title)} stage, ${items.length} items">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;padding:8px 12px;background:#2b2d31;border-radius:8px 8px 0 0;border:1px solid #3a3c41;border-bottom:2px solid #ff6d5a">
|
|
<span style="font-weight:600;font-size:13px;color:#fff">${escapeHtml(stage.name || stage.title)}</span>
|
|
<span style="font-size:12px;color:#b0b2b8">${items.length}</span>
|
|
</div>
|
|
<div style="display:flex;flex-direction:column;gap:8px">
|
|
${items.map((item, j) => `
|
|
<div class="card row-enter" style="padding:12px;animation-delay:${(i * 3 + j) * 50}ms">
|
|
<div style="font-weight:500;font-size:13px;margin-bottom:4px">${escapeHtml(item.name || item.title)}</div>
|
|
${item.value || item.amount ? `<div style="font-size:14px;font-weight:600;color:#ff6d5a">${formatCurrency(item.value || item.amount)}</div>` : ''}
|
|
${item.contact || item.company ? `<div style="font-size:12px;color:#b0b2b8;margin-top:4px">${escapeHtml(item.contact || item.company)}</div>` : ''}
|
|
</div>
|
|
`).join('')}
|
|
${items.length === 0 ? '<div style="text-align:center;padding:16px;color:#b0b2b8;font-size:12px">No items</div>' : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
```
|
|
|
|
**Pipeline empty state customization:**
|
|
```html
|
|
<div id="empty" style="display:none">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">🔄</div>
|
|
<div class="empty-state-title">Pipeline empty</div>
|
|
<div class="empty-state-text">Ask to see your sales pipeline, deal stages, or project workflow.</div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
### 6.7 Calendar
|
|
|
|
**Use when:** Date-based data (appointments, events, schedules).
|
|
|
|
**Expected data shape:** `{ title?, events|appointments: { title|name, date|start|startTime, description?, location?, attendee|contact?, status? }[] }`
|
|
|
|
**Empty state:** "Ask to see upcoming appointments, scheduled events, or your calendar."
|
|
|
|
```javascript
|
|
function render(data) {
|
|
showState('data');
|
|
const el = document.getElementById('content');
|
|
|
|
const events = Array.isArray(data) ? data : (data.events || data.appointments || []);
|
|
const today = new Date();
|
|
|
|
// Validate
|
|
if (events.length > 0) validateData(events[0], ['title']);
|
|
|
|
// Group events by date
|
|
const byDate = {};
|
|
events.forEach(evt => {
|
|
const dateStr = new Date(evt.date || evt.start || evt.startTime).toISOString().split('T')[0];
|
|
if (!byDate[dateStr]) byDate[dateStr] = [];
|
|
byDate[dateStr].push(evt);
|
|
});
|
|
|
|
const sortedDates = Object.keys(byDate).sort();
|
|
|
|
el.innerHTML = `
|
|
<div class="app-header">
|
|
<div>
|
|
<div class="app-title">${escapeHtml(data.title || 'Calendar')}</div>
|
|
<div class="app-subtitle">${events.length} event${events.length !== 1 ? 's' : ''}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div role="list" aria-label="Calendar events grouped by date">
|
|
${sortedDates.map(dateStr => {
|
|
const d = new Date(dateStr + 'T12:00:00');
|
|
const isToday = dateStr === today.toISOString().split('T')[0];
|
|
return `
|
|
<div style="margin-bottom:16px" role="listitem">
|
|
<div style="font-size:13px;font-weight:600;color:${isToday ? '#ff6d5a' : '#fff'};margin-bottom:8px;padding:4px 0;border-bottom:1px solid #3a3c41">
|
|
${isToday ? '📍 Today — ' : ''}${d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
|
|
</div>
|
|
${byDate[dateStr].map((evt, i) => `
|
|
<div class="card row-enter" style="margin-bottom:8px;padding:12px;display:flex;gap:12px;align-items:start;animation-delay:${i * 50}ms">
|
|
<div style="font-size:12px;color:#ff6d5a;font-weight:600;white-space:nowrap;min-width:55px">
|
|
${formatTime(evt.start || evt.startTime || evt.date)}
|
|
</div>
|
|
<div style="flex:1">
|
|
<div style="font-weight:500">${escapeHtml(evt.title || evt.name || '—')}</div>
|
|
${evt.description || evt.location ? `<div style="font-size:12px;color:#b0b2b8;margin-top:2px">${escapeHtml(evt.description || evt.location || '')}</div>` : ''}
|
|
${evt.attendee || evt.contact ? `<div style="font-size:12px;color:#b0b2b8;margin-top:2px">👤 ${escapeHtml(evt.attendee || evt.contact)}</div>` : ''}
|
|
</div>
|
|
${evt.status ? `<span class="badge ${getBadgeClass(evt.status)}"><span class="sr-only">Status: </span>${escapeHtml(evt.status)}</span>` : ''}
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function formatTime(dateStr) {
|
|
if (!dateStr) return '';
|
|
try {
|
|
return new Date(dateStr).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
|
} catch { return ''; }
|
|
}
|
|
```
|
|
|
|
**Calendar empty state customization:**
|
|
```html
|
|
<div id="empty" style="display:none">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">📅</div>
|
|
<div class="empty-state-title">No events scheduled</div>
|
|
<div class="empty-state-text">Ask to see upcoming appointments, scheduled events, or your calendar for a specific date range.</div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
### 6.8 Analytics / Chart
|
|
|
|
**Use when:** Data visualization, trends, comparisons. Pure CSS charts (no external libs).
|
|
|
|
**Expected data shape:** `{ title?, subtitle|timeFrame?, metrics?: { [key]: number }, chart|series: { label|name, value|count }[], chartTitle? }`
|
|
|
|
**Empty state:** "Ask for analytics, performance trends, or a breakdown of your data."
|
|
|
|
```javascript
|
|
function render(data) {
|
|
showState('data');
|
|
const el = document.getElementById('content');
|
|
|
|
// Validate
|
|
validateData(data, ['chart']);
|
|
|
|
const chartData = data.chart || data.series || [];
|
|
const maxVal = Math.max(...chartData.map(d => d.value || d.count || 0), 1);
|
|
|
|
el.innerHTML = `
|
|
<div class="app-header">
|
|
<div>
|
|
<div class="app-title">${escapeHtml(data.title || 'Analytics')}</div>
|
|
<div class="app-subtitle">${escapeHtml(data.subtitle || data.timeFrame || '')}</div>
|
|
</div>
|
|
</div>
|
|
|
|
${data.metrics ? `
|
|
<div class="metrics-row" role="list" aria-label="Key metrics">
|
|
${Object.entries(data.metrics).map(([key, val]) => `
|
|
<div class="metric-card" role="listitem">
|
|
<div class="metric-label">${escapeHtml(key.replace(/_/g, ' '))}</div>
|
|
<div class="metric-value" data-count="${typeof val === 'number' ? val : ''}">${formatNumber(val)}</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="card">
|
|
<div style="font-size:14px;font-weight:600;color:#fff;margin-bottom:16px">${escapeHtml(data.chartTitle || 'Overview')}</div>
|
|
<div style="display:flex;align-items:flex-end;gap:4px;height:160px;padding:0 4px" role="img" aria-label="Bar chart showing ${escapeHtml(data.chartTitle || 'data')}">
|
|
${chartData.map((d, i) => {
|
|
const pct = ((d.value || d.count || 0) / maxVal) * 100;
|
|
return `
|
|
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px" class="row-enter" style="animation-delay:${i * 50}ms">
|
|
<div style="font-size:10px;color:#b0b2b8">${formatNumber(d.value || d.count)}</div>
|
|
<div style="width:100%;background:#ff6d5a;border-radius:4px 4px 0 0;height:${Math.max(pct, 2)}%;min-height:4px;transition:height 0.3s"></div>
|
|
<div style="font-size:10px;color:#b0b2b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;text-align:center">${escapeHtml(d.label || d.name || '')}</div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Animate metric numbers
|
|
el.querySelectorAll('.metric-value[data-count]').forEach(el => {
|
|
const target = parseFloat(el.dataset.count);
|
|
if (!isNaN(target)) animateCount(el, target);
|
|
});
|
|
}
|
|
```
|
|
|
|
**Analytics empty state customization:**
|
|
```html
|
|
<div id="empty" style="display:none">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">📈</div>
|
|
<div class="empty-state-title">No analytics data</div>
|
|
<div class="empty-state-text">Ask for performance trends, a revenue breakdown, or a comparison report.</div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
### 6.9 Interactive Data Grid
|
|
|
|
**Use when:** Data tables that need client-side sorting, filtering, searching, copy-to-clipboard, expand/collapse, or bulk selection. Use this instead of the basic Data Grid (6.2) when users need to interact with the data beyond reading it.
|
|
|
|
**Expected data shape:** `{ title?, data|items: object[], columns?: { key, label, sortable?, copyable? }[], meta?: { total } }`
|
|
|
|
**Empty state:** "Try 'show me all contacts' or 'list invoices from this month.'"
|
|
|
|
This template includes all 5 interactive patterns. Include only the patterns your app needs.
|
|
|
|
```html
|
|
<!-- Additional CSS for Interactive Data Grid (add to <style>) -->
|
|
<style>
|
|
/* ═══ INTERACTIVE DATA GRID ═══ */
|
|
.grid-toolbar {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 12px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
.grid-search {
|
|
flex: 1;
|
|
min-width: 160px;
|
|
padding: 6px 12px;
|
|
background: #1e2024;
|
|
border: 1px solid #3a3c41;
|
|
border-radius: 6px;
|
|
color: #dcddde;
|
|
font-size: 13px;
|
|
}
|
|
.grid-search:focus { border-color: #ff6d5a; outline: none; }
|
|
.grid-search::placeholder { color: #b0b2b8; }
|
|
|
|
/* Sortable column headers */
|
|
.sortable {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
position: relative;
|
|
padding-right: 20px !important;
|
|
}
|
|
.sortable:hover { color: #dcddde; }
|
|
.sortable::after {
|
|
content: '⇅';
|
|
position: absolute;
|
|
right: 4px;
|
|
opacity: 0.4;
|
|
font-size: 10px;
|
|
}
|
|
.sortable.asc::after { content: '↑'; opacity: 1; color: #ff6d5a; }
|
|
.sortable.desc::after { content: '↓'; opacity: 1; color: #ff6d5a; }
|
|
|
|
/* Bulk selection */
|
|
.bulk-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 12px;
|
|
background: rgba(255, 109, 90, 0.1);
|
|
border: 1px solid rgba(255, 109, 90, 0.3);
|
|
border-radius: 6px;
|
|
margin-bottom: 8px;
|
|
font-size: 13px;
|
|
color: #ff6d5a;
|
|
}
|
|
.bulk-bar button {
|
|
background: #ff6d5a;
|
|
color: #fff;
|
|
border: none;
|
|
padding: 4px 12px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
}
|
|
.bulk-bar button:hover { background: #ff8574; }
|
|
|
|
/* Copyable cells */
|
|
.copyable {
|
|
cursor: pointer;
|
|
border-bottom: 1px dashed #3a3c41;
|
|
transition: color 0.15s;
|
|
}
|
|
.copyable:hover { color: #ff6d5a; }
|
|
|
|
/* Accordion / expand-collapse */
|
|
.expandable-row { cursor: pointer; }
|
|
.expandable-row:hover td { background: #35373c; }
|
|
.expand-icon { display: inline-block; transition: transform 0.15s; margin-right: 4px; font-size: 10px; }
|
|
.expand-icon.open { transform: rotate(90deg); }
|
|
.detail-row { display: none; }
|
|
.detail-row.open { display: table-row; }
|
|
.detail-row td {
|
|
background: #232529;
|
|
padding: 12px 16px !important;
|
|
border-bottom: 1px solid #3a3c41;
|
|
}
|
|
|
|
/* Grid checkbox */
|
|
.grid-check {
|
|
appearance: none;
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 2px solid #3a3c41;
|
|
border-radius: 3px;
|
|
background: #1e2024;
|
|
cursor: pointer;
|
|
vertical-align: middle;
|
|
}
|
|
.grid-check:checked {
|
|
background: #ff6d5a;
|
|
border-color: #ff6d5a;
|
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3E%3C/svg%3E");
|
|
}
|
|
.grid-check:focus-visible { outline: 2px solid #ff6d5a; outline-offset: 2px; }
|
|
</style>
|
|
```
|
|
|
|
```javascript
|
|
// ═══ Interactive Data Grid — Full Implementation ═══
|
|
|
|
let gridState = {
|
|
items: [],
|
|
filteredItems: [],
|
|
sortCol: null,
|
|
sortDir: 'asc',
|
|
searchQuery: '',
|
|
selectedIds: new Set(),
|
|
expandedIds: new Set()
|
|
};
|
|
|
|
function render(data) {
|
|
showState('data');
|
|
const el = document.getElementById('content');
|
|
|
|
// Parse items from various data shapes
|
|
const rawItems = Array.isArray(data) ? data : (data.data || data.items || data.contacts || data.results || []);
|
|
gridState.items = rawItems.map((item, i) => ({ ...item, _idx: i, _id: item.id || item._id || `row-${i}` }));
|
|
gridState.filteredItems = [...gridState.items];
|
|
|
|
// Auto-detect columns (or use provided columns config)
|
|
const columnConfig = data.columns || (rawItems.length > 0
|
|
? Object.keys(rawItems[0])
|
|
.filter(k => !['id', '_id', '__v', '_idx'].includes(k))
|
|
.slice(0, 6)
|
|
.map(k => ({ key: k, label: k.replace(/_/g, ' '), sortable: true, copyable: k === 'email' || k === 'id' }))
|
|
: []);
|
|
|
|
const total = data.meta?.total || data.total || rawItems.length;
|
|
|
|
el.innerHTML = `
|
|
<div class="app-header">
|
|
<div>
|
|
<div class="app-title">${escapeHtml(data.title || 'Data Explorer')}</div>
|
|
<div class="app-subtitle"><span id="grid-count">${total}</span> record${total !== 1 ? 's' : ''}</div>
|
|
</div>
|
|
<button class="btn-secondary" onclick="sendToHost('refresh', {})" aria-label="Refresh data" tabindex="0">↻ Refresh</button>
|
|
</div>
|
|
|
|
<!-- Toolbar: Search -->
|
|
<div class="grid-toolbar">
|
|
<input type="text" class="grid-search" placeholder="Search records…" id="grid-search"
|
|
oninput="handleSearch(this.value)" aria-label="Search records" tabindex="0">
|
|
</div>
|
|
|
|
<!-- Bulk action bar (hidden until selection) -->
|
|
<div id="bulk-bar" class="bulk-bar" style="display:none" role="status">
|
|
<span><span id="bulk-count">0</span> selected</span>
|
|
<div style="display:flex;gap:8px">
|
|
<button onclick="handleBulkAction('export')" tabindex="0">Export</button>
|
|
<button onclick="clearSelection()" style="background:transparent;color:#b0b2b8;border:1px solid #3a3c41" tabindex="0">Clear</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Data table -->
|
|
<div class="card" style="overflow-x:auto">
|
|
<table class="data-table" role="table" aria-label="${escapeHtml(data.title || 'Interactive data grid')}">
|
|
<thead>
|
|
<tr>
|
|
<th style="width:32px"><input type="checkbox" class="grid-check" id="select-all" onchange="toggleSelectAll(this.checked)" aria-label="Select all rows" tabindex="0"></th>
|
|
${columnConfig.map(col => `
|
|
<th scope="col" class="${col.sortable !== false ? 'sortable' : ''}"
|
|
${col.sortable !== false ? `onclick="handleSort('${col.key}')" tabindex="0" role="button" aria-label="Sort by ${escapeHtml(col.label)}"` : ''}
|
|
id="col-${col.key}">
|
|
${escapeHtml(col.label)}
|
|
</th>
|
|
`).join('')}
|
|
<th style="width:32px" scope="col"><span class="sr-only">Expand</span></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="grid-body">
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
|
|
// Store column config for re-renders
|
|
gridState.columns = columnConfig;
|
|
renderRows();
|
|
}
|
|
|
|
function renderRows() {
|
|
const tbody = document.getElementById('grid-body');
|
|
if (!tbody) return;
|
|
|
|
const items = gridState.filteredItems;
|
|
const cols = gridState.columns;
|
|
|
|
tbody.innerHTML = items.map((item, i) => {
|
|
const isSelected = gridState.selectedIds.has(item._id);
|
|
const isExpanded = gridState.expandedIds.has(item._id);
|
|
|
|
return `
|
|
<tr class="expandable-row row-enter" style="animation-delay:${i * 30}ms" data-id="${escapeHtml(String(item._id))}">
|
|
<td><input type="checkbox" class="grid-check" ${isSelected ? 'checked' : ''} onchange="toggleSelect('${escapeHtml(String(item._id))}', this.checked)" aria-label="Select row ${i + 1}" tabindex="0"></td>
|
|
${cols.map(col => {
|
|
const val = item[col.key];
|
|
let cellContent;
|
|
|
|
if (col.key === 'status' || col.key === 'state') {
|
|
cellContent = `<span class="badge ${getBadgeClass(val)}"><span class="sr-only">Status: </span>${escapeHtml(String(val || '—'))}</span>`;
|
|
} else if (col.copyable) {
|
|
cellContent = `<span class="copyable" onclick="event.stopPropagation();copyToClipboard('${escapeHtml(String(val || ''))}', this)" title="Click to copy" tabindex="0" role="button" aria-label="Copy ${escapeHtml(col.label)}: ${escapeHtml(String(val || ''))}">${escapeHtml(String(val ?? '—'))}</span>`;
|
|
} else if (typeof val === 'number' && (col.key.includes('amount') || col.key.includes('revenue') || col.key.includes('price'))) {
|
|
cellContent = formatCurrency(val);
|
|
} else if (typeof val === 'string' && val.match(/^\d{4}-\d{2}-\d{2}/)) {
|
|
cellContent = formatDate(val);
|
|
} else {
|
|
cellContent = escapeHtml(String(val ?? '—'));
|
|
}
|
|
|
|
return `<td>${cellContent}</td>`;
|
|
}).join('')}
|
|
<td>
|
|
<span class="expand-icon ${isExpanded ? 'open' : ''}" onclick="toggleExpand('${escapeHtml(String(item._id))}')" tabindex="0" role="button" aria-label="${isExpanded ? 'Collapse' : 'Expand'} row details" aria-expanded="${isExpanded}">▶</span>
|
|
</td>
|
|
</tr>
|
|
<tr class="detail-row ${isExpanded ? 'open' : ''}" id="detail-${escapeHtml(String(item._id))}">
|
|
<td colspan="${cols.length + 2}">
|
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px">
|
|
${Object.entries(item).filter(([k]) => !k.startsWith('_')).map(([k, v]) => `
|
|
<div>
|
|
<span style="color:#b0b2b8;font-size:11px;text-transform:capitalize">${escapeHtml(k.replace(/_/g, ' '))}</span><br>
|
|
<span style="font-size:13px">${escapeHtml(String(v ?? '—'))}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
// Update count
|
|
const countEl = document.getElementById('grid-count');
|
|
if (countEl) countEl.textContent = items.length;
|
|
}
|
|
|
|
// ── Apply Sort (without toggling direction) ──
|
|
// Extracted so handleSearch can re-apply the current sort without side effects
|
|
function applySort() {
|
|
const colKey = gridState.sortCol;
|
|
if (!colKey) return;
|
|
gridState.filteredItems.sort((a, b) => {
|
|
let aVal = a[colKey], bVal = b[colKey];
|
|
if (aVal == null) return 1;
|
|
if (bVal == null) return -1;
|
|
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
|
return gridState.sortDir === 'asc' ? aVal - bVal : bVal - aVal;
|
|
}
|
|
aVal = String(aVal).toLowerCase();
|
|
bVal = String(bVal).toLowerCase();
|
|
const cmp = aVal.localeCompare(bVal);
|
|
return gridState.sortDir === 'asc' ? cmp : -cmp;
|
|
});
|
|
}
|
|
|
|
// ── Sorting (user clicks column header) ──
|
|
function handleSort(colKey) {
|
|
if (gridState.sortCol === colKey) {
|
|
gridState.sortDir = gridState.sortDir === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
gridState.sortCol = colKey;
|
|
gridState.sortDir = 'asc';
|
|
}
|
|
|
|
// Update header classes
|
|
document.querySelectorAll('.sortable').forEach(th => th.classList.remove('asc', 'desc'));
|
|
const activeHeader = document.getElementById(`col-${colKey}`);
|
|
if (activeHeader) activeHeader.classList.add(gridState.sortDir);
|
|
|
|
applySort();
|
|
renderRows();
|
|
}
|
|
|
|
// ── Filtering / Search ──
|
|
function handleSearch(query) {
|
|
gridState.searchQuery = query.toLowerCase().trim();
|
|
if (!gridState.searchQuery) {
|
|
gridState.filteredItems = [...gridState.items];
|
|
} else {
|
|
gridState.filteredItems = gridState.items.filter(item =>
|
|
Object.values(item).some(v =>
|
|
v != null && String(v).toLowerCase().includes(gridState.searchQuery)
|
|
)
|
|
);
|
|
}
|
|
// Re-apply current sort without toggling direction
|
|
if (gridState.sortCol) {
|
|
applySort();
|
|
}
|
|
renderRows();
|
|
}
|
|
|
|
// ── Bulk Selection ──
|
|
function toggleSelect(id, checked) {
|
|
if (checked) {
|
|
gridState.selectedIds.add(id);
|
|
} else {
|
|
gridState.selectedIds.delete(id);
|
|
}
|
|
updateBulkBar();
|
|
}
|
|
|
|
function toggleSelectAll(checked) {
|
|
if (checked) {
|
|
gridState.filteredItems.forEach(item => gridState.selectedIds.add(item._id));
|
|
} else {
|
|
gridState.selectedIds.clear();
|
|
}
|
|
// Update all checkboxes
|
|
document.querySelectorAll('#grid-body .grid-check').forEach(cb => cb.checked = checked);
|
|
updateBulkBar();
|
|
}
|
|
|
|
function clearSelection() {
|
|
gridState.selectedIds.clear();
|
|
document.querySelectorAll('.grid-check').forEach(cb => cb.checked = false);
|
|
updateBulkBar();
|
|
}
|
|
|
|
function updateBulkBar() {
|
|
const bar = document.getElementById('bulk-bar');
|
|
const count = gridState.selectedIds.size;
|
|
if (bar) {
|
|
bar.style.display = count > 0 ? 'flex' : 'none';
|
|
document.getElementById('bulk-count').textContent = count;
|
|
}
|
|
}
|
|
|
|
function handleBulkAction(action) {
|
|
const selectedItems = gridState.items.filter(item => gridState.selectedIds.has(item._id));
|
|
sendToHost('tool_call', { action, items: selectedItems.map(i => ({ ...i, _idx: undefined, _id: undefined })) });
|
|
}
|
|
|
|
// ── Expand/Collapse ──
|
|
function toggleExpand(id) {
|
|
if (gridState.expandedIds.has(id)) {
|
|
gridState.expandedIds.delete(id);
|
|
} else {
|
|
gridState.expandedIds.add(id);
|
|
}
|
|
const detailRow = document.getElementById(`detail-${id}`);
|
|
const icon = document.querySelector(`tr[data-id="${id}"] .expand-icon`);
|
|
if (detailRow) detailRow.classList.toggle('open');
|
|
if (icon) {
|
|
icon.classList.toggle('open');
|
|
icon.setAttribute('aria-expanded', gridState.expandedIds.has(id));
|
|
}
|
|
}
|
|
```
|
|
|
|
> **Performance Note (100+ rows):** For datasets over 100 rows, the full DOM render becomes slow. Two mitigation strategies:
|
|
> 1. **Client-side pagination:** Render 50 rows at a time with prev/next controls. All data is already loaded — just slice the array.
|
|
> 2. **Virtual scrolling:** Only render visible rows + a buffer zone (±10 rows). Recalculate on scroll. More complex but handles 10K+ rows.
|
|
>
|
|
> For most MCP apps, client-side pagination is sufficient. The tool's `meta.pageSize` already limits server-side results to 25-50 rows.
|
|
|
|
**Interactive Data Grid empty state customization:**
|
|
```html
|
|
<div id="empty" style="display:none">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">🔎</div>
|
|
<div class="empty-state-title">Ready to explore</div>
|
|
<div class="empty-state-text">Try "show me all contacts" or "list invoices from this month" to load data you can sort, filter, and explore.</div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Bidirectional Communication Patterns
|
|
|
|
Apps can send actions back to the LocalBosses host using `sendToHost()`. The host listens for `mcp_app_action` messages on the iframe's parent window.
|
|
|
|
### Pattern 1: Request Data Refresh
|
|
|
|
```javascript
|
|
// User clicks a "Refresh" button in the app
|
|
document.getElementById('refreshBtn').addEventListener('click', () => {
|
|
sendToHost('refresh', {});
|
|
showState('loading'); // Show loading while refresh happens
|
|
});
|
|
```
|
|
|
|
### Pattern 2: Navigate to Another App (Drill-Down)
|
|
|
|
```javascript
|
|
// User clicks a contact name → open their detail card
|
|
function openContact(contactId, contactName) {
|
|
sendToHost('navigate', {
|
|
app: 'contact-card',
|
|
params: { id: contactId, name: contactName }
|
|
});
|
|
}
|
|
|
|
// In a table row:
|
|
// <td><a href="#" onclick="openContact('${item.id}', '${escapeHtml(item.name)}')" tabindex="0">${escapeHtml(item.name)}</a></td>
|
|
```
|
|
|
|
> **App-to-App Navigation (Drill-Down):** The `sendToHost('navigate', ...)` pattern enables interconnected apps. Example flows:
|
|
> - **Data Grid → Detail Card:** Click a contact name in the grid → host opens the contact-card app with that contact's data
|
|
> - **Dashboard → Data Grid:** Click a metric card → host opens the grid filtered to that metric
|
|
> - **Detail Card → Form:** Click "Edit" → host opens the form pre-filled with the entity's data
|
|
>
|
|
> The host must listen for `mcp_app_action` messages with `action: 'navigate'` and handle the app switch (see `mcp-localbosses-integrator` Phase 4 for host-side wiring).
|
|
|
|
### Pattern 3: Trigger a Tool Call
|
|
|
|
```javascript
|
|
// User clicks "Delete" on a row
|
|
function deleteItem(itemId) {
|
|
if (confirm('Are you sure you want to delete this item?')) {
|
|
sendToHost('tool_call', {
|
|
tool: 'delete_contact',
|
|
args: { id: itemId }
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Responsive Design Requirements
|
|
|
|
Apps must work from **280px to 800px width**.
|
|
|
|
### Breakpoints:
|
|
|
|
| Width | Behavior |
|
|
|-------|----------|
|
|
| 280-399px | Single column. Compact padding. Smaller fonts. Horizontal scroll for tables. |
|
|
| 400-599px | Two columns for metrics. Standard padding. |
|
|
| 600-800px | Full layout. Three+ metric columns. Tables without scroll. |
|
|
|
|
### Required CSS:
|
|
```css
|
|
@media (max-width: 400px) {
|
|
body { padding: 12px; }
|
|
.metrics-row { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
|
.app-title { font-size: 16px; }
|
|
.data-table { font-size: 12px; }
|
|
}
|
|
@media (max-width: 300px) {
|
|
.metrics-row { grid-template-columns: 1fr; }
|
|
body { padding: 8px; }
|
|
}
|
|
```
|
|
|
|
### Key rules:
|
|
- Use `grid-template-columns: repeat(auto-fit, minmax(Xpx, 1fr))` for adaptive grids
|
|
- Tables get `overflow-x: auto` on the container
|
|
- Pipeline columns scroll horizontally on narrow screens
|
|
- All text uses `word-break: break-word` or `text-overflow: ellipsis`
|
|
|
|
---
|
|
|
|
## 9. Three Required States
|
|
|
|
Every app MUST implement all three:
|
|
|
|
### 1. Loading State (visible on page load)
|
|
- Use CSS skeleton animations (shimmer effect)
|
|
- Match the layout of the data state (skeletons should look like the content)
|
|
- Default state — visible when page first loads
|
|
- Must include `role="status"` and `aria-label="Loading content"` for screen readers
|
|
- Must include `<span class="sr-only">Loading content, please wait…</span>`
|
|
- Skeleton animation respects `prefers-reduced-motion` (degrades to static background)
|
|
|
|
### 2. Empty State (when data is null or empty)
|
|
- Center-aligned with large icon, title, and description
|
|
- **Context-specific prompt per app type** (NOT generic "Ask me a question"):
|
|
- Dashboard: "Ask me for a performance overview, KPIs, or a metrics summary."
|
|
- Data Grid: "Try 'show me all active contacts' or 'list recent invoices.'"
|
|
- Detail Card: "Ask about a specific record by name or ID to see its details."
|
|
- Form: "Tell me what you'd like to create and I'll set up the form."
|
|
- Timeline: "Ask to see recent activity, event history, or an audit trail."
|
|
- Pipeline: "Ask to see your sales pipeline or a specific deal stage."
|
|
- Calendar: "Ask to see upcoming appointments or your calendar for a date range."
|
|
- Analytics: "Ask for analytics, performance trends, or a data breakdown."
|
|
- Interactive Grid: "Try 'show me all contacts' to load data you can sort and explore."
|
|
- Friendly, not error-like
|
|
|
|
### 3. Data State (when data is received)
|
|
- Full app rendering with `aria-live="polite"` on the content container
|
|
- Handle missing/null fields gracefully (show "—" not "undefined")
|
|
- Handle unexpected data shapes (arrays where objects expected, etc.)
|
|
- Validate data shape with `validateData()` before rendering
|
|
- Apply staggered row entrance animations where appropriate
|
|
- Focus moves to content container when data loads
|
|
|
|
---
|
|
|
|
## 10. Rules & Constraints
|
|
|
|
### MUST:
|
|
- [x] Single HTML file — all CSS/JS inline
|
|
- [x] Zero external dependencies — no CDN links, no fetch to external URLs
|
|
- [x] Dark theme matching LocalBosses palette
|
|
- [x] All three states (loading, empty, data)
|
|
- [x] Both data reception methods (postMessage + polling with exponential backoff)
|
|
- [x] HTML escaping on all user data (`escapeHtml()`)
|
|
- [x] Responsive from 280px to 800px
|
|
- [x] Graceful with missing fields (never show "undefined")
|
|
- [x] Error boundary — `window.onerror` handler, try/catch in render
|
|
- [x] WCAG AA contrast — secondary text `#b0b2b8` (5.0:1), never `#96989d`
|
|
- [x] Accessibility — ARIA attributes, keyboard navigation, focus management
|
|
- [x] Data validation — `validateData()` before rendering
|
|
- [x] Context-specific empty state prompts per app type
|
|
- [x] `prefers-reduced-motion` respected for all animations
|
|
- [x] File size under 50KB per app (ideally under 30KB) — budget enforced during QA
|
|
|
|
### MUST NOT:
|
|
- [ ] No external CSS/JS files
|
|
- [ ] No CDN links (Chart.js, D3, etc.)
|
|
- [ ] No `<iframe>` inception
|
|
- [ ] No localStorage/sessionStorage (data comes from host)
|
|
- [ ] No hardcoded API calls (data comes via postMessage/polling)
|
|
- [ ] No light theme elements
|
|
- [ ] No use of `#96989d` for text (fails WCAG AA)
|
|
|
|
---
|
|
|
|
## 11. Quality Gate Checklist
|
|
|
|
Before passing apps to Phase 4, verify:
|
|
|
|
- [ ] **Every app renders with sample data** — no blank screens
|
|
- [ ] **Every app has loading skeleton** — visible on first load, with `role="status"` and sr-only text
|
|
- [ ] **Every app has empty state** — context-specific prompt matching its app type
|
|
- [ ] **Dark theme is consistent** — #1a1d23 bg, #2b2d31 cards, #ff6d5a accent
|
|
- [ ] **WCAG AA contrast** — all secondary text uses `#b0b2b8`, NOT `#96989d`
|
|
- [ ] **Works at 280px width** — no broken layouts, all content accessible
|
|
- [ ] **Works at 800px width** — no excessive whitespace, uses available space
|
|
- [ ] **No external dependencies** — zero CDN links, zero fetch to external URLs
|
|
- [ ] **HTML is escaped** — no XSS from user data
|
|
- [ ] **Handles missing fields** — shows "—" not "undefined" or "null"
|
|
- [ ] **Error boundary present** — `window.onerror` handler catches render failures
|
|
- [ ] **Accessibility basics** — ARIA roles/labels on tables, lists, interactive elements
|
|
- [ ] **Keyboard navigable** — all interactive elements focusable with visible focus indicator
|
|
- [ ] **Reduced motion respected** — `prefers-reduced-motion` disables animations
|
|
- [ ] **Polling uses exponential backoff** — 3s → 5s → 10s → 30s, max 20 attempts
|
|
- [ ] **Data validation** — `validateData()` called before rendering
|
|
- [ ] **File size is reasonable** — single HTML under 50KB (ideally under 30KB)
|
|
|
|
---
|
|
|
|
## 12. Execution Workflow
|
|
|
|
```
|
|
1. Read {service}-api-analysis.md — App Candidates section
|
|
2. For each app candidate:
|
|
a. Choose app type (dashboard/grid/card/form/timeline/funnel/calendar/analytics/interactive-grid)
|
|
b. Copy the base HTML template
|
|
c. Customize the render() function using the type-specific template
|
|
d. Set correct APP_ID for polling
|
|
e. Customize loading skeleton to match content layout
|
|
f. Customize empty state with context-specific icon and message for this app type
|
|
g. Add ARIA attributes (role, aria-label) to dynamic content regions
|
|
h. Verify error boundary is present (window.onerror)
|
|
i. Verify polling uses exponential backoff pattern
|
|
j. Add data validation with validateData() for expected fields
|
|
k. Test with sample data mentally (does the render handle edge cases?)
|
|
3. Save all files to {service}-mcp/app-ui/
|
|
4. Verify all apps against quality gate
|
|
```
|
|
|
|
**Estimated time:** 15-30 minutes per app, 1-3 hours for a full set.
|
|
|
|
**Agent model recommendation:** Sonnet — well-defined templates, HTML/CSS generation.
|
|
|
|
---
|
|
|
|
*This skill is Phase 3 of the MCP Factory pipeline. It produces the visual HTML apps that render inside LocalBosses threads.*
|