84 KiB
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
#b0b2b8achieves 5.0:1 on#1a1d23and 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
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
.card {
background: #2b2d31;
border-radius: 8px;
padding: 16px;
border: 1px solid #3a3c41;
}
Buttons
.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
.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)
<!-- 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:
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)
<!-- 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:
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)
<!-- 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)
<!-- 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)
<!-- 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:
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_DATAblock 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
structuredContentalongside a text fallback incontent. The flow is:
- MCP tool returns
{ content: [...], structuredContent: { data: [...], meta: {...} } }- LocalBosses receives the tool result — the
structuredContentis the typed data- AI uses
structuredContentto generate theAPP_DATAblock in its response text- LocalBosses route.ts parses
APP_DATAfrom the AI's response and sends it to the iframeThe app itself doesn't interact with MCP directly — it receives data via
postMessageor polling, regardless of whether the data originally came fromstructuredContentor was generated by the AI. The apps are a pure rendering layer.
Two data reception methods (apps MUST support both):
- postMessage — Primary. Host sends data to iframe.
- Polling — Fallback. App fetches from
/api/app-datawith exponential backoff.
5. The HTML App Template
This is the EXACT base template for every app. Copy and customize.
<!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."
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:
<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.'"
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:
<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."
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:
<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."
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:
<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."
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:
<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."
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:
<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."
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:
<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."
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:
<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.
<!-- 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>
// ═══ 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:
- Client-side pagination: Render 50 rows at a time with prev/next controls. All data is already loaded — just slice the array.
- 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.pageSizealready limits server-side results to 25-50 rows.
Interactive Data Grid empty state customization:
<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
// 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)
// 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_actionmessages withaction: 'navigate'and handle the app switch (seemcp-localbosses-integratorPhase 4 for host-side wiring).
Pattern 3: Trigger a Tool Call
// 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:
@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: autoon the container - Pipeline columns scroll horizontally on narrow screens
- All text uses
word-break: break-wordortext-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"andaria-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:
- Single HTML file — all CSS/JS inline
- Zero external dependencies — no CDN links, no fetch to external URLs
- Dark theme matching LocalBosses palette
- All three states (loading, empty, data)
- Both data reception methods (postMessage + polling with exponential backoff)
- HTML escaping on all user data (
escapeHtml()) - Responsive from 280px to 800px
- Graceful with missing fields (never show "undefined")
- Error boundary —
window.onerrorhandler, try/catch in render - WCAG AA contrast — secondary text
#b0b2b8(5.0:1), never#96989d - Accessibility — ARIA attributes, keyboard navigation, focus management
- Data validation —
validateData()before rendering - Context-specific empty state prompts per app type
prefers-reduced-motionrespected for all animations- 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
#96989dfor 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.onerrorhandler 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-motiondisables 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.