@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.
- package/package.json +26 -0
- package/src/bridge.ts +98 -0
- package/src/data-sniffer.ts +177 -0
- package/src/engine.ts +123 -0
- package/src/html-builder.ts +61 -0
- package/src/index.ts +20 -0
- package/src/playground.ts +187 -0
- package/src/renderers/composite.ts +73 -0
- package/src/renderers/data-grid.ts +159 -0
- package/src/renderers/form.ts +200 -0
- package/src/renderers/json-tree.ts +141 -0
- package/src/renderers/metrics-card.ts +108 -0
- package/src/renderers/reading-block.ts +114 -0
- package/src/theme.ts +184 -0
- package/src/types.ts +83 -0
- package/tsconfig.json +8 -0
|
@@ -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
|
+
}
|