@mcp-html-bridge/ui-engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ // ── Composite Renderer: orchestrates multiple renderers for mixed data ──
2
+ import { escapeHtml } from '../html-builder.js';
3
+ import { sniff } from '../data-sniffer.js';
4
+ import { renderDataGrid } from './data-grid.js';
5
+ import { renderJsonTree } from './json-tree.js';
6
+ import { renderReadingBlock } from './reading-block.js';
7
+ import { renderMetricsCard } from './metrics-card.js';
8
+ import type { RenderIntent } from '../types.js';
9
+
10
+ function humanizeKey(key: string): string {
11
+ return key
12
+ .replace(/([A-Z])/g, ' $1')
13
+ .replace(/[_-]/g, ' ')
14
+ .replace(/^\w/, (c) => c.toUpperCase())
15
+ .trim();
16
+ }
17
+
18
+ function renderByIntent(intent: RenderIntent, data: unknown, metadata: Record<string, unknown>): string {
19
+ switch (intent) {
20
+ case 'data-grid':
21
+ return renderDataGrid(data, metadata);
22
+ case 'metrics-card':
23
+ return renderMetricsCard(data, metadata);
24
+ case 'reading-block':
25
+ return renderReadingBlock(data, metadata);
26
+ case 'json-tree':
27
+ return renderJsonTree(data, metadata);
28
+ default:
29
+ return renderJsonTree(data, metadata);
30
+ }
31
+ }
32
+
33
+ export function renderComposite(
34
+ data: unknown,
35
+ _metadata: Record<string, unknown>
36
+ ): string {
37
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
38
+ // For arrays or primitives, just sniff and render directly
39
+ const results = sniff(data);
40
+ const best = results[0];
41
+ return renderByIntent(best.intent, data, best.metadata);
42
+ }
43
+
44
+ const obj = data as Record<string, unknown>;
45
+ const sections: string[] = [];
46
+
47
+ for (const [key, value] of Object.entries(obj)) {
48
+ const results = sniff(value);
49
+ const best = results[0];
50
+ const label = humanizeKey(key);
51
+
52
+ sections.push(`<section class="composite-section animate-in">
53
+ <h3 class="section-title">${escapeHtml(label)}</h3>
54
+ ${renderByIntent(best.intent, value, best.metadata)}
55
+ </section>`);
56
+ }
57
+
58
+ return `<div class="composite-layout">${sections.join('\n')}</div>`;
59
+ }
60
+
61
+ export function getCompositeCSS(): string {
62
+ return `
63
+ .composite-layout {
64
+ display: flex;
65
+ flex-direction: column;
66
+ gap: var(--sp-8);
67
+ }
68
+
69
+ .composite-section {
70
+ /* Each section self-contained */
71
+ }
72
+ `;
73
+ }
@@ -0,0 +1,159 @@
1
+ // ── Data Grid Renderer: Array of objects → sortable table ──
2
+ import { escapeHtml } from '../html-builder.js';
3
+
4
+ const STATUS_MAP: Record<string, string> = {
5
+ active: 'badge-success',
6
+ enabled: 'badge-success',
7
+ available: 'badge-success',
8
+ in_stock: 'badge-success',
9
+ completed: 'badge-success',
10
+ inactive: 'badge-danger',
11
+ disabled: 'badge-danger',
12
+ out_of_stock: 'badge-danger',
13
+ backorder: 'badge-warning',
14
+ pending: 'badge-warning',
15
+ processing: 'badge-warning',
16
+ low_stock: 'badge-warning',
17
+ info: 'badge-info',
18
+ draft: 'badge-info',
19
+ };
20
+
21
+ function formatCell(value: unknown, key: string): string {
22
+ if (value === null || value === undefined) return '<span class="null">—</span>';
23
+
24
+ const strVal = String(value);
25
+
26
+ // Status badge detection
27
+ const normalized = strVal.toLowerCase().replace(/[\s-]+/g, '_');
28
+ if (STATUS_MAP[normalized]) {
29
+ return `<span class="badge ${STATUS_MAP[normalized]}">${escapeHtml(strVal)}</span>`;
30
+ }
31
+
32
+ // Price formatting detection
33
+ if (typeof value === 'number' && /price|cost|amount|total|revenue/i.test(key)) {
34
+ return `<span class="price">$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>`;
35
+ }
36
+
37
+ // Number formatting
38
+ if (typeof value === 'number') {
39
+ return `<span class="num">${value.toLocaleString()}</span>`;
40
+ }
41
+
42
+ // Boolean
43
+ if (typeof value === 'boolean') {
44
+ return value ? '<span class="bool-true">✓</span>' : '<span class="bool-false">✗</span>';
45
+ }
46
+
47
+ // Nested object/array → compact JSON
48
+ if (typeof value === 'object') {
49
+ return `<code class="cell-json">${escapeHtml(JSON.stringify(value))}</code>`;
50
+ }
51
+
52
+ return escapeHtml(strVal);
53
+ }
54
+
55
+ function humanizeKey(key: string): string {
56
+ return key
57
+ .replace(/([A-Z])/g, ' $1')
58
+ .replace(/[_-]/g, ' ')
59
+ .replace(/^\w/, (c) => c.toUpperCase())
60
+ .trim();
61
+ }
62
+
63
+ export function renderDataGrid(
64
+ data: unknown,
65
+ metadata: Record<string, unknown>
66
+ ): string {
67
+ const rows = data as Record<string, unknown>[];
68
+ const columns = (metadata['columns'] as string[]) ?? Object.keys(rows[0] ?? {});
69
+
70
+ const headerCells = columns
71
+ .map(
72
+ (col, i) =>
73
+ `<th onclick="__mcpSort(${i})" class="sortable">${escapeHtml(humanizeKey(col))}<span class="sort-icon">⇅</span></th>`
74
+ )
75
+ .join('');
76
+
77
+ const bodyRows = rows
78
+ .map(
79
+ (row, ri) =>
80
+ `<tr class="animate-in" style="animation-delay:${Math.min(ri * 20, 300)}ms">${columns.map((col) => `<td>${formatCell(row[col], col)}</td>`).join('')}</tr>`
81
+ )
82
+ .join('\n');
83
+
84
+ const rowCount = rows.length;
85
+ const colCount = columns.length;
86
+
87
+ return `<div class="grid-container card">
88
+ <div class="grid-header">
89
+ <span class="grid-meta">${rowCount} rows × ${colCount} columns</span>
90
+ </div>
91
+ <div class="table-wrap">
92
+ <table class="data-table" id="mcp-grid">
93
+ <thead><tr>${headerCells}</tr></thead>
94
+ <tbody>${bodyRows}</tbody>
95
+ </table>
96
+ </div>
97
+ </div>`;
98
+ }
99
+
100
+ export function getDataGridCSS(): string {
101
+ return `
102
+ .grid-container { overflow: hidden; }
103
+ .grid-header {
104
+ display: flex; justify-content: space-between; align-items: center;
105
+ padding-bottom: var(--sp-3); margin-bottom: var(--sp-3); border-bottom: 1px solid var(--border);
106
+ }
107
+ .grid-meta { font-size: var(--text-xs); color: var(--text-tertiary); }
108
+ .table-wrap { overflow-x: auto; }
109
+ .data-table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
110
+ .data-table th {
111
+ text-align: left; padding: var(--sp-2) var(--sp-3);
112
+ font-weight: 600; color: var(--text-secondary); font-size: var(--text-xs);
113
+ text-transform: uppercase; letter-spacing: 0.05em;
114
+ border-bottom: 2px solid var(--border);
115
+ white-space: nowrap; user-select: none;
116
+ }
117
+ .sortable { cursor: pointer; }
118
+ .sortable:hover { color: var(--accent); }
119
+ .sort-icon { margin-left: 4px; opacity: 0.3; font-size: 10px; }
120
+ .data-table td {
121
+ padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border);
122
+ vertical-align: middle;
123
+ }
124
+ .data-table tbody tr:hover { background: var(--accent-subtle); }
125
+ .data-table tbody tr:nth-child(even) { background: var(--bg-secondary); }
126
+ .data-table tbody tr:nth-child(even):hover { background: var(--accent-subtle); }
127
+
128
+ .null { color: var(--text-tertiary); }
129
+ .price { font-weight: 600; font-variant-numeric: tabular-nums; }
130
+ .num { font-variant-numeric: tabular-nums; }
131
+ .bool-true { color: var(--success); font-weight: 700; }
132
+ .bool-false { color: var(--danger); font-weight: 700; }
133
+ .cell-json { font-size: var(--text-xs); max-width: 200px; overflow: hidden; text-overflow: ellipsis; display: inline-block; }
134
+ `;
135
+ }
136
+
137
+ export function getDataGridJS(): string {
138
+ return `
139
+ function __mcpSort(colIdx) {
140
+ var table = document.getElementById('mcp-grid');
141
+ if (!table) return;
142
+ var tbody = table.tBodies[0];
143
+ var rows = Array.from(tbody.rows);
144
+ var dir = table.dataset.sortDir === 'asc' ? 'desc' : 'asc';
145
+ table.dataset.sortDir = dir;
146
+ table.dataset.sortCol = colIdx;
147
+
148
+ rows.sort(function(a, b) {
149
+ var av = a.cells[colIdx].textContent.trim();
150
+ var bv = b.cells[colIdx].textContent.trim();
151
+ var an = parseFloat(av.replace(/[^\\d.-]/g, ''));
152
+ var bn = parseFloat(bv.replace(/[^\\d.-]/g, ''));
153
+ if (!isNaN(an) && !isNaN(bn)) return dir === 'asc' ? an - bn : bn - an;
154
+ return dir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
155
+ });
156
+
157
+ rows.forEach(function(row) { tbody.appendChild(row); });
158
+ }`;
159
+ }
@@ -0,0 +1,200 @@
1
+ // ── Form Renderer: JSON Schema → interactive form ──
2
+ import type { JSONSchema } from '../types.js';
3
+ import { escapeHtml } from '../html-builder.js';
4
+
5
+ function renderField(name: string, schema: JSONSchema, required: boolean): string {
6
+ const label = schema.title ?? name;
7
+ const desc = schema.description ? `<div class="field-desc">${escapeHtml(schema.description)}</div>` : '';
8
+ const requiredAttr = required ? 'required' : '';
9
+ const requiredMark = required ? '<span class="required">*</span>' : '';
10
+
11
+ // Enum → capsule select
12
+ if (schema.enum && schema.enum.length > 0) {
13
+ const capsules = schema.enum
14
+ .map((v) => {
15
+ const val = escapeHtml(String(v));
16
+ return `<label class="capsule"><input type="radio" name="${escapeHtml(name)}" value="${val}"><span>${val}</span></label>`;
17
+ })
18
+ .join('\n');
19
+ return `<div class="field animate-in">
20
+ <label class="field-label">${escapeHtml(label)}${requiredMark}</label>
21
+ ${desc}
22
+ <div class="capsule-group">${capsules}</div>
23
+ </div>`;
24
+ }
25
+
26
+ // Boolean → toggle
27
+ if (schema.type === 'boolean') {
28
+ const checked = schema.default === true ? 'checked' : '';
29
+ return `<div class="field animate-in">
30
+ <label class="toggle-label">
31
+ <input type="checkbox" name="${escapeHtml(name)}" ${checked}>
32
+ <span class="toggle-switch"></span>
33
+ <span>${escapeHtml(label)}${requiredMark}</span>
34
+ </label>
35
+ ${desc}
36
+ </div>`;
37
+ }
38
+
39
+ // Nested object → collapsible section
40
+ if (schema.type === 'object' && schema.properties) {
41
+ const inner = renderProperties(schema.properties, schema.required ?? []);
42
+ return `<details class="nested-section animate-in" open>
43
+ <summary class="section-title">${escapeHtml(label)}${requiredMark}</summary>
44
+ ${desc}
45
+ <div class="nested-fields">${inner}</div>
46
+ </details>`;
47
+ }
48
+
49
+ // Array → textarea hint
50
+ if (schema.type === 'array') {
51
+ return `<div class="field animate-in">
52
+ <label class="field-label" for="f-${escapeHtml(name)}">${escapeHtml(label)}${requiredMark}</label>
53
+ ${desc}
54
+ <textarea id="f-${escapeHtml(name)}" name="${escapeHtml(name)}" class="input textarea" placeholder="JSON array..." ${requiredAttr}>${escapeHtml(String(schema.default ?? ''))}</textarea>
55
+ </div>`;
56
+ }
57
+
58
+ // Number/integer
59
+ if (schema.type === 'number' || schema.type === 'integer') {
60
+ const min = schema.minimum !== undefined ? `min="${schema.minimum}"` : '';
61
+ const max = schema.maximum !== undefined ? `max="${schema.maximum}"` : '';
62
+ const step = schema.type === 'integer' ? 'step="1"' : '';
63
+ const def = schema.default !== undefined ? `value="${escapeHtml(String(schema.default))}"` : '';
64
+ return `<div class="field animate-in">
65
+ <label class="field-label" for="f-${escapeHtml(name)}">${escapeHtml(label)}${requiredMark}</label>
66
+ ${desc}
67
+ <input type="number" id="f-${escapeHtml(name)}" name="${escapeHtml(name)}" class="input" ${min} ${max} ${step} ${def} ${requiredAttr}>
68
+ </div>`;
69
+ }
70
+
71
+ // String (default)
72
+ const inputType = schema.format === 'email' ? 'email'
73
+ : schema.format === 'uri' ? 'url'
74
+ : schema.format === 'date' ? 'date'
75
+ : schema.format === 'date-time' ? 'datetime-local'
76
+ : schema.format === 'password' ? 'password'
77
+ : 'text';
78
+
79
+ const isLong = (schema.maxLength && schema.maxLength > 200) || schema.format === 'textarea';
80
+ const def = schema.default !== undefined ? escapeHtml(String(schema.default)) : '';
81
+
82
+ if (isLong) {
83
+ return `<div class="field animate-in">
84
+ <label class="field-label" for="f-${escapeHtml(name)}">${escapeHtml(label)}${requiredMark}</label>
85
+ ${desc}
86
+ <textarea id="f-${escapeHtml(name)}" name="${escapeHtml(name)}" class="input textarea" ${requiredAttr}>${def}</textarea>
87
+ </div>`;
88
+ }
89
+
90
+ return `<div class="field animate-in">
91
+ <label class="field-label" for="f-${escapeHtml(name)}">${escapeHtml(label)}${requiredMark}</label>
92
+ ${desc}
93
+ <input type="${inputType}" id="f-${escapeHtml(name)}" name="${escapeHtml(name)}" class="input" value="${def}" ${requiredAttr}>
94
+ </div>`;
95
+ }
96
+
97
+ function renderProperties(
98
+ properties: Record<string, JSONSchema>,
99
+ required: string[]
100
+ ): string {
101
+ const reqSet = new Set(required);
102
+ return Object.entries(properties)
103
+ .map(([name, schema]) => renderField(name, schema, reqSet.has(name)))
104
+ .join('\n');
105
+ }
106
+
107
+ export function renderForm(
108
+ schema: JSONSchema,
109
+ metadata: Record<string, unknown>
110
+ ): string {
111
+ const toolName = metadata['toolName'] as string | undefined;
112
+ const toolDesc = metadata['toolDescription'] as string | undefined;
113
+
114
+ const header = toolName
115
+ ? `<div class="form-header"><h2>${escapeHtml(toolName)}</h2>${toolDesc ? `<p class="form-desc">${escapeHtml(toolDesc)}</p>` : ''}</div>`
116
+ : '';
117
+
118
+ const fields = schema.properties
119
+ ? renderProperties(schema.properties, schema.required ?? [])
120
+ : '<p class="text-secondary">No input parameters required.</p>';
121
+
122
+ return `${header}
123
+ <form id="mcp-form" class="mcp-form card" onsubmit="return __mcpSubmit(event)">
124
+ ${fields}
125
+ <div class="form-actions">
126
+ <button type="submit" class="btn btn-primary">Execute Tool</button>
127
+ <button type="reset" class="btn btn-ghost">Reset</button>
128
+ </div>
129
+ </form>`;
130
+ }
131
+
132
+ export function getFormCSS(): string {
133
+ return `
134
+ .mcp-form { display: flex; flex-direction: column; gap: var(--sp-5); max-width: 640px; }
135
+ .form-header h2 { font-size: var(--text-2xl); font-weight: 700; }
136
+ .form-desc { color: var(--text-secondary); margin-top: var(--sp-1); }
137
+
138
+ .field { display: flex; flex-direction: column; gap: var(--sp-1); }
139
+ .field-label { font-weight: 600; font-size: var(--text-sm); color: var(--text-primary); }
140
+ .field-desc { font-size: var(--text-xs); color: var(--text-tertiary); }
141
+ .required { color: var(--danger); margin-left: 2px; }
142
+
143
+ .input {
144
+ padding: var(--sp-2) var(--sp-3);
145
+ border: 1px solid var(--border);
146
+ border-radius: var(--radius-sm);
147
+ font-size: var(--text-sm);
148
+ font-family: var(--font-sans);
149
+ background: var(--bg-primary);
150
+ color: var(--text-primary);
151
+ transition: border-color var(--duration-fast) var(--ease-out),
152
+ box-shadow var(--duration-fast) var(--ease-out);
153
+ }
154
+ .input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-subtle); }
155
+ .textarea { min-height: 80px; resize: vertical; }
156
+
157
+ .capsule-group { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
158
+ .capsule input { display: none; }
159
+ .capsule span {
160
+ display: inline-block;
161
+ padding: var(--sp-1) var(--sp-3);
162
+ border: 1px solid var(--border);
163
+ border-radius: var(--radius-full);
164
+ font-size: var(--text-sm);
165
+ cursor: pointer;
166
+ transition: all var(--duration-fast) var(--ease-out);
167
+ }
168
+ .capsule input:checked + span { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
169
+ .capsule span:hover { border-color: var(--accent); }
170
+
171
+ .toggle-label { display: flex; align-items: center; gap: var(--sp-2); cursor: pointer; font-weight: 600; font-size: var(--text-sm); }
172
+ .toggle-label input { display: none; }
173
+ .toggle-switch {
174
+ width: 36px; height: 20px; background: var(--border-strong); border-radius: 10px;
175
+ position: relative; transition: background var(--duration-fast) var(--ease-out);
176
+ }
177
+ .toggle-switch::after {
178
+ content: ''; position: absolute; top: 2px; left: 2px;
179
+ width: 16px; height: 16px; background: white; border-radius: 50%;
180
+ transition: transform var(--duration-fast) var(--ease-out);
181
+ }
182
+ .toggle-label input:checked + .toggle-switch { background: var(--accent); }
183
+ .toggle-label input:checked + .toggle-switch::after { transform: translateX(16px); }
184
+
185
+ .nested-section { border: 1px solid var(--border); border-radius: var(--radius-md); padding: var(--sp-4); }
186
+ .nested-section summary { cursor: pointer; user-select: none; }
187
+ .nested-fields { margin-top: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-4); }
188
+
189
+ .form-actions { display: flex; gap: var(--sp-3); margin-top: var(--sp-2); }
190
+ .btn {
191
+ padding: var(--sp-2) var(--sp-5); border-radius: var(--radius-sm);
192
+ font-weight: 600; font-size: var(--text-sm); cursor: pointer; border: none;
193
+ transition: all var(--duration-fast) var(--ease-out);
194
+ }
195
+ .btn-primary { background: var(--accent); color: var(--accent-text); }
196
+ .btn-primary:hover { background: var(--accent-hover); }
197
+ .btn-ghost { background: transparent; color: var(--text-secondary); border: 1px solid var(--border); }
198
+ .btn-ghost:hover { background: var(--bg-tertiary); }
199
+ `;
200
+ }
@@ -0,0 +1,141 @@
1
+ // ── JSON Tree Renderer: recursive collapsible tree ──
2
+ import { escapeHtml } from '../html-builder.js';
3
+
4
+ function typeClass(val: unknown): string {
5
+ if (val === null) return 'jt-null';
6
+ if (typeof val === 'string') return 'jt-string';
7
+ if (typeof val === 'number') return 'jt-number';
8
+ if (typeof val === 'boolean') return 'jt-boolean';
9
+ return '';
10
+ }
11
+
12
+ function formatValue(val: unknown): string {
13
+ if (val === null) return '<span class="jt-null">null</span>';
14
+ if (typeof val === 'string') return `<span class="jt-string">"${escapeHtml(val)}"</span>`;
15
+ if (typeof val === 'number') return `<span class="jt-number">${val}</span>`;
16
+ if (typeof val === 'boolean') return `<span class="jt-boolean">${val}</span>`;
17
+ return escapeHtml(String(val));
18
+ }
19
+
20
+ function renderNode(key: string | null, val: unknown, depth: number): string {
21
+ const keyHtml = key !== null ? `<span class="jt-key">${escapeHtml(key)}</span><span class="jt-colon">: </span>` : '';
22
+ const indent = depth * 16;
23
+ const nodeId = `jt-${Math.random().toString(36).slice(2, 8)}`;
24
+
25
+ if (val === null || typeof val !== 'object') {
26
+ return `<div class="jt-line" style="padding-left:${indent}px">
27
+ ${keyHtml}${formatValue(val)}
28
+ <button class="jt-copy" onclick="__mcpCopy(this)" data-val="${escapeHtml(JSON.stringify(val))}" title="Copy">⎘</button>
29
+ </div>`;
30
+ }
31
+
32
+ const isArray = Array.isArray(val);
33
+ const entries = isArray
34
+ ? (val as unknown[]).map((v, i) => [String(i), v] as const)
35
+ : Object.entries(val as Record<string, unknown>);
36
+
37
+ const bracket = isArray ? ['[', ']'] : ['{', '}'];
38
+ const count = entries.length;
39
+ const preview = count === 0 ? (isArray ? '[]' : '{}') : '';
40
+
41
+ if (count === 0) {
42
+ return `<div class="jt-line" style="padding-left:${indent}px">${keyHtml}<span class="jt-bracket">${preview}</span></div>`;
43
+ }
44
+
45
+ const children = entries.map(([k, v]) => renderNode(isArray ? null : k, v, depth + 1)).join('\n');
46
+
47
+ return `<div class="jt-node" style="padding-left:${indent}px">
48
+ <div class="jt-toggle" onclick="__mcpToggle('${nodeId}')">
49
+ <span class="jt-arrow" id="${nodeId}-arrow">▼</span>
50
+ ${keyHtml}<span class="jt-bracket">${bracket[0]}</span>
51
+ <span class="jt-count">${count} ${isArray ? 'items' : 'keys'}</span>
52
+ <button class="jt-copy" onclick="event.stopPropagation();__mcpCopy(this)" data-val="${escapeHtml(JSON.stringify(val))}" title="Copy">⎘</button>
53
+ </div>
54
+ <div class="jt-children" id="${nodeId}">${children}</div>
55
+ <div style="padding-left:0"><span class="jt-bracket">${bracket[1]}</span></div>
56
+ </div>`;
57
+ }
58
+
59
+ export function renderJsonTree(
60
+ data: unknown,
61
+ _metadata: Record<string, unknown>
62
+ ): string {
63
+ return `<div class="json-tree card">
64
+ <div class="jt-toolbar">
65
+ <button class="btn btn-ghost btn-sm" onclick="__mcpExpandAll()">Expand All</button>
66
+ <button class="btn btn-ghost btn-sm" onclick="__mcpCollapseAll()">Collapse All</button>
67
+ <button class="btn btn-ghost btn-sm" onclick="__mcpCopyAll()">Copy JSON</button>
68
+ </div>
69
+ <div class="jt-root">${renderNode(null, data, 0)}</div>
70
+ </div>`;
71
+ }
72
+
73
+ export function getJsonTreeCSS(): string {
74
+ return `
75
+ .json-tree { font-family: var(--font-mono); font-size: var(--text-sm); overflow-x: auto; }
76
+ .jt-toolbar { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-3); padding-bottom: var(--sp-3); border-bottom: 1px solid var(--border); }
77
+ .btn-sm { padding: var(--sp-1) var(--sp-2); font-size: var(--text-xs); }
78
+
79
+ .jt-line, .jt-node > .jt-toggle { padding: 2px 0; display: flex; align-items: center; gap: 4px; }
80
+ .jt-toggle { cursor: pointer; user-select: none; }
81
+ .jt-toggle:hover { background: var(--accent-subtle); border-radius: var(--radius-sm); }
82
+ .jt-arrow { font-size: 10px; width: 14px; text-align: center; transition: transform var(--duration-fast) var(--ease-out); display: inline-block; }
83
+ .jt-arrow.collapsed { transform: rotate(-90deg); }
84
+
85
+ .jt-key { color: var(--accent); font-weight: 600; }
86
+ .jt-colon { color: var(--text-tertiary); }
87
+ .jt-string { color: #22863a; }
88
+ .jt-number { color: #005cc5; }
89
+ .jt-boolean { color: #d73a49; }
90
+ .jt-null { color: var(--text-tertiary); font-style: italic; }
91
+ .jt-bracket { color: var(--text-tertiary); font-weight: 700; }
92
+ .jt-count { color: var(--text-tertiary); font-size: var(--text-xs); margin-left: var(--sp-1); }
93
+
94
+ @media (prefers-color-scheme: dark) {
95
+ .jt-string { color: #7ee787; }
96
+ .jt-number { color: #79c0ff; }
97
+ .jt-boolean { color: #ff7b72; }
98
+ }
99
+
100
+ .jt-children { overflow: hidden; }
101
+ .jt-children.hidden { display: none; }
102
+ .jt-copy {
103
+ opacity: 0; border: none; background: none; cursor: pointer;
104
+ color: var(--text-tertiary); font-size: 14px; padding: 0 4px;
105
+ transition: opacity var(--duration-fast) var(--ease-out);
106
+ }
107
+ .jt-line:hover .jt-copy, .jt-toggle:hover .jt-copy { opacity: 1; }
108
+ .jt-copy:hover { color: var(--accent); }
109
+ `;
110
+ }
111
+
112
+ export function getJsonTreeJS(): string {
113
+ return `
114
+ var __mcpTreeData;
115
+ function __mcpToggle(id) {
116
+ var el = document.getElementById(id);
117
+ var arrow = document.getElementById(id + '-arrow');
118
+ if (!el) return;
119
+ el.classList.toggle('hidden');
120
+ if (arrow) arrow.classList.toggle('collapsed');
121
+ }
122
+ function __mcpExpandAll() {
123
+ document.querySelectorAll('.jt-children').forEach(function(el) { el.classList.remove('hidden'); });
124
+ document.querySelectorAll('.jt-arrow').forEach(function(el) { el.classList.remove('collapsed'); });
125
+ }
126
+ function __mcpCollapseAll() {
127
+ document.querySelectorAll('.jt-children').forEach(function(el) { el.classList.add('hidden'); });
128
+ document.querySelectorAll('.jt-arrow').forEach(function(el) { el.classList.add('collapsed'); });
129
+ }
130
+ function __mcpCopy(btn) {
131
+ var val = btn.getAttribute('data-val');
132
+ navigator.clipboard.writeText(val).then(function() {
133
+ var orig = btn.textContent;
134
+ btn.textContent = '✓';
135
+ setTimeout(function() { btn.textContent = orig; }, 1000);
136
+ });
137
+ }
138
+ function __mcpCopyAll() {
139
+ if (__mcpTreeData) navigator.clipboard.writeText(JSON.stringify(__mcpTreeData, null, 2));
140
+ }`;
141
+ }
@@ -0,0 +1,108 @@
1
+ // ── Metrics Card Renderer: KPI/summary → card layout ──
2
+ import { escapeHtml } from '../html-builder.js';
3
+
4
+ function formatMetricValue(value: number, key: string): string {
5
+ // Percentage
6
+ if (/rate|ratio|percent|pct/i.test(key)) {
7
+ return `${(value * (value <= 1 ? 100 : 1)).toFixed(1)}%`;
8
+ }
9
+ // Currency
10
+ if (/price|cost|revenue|amount|total|value/i.test(key)) {
11
+ if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
12
+ if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}K`;
13
+ return `$${value.toFixed(2)}`;
14
+ }
15
+ // Large numbers
16
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
17
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
18
+ // Scientific notation for very small/large
19
+ if (Math.abs(value) < 0.001 && value !== 0) return value.toExponential(2);
20
+
21
+ return Number.isInteger(value) ? value.toLocaleString() : value.toFixed(2);
22
+ }
23
+
24
+ function humanizeKey(key: string): string {
25
+ return key
26
+ .replace(/([A-Z])/g, ' $1')
27
+ .replace(/[_-]/g, ' ')
28
+ .replace(/^\w/, (c) => c.toUpperCase())
29
+ .trim();
30
+ }
31
+
32
+ export function renderMetricsCard(
33
+ data: unknown,
34
+ _metadata: Record<string, unknown>
35
+ ): string {
36
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
37
+ return '<div class="card">Invalid metrics data</div>';
38
+ }
39
+
40
+ const obj = data as Record<string, unknown>;
41
+ const cards: string[] = [];
42
+
43
+ for (const [key, value] of Object.entries(obj)) {
44
+ if (typeof value === 'number') {
45
+ const formatted = formatMetricValue(value, key);
46
+ const label = humanizeKey(key);
47
+ cards.push(`<div class="metric-card animate-in">
48
+ <div class="metric-label">${escapeHtml(label)}</div>
49
+ <div class="metric-value">${escapeHtml(formatted)}</div>
50
+ </div>`);
51
+ } else if (typeof value === 'string') {
52
+ cards.push(`<div class="metric-card metric-text animate-in">
53
+ <div class="metric-label">${escapeHtml(humanizeKey(key))}</div>
54
+ <div class="metric-text-value">${escapeHtml(value)}</div>
55
+ </div>`);
56
+ }
57
+ }
58
+
59
+ return `<div class="metrics-grid">${cards.join('\n')}</div>`;
60
+ }
61
+
62
+ export function getMetricsCardCSS(): string {
63
+ return `
64
+ .metrics-grid {
65
+ display: grid;
66
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
67
+ gap: var(--sp-4);
68
+ }
69
+
70
+ .metric-card {
71
+ background: var(--bg-elevated);
72
+ border: 1px solid var(--border);
73
+ border-radius: var(--radius-md);
74
+ padding: var(--sp-5);
75
+ display: flex;
76
+ flex-direction: column;
77
+ gap: var(--sp-2);
78
+ transition: box-shadow var(--duration-fast) var(--ease-out),
79
+ transform var(--duration-fast) var(--ease-out);
80
+ }
81
+ .metric-card:hover {
82
+ box-shadow: var(--shadow-md);
83
+ transform: translateY(-2px);
84
+ }
85
+
86
+ .metric-label {
87
+ font-size: var(--text-xs);
88
+ font-weight: 600;
89
+ text-transform: uppercase;
90
+ letter-spacing: 0.05em;
91
+ color: var(--text-tertiary);
92
+ }
93
+
94
+ .metric-value {
95
+ font-size: var(--text-3xl);
96
+ font-weight: 800;
97
+ color: var(--text-primary);
98
+ font-variant-numeric: tabular-nums;
99
+ letter-spacing: -0.02em;
100
+ }
101
+
102
+ .metric-text-value {
103
+ font-size: var(--text-base);
104
+ font-weight: 500;
105
+ color: var(--text-secondary);
106
+ }
107
+ `;
108
+ }