# 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
```
**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:
```
### 3.2 Donut / Pie Chart (SVG Circle)
```html
```
**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 = ``;
offset -= seg.percent;
return html;
}).join('');
}
```
### 3.3 Sparklines (Inline SVG)
```html
```
### 3.4 Progress Bars (CSS-Only)
```html
Conversion
45%
```
### 3.5 Horizontal Bar Charts (CSS Flexbox)
```html
Email
82%
Social
54%
Direct
31%
```
**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 `
${escapeHtml(d.label)}
${formatNumber(d.value)}
`;
}).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
│
▼
│
▼
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 (``) 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
{App Name}
Loading content, please wait…
📋
No data yet
Ask me a question in the chat to populate this view with data.
Updating…
```
---
## 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 = `
Ask me for a performance overview, revenue metrics, or a summary of recent activity.
```
### 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 = `
${escapeHtml(data.title || 'Results')}
${total} record${total !== 1 ? 's' : ''}
${columns.map(col => `
${escapeHtml(col.replace(/_/g, ' '))}
`).join('')}
${items.map((item, i) => `
${columns.map(col => {
const val = item[col];
if (col === 'status' || col === 'state') {
return `
Status: ${escapeHtml(String(val || '—'))}
`;
}
if (typeof val === 'number' && (col.includes('amount') || col.includes('revenue') || col.includes('price'))) {
return `
${formatCurrency(val)}
`;
}
if (typeof val === 'string' && val.match(/^\d{4}-\d{2}-\d{2}/)) {
return `
${formatDate(val)}
`;
}
return `
${escapeHtml(String(val ?? '—'))}
`;
}).join('')}
`).join('')}
`;
}
```
**Data Grid empty state customization:**
```html
📋
No records yet
Try "show me all active contacts" or "list recent invoices."
```
### 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 = `
Ask for performance trends, a revenue breakdown, or a comparison report.
```
### 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
```
```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 = `
`;
}).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
🔎
Ready to explore
Try "show me all contacts" or "list invoices from this month" to load data you can sort, filter, and explore.
```
---
## 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:
//
```
> **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 `Loading content, please wait…`
- 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 `