# 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 72% ``` **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…
``` --- ## 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 = `
${escapeHtml(data.title || '{Service} Dashboard')}
${escapeHtml(data.timeFrame || 'Last 30 days')}
${Object.entries(metrics).map(([key, val]) => `
${escapeHtml(key.replace(/_/g, ' '))}
${typeof val === 'number' && key.includes('revenue') ? formatCurrency(val) : formatNumber(val)}
`).join('')}
${recentItems.length > 0 ? `
Recent Activity
${recentItems.slice(0, 10).map((item, i) => `
${escapeHtml(item.title || item.name || '—')}
${escapeHtml(item.description || item.type || '')}
${formatDateTime(item.date || item.createdAt)}
`).join('')}
` : ''} `; // Animate metric numbers el.querySelectorAll('.metric-value[data-count]').forEach(el => { const target = parseFloat(el.dataset.count); if (!isNaN(target)) { const isCurrency = el.textContent.startsWith('$'); animateCount(el, target, 600, isCurrency ? formatCurrency : formatNumber); } }); } ``` **Dashboard empty state customization:** ```html ``` ### 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 => ``).join('')} ${items.map((item, i) => ` ${columns.map(col => { const val = item[col]; if (col === 'status' || col === 'state') { return ``; } if (typeof val === 'number' && (col.includes('amount') || col.includes('revenue') || col.includes('price'))) { return ``; } if (typeof val === 'string' && val.match(/^\d{4}-\d{2}-\d{2}/)) { return ``; } return ``; }).join('')} `).join('')}
${escapeHtml(col.replace(/_/g, ' '))}
Status: ${escapeHtml(String(val || '—'))}${formatCurrency(val)}${formatDate(val)}${escapeHtml(String(val ?? '—'))}
`; } ``` **Data Grid empty state customization:** ```html ``` ### 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 = `
${escapeHtml(item.name || item.title || 'Details')}
${escapeHtml(item.email || item.type || item.status || '')}
${item.status ? `Status: ${escapeHtml(item.status)}` : ''}
${fields.map(([key, val], i) => { if (val == null || val === '') return ''; if (typeof val === 'object') val = JSON.stringify(val); return `
${escapeHtml(key.replace(/_/g, ' '))} ${escapeHtml(String(val))}
`; }).join('')}
`; } ``` **Detail Card empty state customization:** ```html ``` ### 6.4 Form / Wizard **Use when:** Multi-step creation or edit flows. **Expected data shape:** `{ title?, description?, fields: { name, label?, type?, required?, placeholder?, options?: {value, label}[] }[] }` **Empty state:** "Tell me what you'd like to create and I'll set up the form." ```javascript function render(data) { showState('data'); const el = document.getElementById('content'); // Validate validateData(data, ['fields']); const fields = data.fields || []; const title = data.title || 'Create New'; el.innerHTML = `
${escapeHtml(title)}
${escapeHtml(data.description || 'Fill in the details below')}
${fields.map((field, i) => `
${field.type === 'select' ? ` ` : field.type === 'textarea' ? ` ` : ` `}
`).join('')}
`; } // Form submit handler — collects values, validates required fields, sends to host function submitForm() { const form = document.getElementById('appForm'); if (!form) return; const formData = {}; const fields = form.querySelectorAll('input, select, textarea'); // Reset field borders fields.forEach(f => { f.style.borderColor = '#3a3c41'; }); // Collect values fields.forEach(field => { if (field.name) formData[field.name] = field.value; }); // Validate required fields const missing = [...fields].filter(f => f.required && !f.value); if (missing.length > 0) { missing.forEach(f => { f.style.borderColor = '#f04747'; }); missing[0].focus(); return; } // Send to host for tool execution sendToHost('tool_call', { tool: 'create_' + APP_ID.split('-').pop(), args: formData }); // Show confirmation state showState('empty'); document.querySelector('#empty .empty-state-icon').textContent = '✅'; document.querySelector('#empty .empty-state-title').textContent = 'Submitted!'; document.querySelector('#empty .empty-state-text').textContent = 'Your request has been sent. Check the chat for confirmation.'; } ``` **Form empty state customization:** ```html ``` ### 6.5 Timeline **Use when:** Chronological events, activity feeds, audit logs. **Expected data shape:** `{ title?, events|activities|timeline: { title, description?, date|timestamp, user|actor? }[] }` **Empty state:** "Ask to see recent activity, event history, or an audit log." ```javascript function render(data) { showState('data'); const el = document.getElementById('content'); const events = Array.isArray(data) ? data : (data.events || data.activities || data.timeline || []); // Validate if (events.length > 0) validateData(events[0], ['title']); el.innerHTML = `
${escapeHtml(data.title || 'Activity Timeline')}
${events.length} event${events.length !== 1 ? 's' : ''}
${events.map((event, i) => `
${escapeHtml(event.title || event.type || event.action || '—')}
${escapeHtml(event.description || event.details || '')}
${formatDateTime(event.date || event.timestamp || event.createdAt)}
${event.user || event.actor ? `
by ${escapeHtml(event.user || event.actor)}
` : ''}
`).join('')}
`; } ``` **Timeline empty state customization:** ```html ``` ### 6.6 Funnel / Pipeline **Use when:** Stage-based progression (sales pipeline, deal stages). **Expected data shape:** `{ title?, stages|pipeline: { name|title, items|deals: { name|title, value|amount?, contact|company? }[] }[] }` **Empty state:** "Ask to see your sales pipeline or a specific deal stage." ```javascript function render(data) { showState('data'); const el = document.getElementById('content'); const stages = Array.isArray(data) ? data : (data.stages || data.pipeline || []); // Validate if (stages.length > 0) validateData(stages[0], ['name']); el.innerHTML = `
${escapeHtml(data.title || 'Pipeline')}
${escapeHtml(data.subtitle || '')}
${stages.map((stage, i) => { const items = stage.items || stage.deals || stage.opportunities || []; return `
${escapeHtml(stage.name || stage.title)} ${items.length}
${items.map((item, j) => `
${escapeHtml(item.name || item.title)}
${item.value || item.amount ? `
${formatCurrency(item.value || item.amount)}
` : ''} ${item.contact || item.company ? `
${escapeHtml(item.contact || item.company)}
` : ''}
`).join('')} ${items.length === 0 ? '
No items
' : ''}
`; }).join('')}
`; } ``` **Pipeline empty state customization:** ```html ``` ### 6.7 Calendar **Use when:** Date-based data (appointments, events, schedules). **Expected data shape:** `{ title?, events|appointments: { title|name, date|start|startTime, description?, location?, attendee|contact?, status? }[] }` **Empty state:** "Ask to see upcoming appointments, scheduled events, or your calendar." ```javascript function render(data) { showState('data'); const el = document.getElementById('content'); const events = Array.isArray(data) ? data : (data.events || data.appointments || []); const today = new Date(); // Validate if (events.length > 0) validateData(events[0], ['title']); // Group events by date const byDate = {}; events.forEach(evt => { const dateStr = new Date(evt.date || evt.start || evt.startTime).toISOString().split('T')[0]; if (!byDate[dateStr]) byDate[dateStr] = []; byDate[dateStr].push(evt); }); const sortedDates = Object.keys(byDate).sort(); el.innerHTML = `
${escapeHtml(data.title || 'Calendar')}
${events.length} event${events.length !== 1 ? 's' : ''}
${sortedDates.map(dateStr => { const d = new Date(dateStr + 'T12:00:00'); const isToday = dateStr === today.toISOString().split('T')[0]; return `
${isToday ? '📍 Today — ' : ''}${d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
${byDate[dateStr].map((evt, i) => `
${formatTime(evt.start || evt.startTime || evt.date)}
${escapeHtml(evt.title || evt.name || '—')}
${evt.description || evt.location ? `
${escapeHtml(evt.description || evt.location || '')}
` : ''} ${evt.attendee || evt.contact ? `
👤 ${escapeHtml(evt.attendee || evt.contact)}
` : ''}
${evt.status ? `Status: ${escapeHtml(evt.status)}` : ''}
`).join('')}
`; }).join('')}
`; } function formatTime(dateStr) { if (!dateStr) return ''; try { return new Date(dateStr).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); } catch { return ''; } } ``` **Calendar empty state customization:** ```html ``` ### 6.8 Analytics / Chart **Use when:** Data visualization, trends, comparisons. Pure CSS charts (no external libs). **Expected data shape:** `{ title?, subtitle|timeFrame?, metrics?: { [key]: number }, chart|series: { label|name, value|count }[], chartTitle? }` **Empty state:** "Ask for analytics, performance trends, or a breakdown of your data." ```javascript function render(data) { showState('data'); const el = document.getElementById('content'); // Validate validateData(data, ['chart']); const chartData = data.chart || data.series || []; const maxVal = Math.max(...chartData.map(d => d.value || d.count || 0), 1); el.innerHTML = `
${escapeHtml(data.title || 'Analytics')}
${escapeHtml(data.subtitle || data.timeFrame || '')}
${data.metrics ? `
${Object.entries(data.metrics).map(([key, val]) => `
${escapeHtml(key.replace(/_/g, ' '))}
${formatNumber(val)}
`).join('')}
` : ''}
${escapeHtml(data.chartTitle || 'Overview')}
${chartData.map((d, i) => { const pct = ((d.value || d.count || 0) / maxVal) * 100; return `
${formatNumber(d.value || d.count)}
${escapeHtml(d.label || d.name || '')}
`; }).join('')}
`; // Animate metric numbers el.querySelectorAll('.metric-value[data-count]').forEach(el => { const target = parseFloat(el.dataset.count); if (!isNaN(target)) animateCount(el, target); }); } ``` **Analytics empty state customization:** ```html ``` ### 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 = `
${escapeHtml(data.title || 'Data Explorer')}
${total} record${total !== 1 ? 's' : ''}
${columnConfig.map(col => ` `).join('')}
${escapeHtml(col.label)} Expand
`; // 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 ` ${cols.map(col => { const val = item[col.key]; let cellContent; if (col.key === 'status' || col.key === 'state') { cellContent = `Status: ${escapeHtml(String(val || '—'))}`; } else if (col.copyable) { cellContent = `${escapeHtml(String(val ?? '—'))}`; } 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 `${cellContent}`; }).join('')}
${Object.entries(item).filter(([k]) => !k.startsWith('_')).map(([k, v]) => `
${escapeHtml(k.replace(/_/g, ' '))}
${escapeHtml(String(v ?? '—'))}
`).join('')}
`; }).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 ``` --- ## 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: // ${escapeHtml(item.name)} ``` > **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 `