@mcp-html-bridge/ui-engine 0.4.0 → 0.5.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcp-html-bridge/ui-engine",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Core UI rendering engine — schema/data → self-contained HTML",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/engine.ts CHANGED
@@ -1,26 +1,27 @@
1
1
  // ── Engine Orchestrator: JSON → self-contained HTML document ──
2
2
  import type { EngineInput, RenderOptions, JSONSchema } from './types.js';
3
+ import type { LLMConfig } from './llm-renderer.js';
3
4
  import { document as htmlDocument } from './html-builder.js';
4
5
  import { generateThemeCSS } from './theme.js';
5
6
  import { generateBridgeJS } from './bridge.js';
6
7
  import { renderJSON, getRendererCSS, getRendererJS } from './renderer.js';
8
+ import { renderWithLLM } from './llm-renderer.js';
7
9
  import { renderForm, getFormCSS } from './renderers/form.js';
8
10
  import { generatePlaygroundHTML, getPlaygroundCSS, getPlaygroundJS } from './playground.js';
11
+ import { generateUtilityCSS } from './utilities.js';
9
12
 
10
13
  /** Options for rendering */
11
14
  interface DataRenderOptions extends RenderOptions {
12
15
  toolName?: string;
13
16
  toolDescription?: string;
17
+ /** LLM config for semantic rendering. Omit for structural fallback. */
18
+ llm?: LLMConfig;
14
19
  }
15
20
 
16
- /** Render any JSON data as a full HTML document. Pure structural rendering. */
17
- export function renderFromData(
18
- data: unknown,
19
- options: DataRenderOptions = {}
20
- ): string {
21
- const body = renderJSON(data);
22
- const cssParts = [generateThemeCSS(), getRendererCSS()];
23
- const jsParts = [generateBridgeJS(), getRendererJS()];
21
+ /** Build the document wrapper around an HTML body fragment */
22
+ function buildDocument(body: string, options: DataRenderOptions, extraCSS = '', extraJS = ''): string {
23
+ const cssParts = [generateThemeCSS(), generateUtilityCSS(), extraCSS];
24
+ const jsParts = [generateBridgeJS(), extraJS];
24
25
 
25
26
  if (options.debug) {
26
27
  cssParts.push(getPlaygroundCSS());
@@ -37,7 +38,38 @@ export function renderFromData(
37
38
  });
38
39
  }
39
40
 
40
- /** Render a form from a JSON Schema (for tool input) */
41
+ /**
42
+ * Render any JSON data as a full HTML document.
43
+ *
44
+ * - If `options.llm` is provided: calls the LLM for semantic rendering (async).
45
+ * - If omitted: uses the structural renderer (sync, returned as resolved Promise).
46
+ */
47
+ export async function renderFromData(
48
+ data: unknown,
49
+ options: DataRenderOptions = {}
50
+ ): Promise<string> {
51
+ if (options.llm) {
52
+ const body = await renderWithLLM(data, options.llm);
53
+ // Always include structural CSS/JS — the LLM fallback path may produce structural HTML
54
+ return buildDocument(body, options, getRendererCSS(), getRendererJS());
55
+ }
56
+
57
+ const body = renderJSON(data);
58
+ return buildDocument(body, options, getRendererCSS(), getRendererJS());
59
+ }
60
+
61
+ /**
62
+ * Sync structural rendering (no LLM). Use when you know you don't need LLM.
63
+ */
64
+ export function renderFromDataSync(
65
+ data: unknown,
66
+ options: Omit<DataRenderOptions, 'llm'> = {}
67
+ ): string {
68
+ const body = renderJSON(data);
69
+ return buildDocument(body, options, getRendererCSS(), getRendererJS());
70
+ }
71
+
72
+ /** Render a form from a JSON Schema (for tool input). Always sync. */
41
73
  export function renderFromSchema(
42
74
  schema: JSONSchema,
43
75
  options: DataRenderOptions = {}
@@ -51,6 +83,7 @@ export function renderFromSchema(
51
83
 
52
84
  const css = [
53
85
  generateThemeCSS(),
86
+ generateUtilityCSS(),
54
87
  getFormCSS(),
55
88
  options.debug ? getPlaygroundCSS() : '',
56
89
  ].join('\n');
@@ -69,7 +102,7 @@ export function renderFromSchema(
69
102
  }
70
103
 
71
104
  /** Unified API */
72
- export function render(input: EngineInput, options: DataRenderOptions = {}): string {
105
+ export async function render(input: EngineInput, options: DataRenderOptions = {}): Promise<string> {
73
106
  if (input.mode === 'schema') {
74
107
  return renderFromSchema(input.schema, {
75
108
  ...options,
package/src/index.ts CHANGED
@@ -3,13 +3,19 @@ export {
3
3
  render,
4
4
  renderFromSchema,
5
5
  renderFromData,
6
+ renderFromDataSync,
6
7
  } from './engine.js';
7
8
 
8
- // Universal JSON → HTML renderer (the core)
9
+ // Universal JSON → HTML renderer (structural fallback)
9
10
  export { renderJSON, getRendererCSS, getRendererJS } from './renderer.js';
10
11
 
12
+ // LLM-powered semantic renderer
13
+ export { renderWithLLM, RENDERING_PROMPT } from './llm-renderer.js';
14
+ export type { LLMConfig } from './llm-renderer.js';
15
+
11
16
  // Building blocks (for custom composition)
12
17
  export { generateThemeCSS } from './theme.js';
18
+ export { generateUtilityCSS } from './utilities.js';
13
19
  export { generateBridgeJS } from './bridge.js';
14
20
  export { escapeHtml, tag, style, script, document } from './html-builder.js';
15
21
 
@@ -0,0 +1,129 @@
1
+ /**
2
+ * LLM-powered semantic renderer.
3
+ *
4
+ * The structural renderer (renderer.ts) maps JSON shapes to HTML mechanically.
5
+ * This module sends JSON to an LLM with a rendering prompt,
6
+ * letting the model understand semantics and produce the best HTML.
7
+ *
8
+ * Supports any OpenAI-compatible API.
9
+ */
10
+
11
+ /** Configuration for the LLM endpoint */
12
+ export interface LLMConfig {
13
+ /** API base URL (e.g. "http://localhost:11434/v1") */
14
+ apiUrl: string;
15
+ /** API key (optional for local models) */
16
+ apiKey?: string;
17
+ /** Model name */
18
+ model: string;
19
+ }
20
+
21
+ /**
22
+ * The rendering prompt.
23
+ *
24
+ * This is intentionally short and non-prescriptive. We tell the model
25
+ * WHAT to do (produce the best HTML for this data), not HOW to do it
26
+ * (don't enumerate SVG, markdown, etc.). The model should figure out
27
+ * the semantics on its own.
28
+ */
29
+ const SYSTEM_PROMPT = `You are a JSON-to-HTML renderer. You receive JSON data and produce an HTML fragment that visualizes it in the best way possible.
30
+
31
+ Rules:
32
+ 1. Output ONLY an HTML fragment. No <html>, <head>, <body> wrappers — those exist already.
33
+ 2. Understand what the data MEANS, not just its shape. Render content in its native form — if something is meant to be seen, show it; if it's meant to be read, format it; if it's structured, organize it.
34
+ 3. Use these CSS variables for theming (light/dark mode is handled automatically):
35
+ Colors: --bg-primary, --bg-secondary, --bg-tertiary, --bg-elevated, --text-primary, --text-secondary, --text-tertiary, --accent, --accent-subtle, --success, --warning, --danger, --info (each has a -subtle variant), --border, --border-strong
36
+ Typography: --font-sans, --font-mono, --text-xs to --text-3xl
37
+ Spacing: --sp-1 (4px) to --sp-12 (48px)
38
+ Radius: --radius-sm, --radius-md, --radius-lg, --radius-full
39
+ Shadows: --shadow-sm, --shadow-md, --shadow-lg
40
+ Utility classes: .card, .badge, .badge-success, .badge-warning, .badge-danger, .badge-info, .section-title
41
+ 4. Tailwind-compatible utility classes are available. Use them freely: flex, grid, gap-*, p-*, m-*, text-*, bg-*, border, rounded-*, shadow-*, font-*, items-*, justify-*, w-full, h-full, overflow-*, relative/absolute, etc. Responsive prefixes sm: and md: work too.
42
+ 5. Do not invent data. Do not add commentary. Just the HTML.
43
+ 6. Do not wrap output in markdown code fences.`;
44
+
45
+ /**
46
+ * Call an OpenAI-compatible chat completions API.
47
+ */
48
+ async function callLLM(
49
+ data: unknown,
50
+ config: LLMConfig
51
+ ): Promise<string> {
52
+ const url = config.apiUrl.replace(/\/+$/, '') + '/chat/completions';
53
+
54
+ const headers: Record<string, string> = {
55
+ 'Content-Type': 'application/json',
56
+ };
57
+ if (config.apiKey) {
58
+ headers['Authorization'] = `Bearer ${config.apiKey}`;
59
+ }
60
+
61
+ const body = {
62
+ model: config.model,
63
+ messages: [
64
+ { role: 'system', content: SYSTEM_PROMPT },
65
+ { role: 'user', content: JSON.stringify(data, null, 2) },
66
+ ],
67
+ temperature: 0.2,
68
+ max_tokens: 16384,
69
+ };
70
+
71
+ const response = await fetch(url, {
72
+ method: 'POST',
73
+ headers,
74
+ body: JSON.stringify(body),
75
+ });
76
+
77
+ if (!response.ok) {
78
+ const text = await response.text();
79
+ throw new Error(`LLM API ${response.status}: ${text.slice(0, 300)}`);
80
+ }
81
+
82
+ const result = await response.json() as {
83
+ choices?: Array<{ message?: { content?: string } }>;
84
+ };
85
+
86
+ const content = result.choices?.[0]?.message?.content;
87
+ if (!content) {
88
+ throw new Error('LLM returned empty response');
89
+ }
90
+
91
+ return stripCodeFences(content);
92
+ }
93
+
94
+ /** Remove markdown code fences if the LLM wrapped its output */
95
+ function stripCodeFences(html: string): string {
96
+ const trimmed = html.trim();
97
+ const fenceMatch = trimmed.match(/^```(?:html)?\s*\n([\s\S]*?)\n\s*```$/);
98
+ if (fenceMatch) {
99
+ return fenceMatch[1].trim();
100
+ }
101
+ return trimmed;
102
+ }
103
+
104
+ /**
105
+ * Render JSON data using an LLM for semantic understanding.
106
+ * Returns an HTML fragment (not a full document).
107
+ * Falls back to structural renderer on failure.
108
+ */
109
+ export async function renderWithLLM(
110
+ data: unknown,
111
+ config: LLMConfig
112
+ ): Promise<string> {
113
+ try {
114
+ const html = await callLLM(data, config);
115
+ return `<div class="mcp-root">${html}</div>`;
116
+ } catch (err) {
117
+ const message = err instanceof Error ? err.message : String(err);
118
+ console.error(` LLM rendering failed: ${message}`);
119
+ console.error(' Falling back to structural renderer.');
120
+
121
+ const { renderJSON } = await import('./renderer.js');
122
+ const fallbackHTML = renderJSON(data);
123
+ const errorBanner = `<div style="padding:var(--sp-3);margin-bottom:var(--sp-4);background:var(--warning-subtle);border:1px solid var(--warning);border-radius:var(--radius-sm);font-size:var(--text-sm);color:var(--text-primary)">LLM rendering failed: ${message.replace(/</g, '&lt;')}. Showing structural fallback.</div>`;
124
+ return `<div class="mcp-root">${errorBanner}${fallbackHTML}</div>`;
125
+ }
126
+ }
127
+
128
+ /** Export the system prompt for inspection/customization */
129
+ export { SYSTEM_PROMPT as RENDERING_PROMPT };
package/src/renderer.ts CHANGED
@@ -2,32 +2,49 @@
2
2
  * Universal JSON → HTML renderer.
3
3
  *
4
4
  * No hardcoded business logic. No status badges, no price formatting,
5
- * no regex-based field type guessing. Just clean structural rendering:
5
+ * no regex-based field type guessing. Just clean structural rendering
6
+ * that handles any JSON shape gracefully:
6
7
  *
7
- * Array<Object> → sortable <table>
8
+ * Array<Object> → sortable <table> (union of all keys)
8
9
  * Object → <dl> key-value pairs (recursive for nesting)
9
- * Array<scalar> → <ul> list
10
- * string → text
10
+ * Array<mixed> → <ul> list with recursive items
11
+ * string → text (auto-links URLs)
11
12
  * number → number
12
13
  * boolean → true/false
13
14
  * null → placeholder
15
+ * empty → explicit empty indicator
14
16
  *
15
17
  * All formatting decisions are the caller's responsibility (LLM or user).
16
18
  */
17
19
  import { escapeHtml } from './html-builder.js';
18
20
 
21
+ // ── Constants ──
22
+
23
+ const MAX_DEPTH = 30;
24
+ const MAX_TABLE_ROWS = 500;
25
+ const MAX_LIST_ITEMS = 200;
26
+ const URL_REGEX = /^https?:\/\/[^\s<>"{}|\\^`[\]]+$/;
27
+ const IMAGE_EXT_REGEX = /\.(png|jpe?g|gif|webp|svg|ico|bmp)(\?[^\s]*)?$/i;
28
+
19
29
  // ── Structural detection ──
20
30
 
21
31
  function isArrayOfObjects(data: unknown): data is Record<string, unknown>[] {
22
- return Array.isArray(data) && data.length > 0 &&
23
- data.every(item => item !== null && typeof item === 'object' && !Array.isArray(item));
32
+ if (!Array.isArray(data) || data.length === 0) return false;
33
+ // At least 80% must be non-null objects (tolerates a few nulls in the array)
34
+ let objCount = 0;
35
+ for (const item of data) {
36
+ if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
37
+ objCount++;
38
+ }
39
+ }
40
+ return objCount / data.length >= 0.8 && objCount >= 1;
24
41
  }
25
42
 
26
43
  function isFlatObject(data: unknown): data is Record<string, unknown> {
27
44
  if (data === null || typeof data !== 'object' || Array.isArray(data)) return false;
28
- return Object.values(data as Record<string, unknown>).every(
29
- v => v === null || typeof v !== 'object'
30
- );
45
+ const values = Object.values(data as Record<string, unknown>);
46
+ if (values.length === 0) return true;
47
+ return values.every(v => v === null || typeof v !== 'object');
31
48
  }
32
49
 
33
50
  function humanizeKey(key: string): string {
@@ -38,19 +55,38 @@ function humanizeKey(key: string): string {
38
55
  .trim();
39
56
  }
40
57
 
58
+ function isUrl(value: string): boolean {
59
+ return URL_REGEX.test(value);
60
+ }
61
+
62
+ function isImageUrl(value: string): boolean {
63
+ return IMAGE_EXT_REGEX.test(value);
64
+ }
65
+
41
66
  // ── Render primitives ──
42
67
 
43
68
  function renderValue(value: unknown, depth: number): string {
44
69
  if (value === null || value === undefined) {
45
- return '<span class="mcp-null">—</span>';
70
+ return '<span class="mcp-null">\u2014</span>';
46
71
  }
47
72
  if (typeof value === 'boolean') {
48
- return `<span class="mcp-bool">${value}</span>`;
73
+ return `<span class="mcp-bool mcp-bool-${value}">${value}</span>`;
49
74
  }
50
75
  if (typeof value === 'number') {
51
76
  return `<span class="mcp-num">${value}</span>`;
52
77
  }
53
78
  if (typeof value === 'string') {
79
+ if (value.length === 0) {
80
+ return '<span class="mcp-null">(empty)</span>';
81
+ }
82
+ // Auto-link URLs
83
+ if (isUrl(value)) {
84
+ const escaped = escapeHtml(value);
85
+ if (isImageUrl(value)) {
86
+ return `<a href="${escaped}" target="_blank" rel="noopener"><img class="mcp-img" src="${escaped}" alt="" loading="lazy"></a>`;
87
+ }
88
+ return `<a class="mcp-link" href="${escaped}" target="_blank" rel="noopener">${escaped}</a>`;
89
+ }
54
90
  if (value.length > 300) {
55
91
  return `<div class="mcp-text">${escapeHtml(value)}</div>`;
56
92
  }
@@ -62,22 +98,49 @@ function renderValue(value: unknown, depth: number): string {
62
98
 
63
99
  // ── Core recursive renderer ──
64
100
 
65
- function renderTable(rows: Record<string, unknown>[]): string {
66
- const columns = Object.keys(rows[0]);
101
+ /** Collect union of all keys across an array of objects, preserving order */
102
+ function collectColumns(rows: Record<string, unknown>[]): string[] {
103
+ const seen = new Set<string>();
104
+ const columns: string[] = [];
105
+ for (const row of rows) {
106
+ for (const key of Object.keys(row)) {
107
+ if (!seen.has(key)) {
108
+ seen.add(key);
109
+ columns.push(key);
110
+ }
111
+ }
112
+ }
113
+ return columns;
114
+ }
115
+
116
+ function renderTable(rows: Record<string, unknown>[], tableId: string): string {
117
+ const columns = collectColumns(rows);
118
+ const truncated = rows.length > MAX_TABLE_ROWS;
119
+ const displayRows = truncated ? rows.slice(0, MAX_TABLE_ROWS) : rows;
120
+
67
121
  const headerCells = columns
68
122
  .map((col, i) =>
69
- `<th onclick="__mcpSort(${i})" class="mcp-sortable">${escapeHtml(humanizeKey(col))}<span class="mcp-sort-icon">⇅</span></th>`)
123
+ `<th onclick="__mcpSort('${escapeHtml(tableId)}',${i})" class="mcp-sortable">${escapeHtml(humanizeKey(col))}<span class="mcp-sort-icon">\u21C5</span></th>`)
70
124
  .join('');
71
125
 
72
- const bodyRows = rows
73
- .map(row =>
74
- `<tr>${columns.map(col => `<td>${renderValue(row[col], 0)}</td>`).join('')}</tr>`)
126
+ const bodyRows = displayRows
127
+ .map(row => {
128
+ const cells = columns.map(col => {
129
+ const val = Object.prototype.hasOwnProperty.call(row, col) ? row[col] : undefined;
130
+ return `<td>${renderValue(val, 1)}</td>`;
131
+ }).join('');
132
+ return `<tr>${cells}</tr>`;
133
+ })
75
134
  .join('\n');
76
135
 
136
+ const meta = truncated
137
+ ? `${rows.length} rows \u00D7 ${columns.length} columns (showing first ${MAX_TABLE_ROWS})`
138
+ : `${rows.length} rows \u00D7 ${columns.length} columns`;
139
+
77
140
  return `<div class="mcp-table-wrap">
78
- <div class="mcp-table-meta">${rows.length} rows × ${columns.length} columns</div>
141
+ <div class="mcp-table-meta">${meta}</div>
79
142
  <div class="mcp-table-scroll">
80
- <table class="mcp-table" id="mcp-grid">
143
+ <table class="mcp-table" id="${escapeHtml(tableId)}">
81
144
  <thead><tr>${headerCells}</tr></thead>
82
145
  <tbody>${bodyRows}</tbody>
83
146
  </table>
@@ -87,6 +150,9 @@ function renderTable(rows: Record<string, unknown>[]): string {
87
150
 
88
151
  function renderKeyValue(obj: Record<string, unknown>, depth: number): string {
89
152
  const entries = Object.entries(obj);
153
+ if (entries.length === 0) {
154
+ return '<div class="mcp-empty">(empty object)</div>';
155
+ }
90
156
  const items = entries.map(([key, val]) => {
91
157
  const rendered = renderValue(val, depth);
92
158
  return `<div class="mcp-kv">
@@ -99,10 +165,21 @@ function renderKeyValue(obj: Record<string, unknown>, depth: number): string {
99
165
  }
100
166
 
101
167
  function renderList(arr: unknown[], depth: number): string {
102
- const items = arr.map(item =>
168
+ if (arr.length === 0) {
169
+ return '<div class="mcp-empty">(empty array)</div>';
170
+ }
171
+ const truncated = arr.length > MAX_LIST_ITEMS;
172
+ const displayItems = truncated ? arr.slice(0, MAX_LIST_ITEMS) : arr;
173
+
174
+ const items = displayItems.map(item =>
103
175
  `<li>${renderValue(item, depth)}</li>`
104
176
  ).join('\n');
105
- return `<ul class="mcp-list">${items}</ul>`;
177
+
178
+ const suffix = truncated
179
+ ? `<li class="mcp-truncated">\u2026 and ${arr.length - MAX_LIST_ITEMS} more items</li>`
180
+ : '';
181
+
182
+ return `<ul class="mcp-list">${items}\n${suffix}</ul>`;
106
183
  }
107
184
 
108
185
  function renderCollapsible(label: string, content: string, open = true): string {
@@ -112,7 +189,15 @@ function renderCollapsible(label: string, content: string, open = true): string
112
189
  </details>`;
113
190
  }
114
191
 
192
+ // Global table counter for unique IDs
193
+ let tableCounter = 0;
194
+
115
195
  function renderAny(data: unknown, depth: number): string {
196
+ // Depth guard
197
+ if (depth > MAX_DEPTH) {
198
+ return '<span class="mcp-null">(max depth reached)</span>';
199
+ }
200
+
116
201
  // Primitive
117
202
  if (data === null || data === undefined || typeof data !== 'object') {
118
203
  return renderValue(data, depth);
@@ -120,7 +205,13 @@ function renderAny(data: unknown, depth: number): string {
120
205
 
121
206
  // Array of objects → table
122
207
  if (isArrayOfObjects(data)) {
123
- const content = renderTable(data);
208
+ // Filter to only objects for the table, skip non-objects
209
+ const objectRows = (data as unknown[]).filter(
210
+ (item): item is Record<string, unknown> =>
211
+ item !== null && typeof item === 'object' && !Array.isArray(item)
212
+ );
213
+ const tableId = `mcp-grid-${tableCounter++}`;
214
+ const content = renderTable(objectRows, tableId);
124
215
  return depth > 0 ? renderCollapsible(`Array (${data.length} items)`, content) : content;
125
216
  }
126
217
 
@@ -138,6 +229,10 @@ function renderAny(data: unknown, depth: number): string {
138
229
 
139
230
  // Nested object → grouped sections
140
231
  const entries = Object.entries(obj);
232
+ if (entries.length === 0) {
233
+ return '<div class="mcp-empty">(empty object)</div>';
234
+ }
235
+
141
236
  const sections = entries.map(([key, val]) => {
142
237
  if (val !== null && typeof val === 'object') {
143
238
  return renderCollapsible(humanizeKey(key), renderAny(val, depth + 1), depth < 2);
@@ -161,6 +256,7 @@ function renderAny(data: unknown, depth: number): string {
161
256
 
162
257
  /** Render any JSON data as an HTML fragment. No business logic, pure structure. */
163
258
  export function renderJSON(data: unknown): string {
259
+ tableCounter = 0; // Reset per render call
164
260
  return `<div class="mcp-root">${renderAny(data, 0)}</div>`;
165
261
  }
166
262
 
@@ -184,13 +280,14 @@ export function getRendererCSS(): string {
184
280
  text-transform: uppercase; letter-spacing: 0.05em;
185
281
  border-bottom: 2px solid var(--border);
186
282
  white-space: nowrap; user-select: none;
283
+ position: sticky; top: 0; background: var(--bg-primary); z-index: 1;
187
284
  }
188
285
  .mcp-sortable { cursor: pointer; }
189
286
  .mcp-sortable:hover { color: var(--accent); }
190
287
  .mcp-sort-icon { margin-left: 4px; opacity: 0.3; font-size: 10px; }
191
288
  .mcp-table td {
192
289
  padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border);
193
- vertical-align: top;
290
+ vertical-align: top; max-width: 400px;
194
291
  }
195
292
  .mcp-table tbody tr:hover { background: var(--accent-subtle); }
196
293
 
@@ -236,25 +333,42 @@ export function getRendererCSS(): string {
236
333
  /* Primitives */
237
334
  .mcp-null { color: var(--text-tertiary); font-style: italic; }
238
335
  .mcp-bool { font-weight: 600; }
336
+ .mcp-bool-true { color: var(--success); }
337
+ .mcp-bool-false { color: var(--danger); }
239
338
  .mcp-num { font-variant-numeric: tabular-nums; }
240
339
  .mcp-text { white-space: pre-wrap; line-height: 1.6; }
340
+ .mcp-empty { color: var(--text-tertiary); font-style: italic; padding: var(--sp-2) 0; }
341
+ .mcp-truncated { color: var(--text-tertiary); font-style: italic; }
342
+
343
+ /* Links & images */
344
+ .mcp-link { color: var(--accent); text-decoration: none; word-break: break-all; }
345
+ .mcp-link:hover { text-decoration: underline; }
346
+ .mcp-img { max-width: 200px; max-height: 150px; border-radius: var(--radius-sm); border: 1px solid var(--border); }
347
+
348
+ /* Nested table styling */
349
+ .mcp-details .mcp-table-wrap { margin: 0; }
350
+ .mcp-details .mcp-table th { position: static; }
241
351
  `;
242
352
  }
243
353
 
244
- /** Get the JS for table sorting (the only interactive behavior) */
354
+ /** Get the JS for table sorting (supports multiple tables) */
245
355
  export function getRendererJS(): string {
246
356
  return `
247
- function __mcpSort(colIdx) {
248
- var table = document.getElementById('mcp-grid');
357
+ function __mcpSort(tableId, colIdx) {
358
+ var table = document.getElementById(tableId);
249
359
  if (!table) return;
250
360
  var tbody = table.tBodies[0];
251
361
  var rows = Array.from(tbody.rows);
252
- var dir = table.dataset.sortDir === 'asc' ? 'desc' : 'asc';
362
+ var key = tableId + '_' + colIdx;
363
+ var dir = table.dataset.sortKey === key && table.dataset.sortDir === 'asc' ? 'desc' : 'asc';
253
364
  table.dataset.sortDir = dir;
365
+ table.dataset.sortKey = key;
254
366
 
255
367
  rows.sort(function(a, b) {
256
- var av = a.cells[colIdx].textContent.trim();
257
- var bv = b.cells[colIdx].textContent.trim();
368
+ var ac = a.cells[colIdx], bc = b.cells[colIdx];
369
+ if (!ac || !bc) return 0;
370
+ var av = ac.textContent.trim();
371
+ var bv = bc.textContent.trim();
258
372
  var an = parseFloat(av.replace(/[^\\d.-]/g, ''));
259
373
  var bn = parseFloat(bv.replace(/[^\\d.-]/g, ''));
260
374
  if (!isNaN(an) && !isNaN(bn)) return dir === 'asc' ? an - bn : bn - an;
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Tailwind-like atomic utility classes.
3
+ *
4
+ * Maps familiar Tailwind class names to our CSS variable system.
5
+ * LLMs already know Tailwind syntax from training data — by providing
6
+ * these utilities, we ensure any model produces visually consistent HTML
7
+ * that automatically respects light/dark mode.
8
+ *
9
+ * This is NOT a full Tailwind build. It's a curated subset:
10
+ * - Layout (flex, grid, gap)
11
+ * - Spacing (p-*, m-*)
12
+ * - Typography (text-*, font-*)
13
+ * - Colors (text-*, bg-*, border-*)
14
+ * - Borders & radius
15
+ * - Shadows & effects
16
+ * - Sizing & overflow
17
+ */
18
+
19
+ export function generateUtilityCSS(): string {
20
+ return `
21
+ /* ══════════════════════════════════════════
22
+ Tailwind-compatible utility classes
23
+ Mapped to CSS variable theming system
24
+ ══════════════════════════════════════════ */
25
+
26
+ /* ── Display ── */
27
+ .block { display: block; }
28
+ .inline-block { display: inline-block; }
29
+ .inline { display: inline; }
30
+ .hidden { display: none; }
31
+
32
+ /* ── Flex ── */
33
+ .flex { display: flex; }
34
+ .inline-flex { display: inline-flex; }
35
+ .flex-row { flex-direction: row; }
36
+ .flex-col { flex-direction: column; }
37
+ .flex-wrap { flex-wrap: wrap; }
38
+ .flex-nowrap { flex-wrap: nowrap; }
39
+ .flex-1 { flex: 1 1 0%; }
40
+ .flex-auto { flex: 1 1 auto; }
41
+ .flex-none { flex: none; }
42
+ .items-start { align-items: flex-start; }
43
+ .items-center { align-items: center; }
44
+ .items-end { align-items: flex-end; }
45
+ .items-stretch { align-items: stretch; }
46
+ .items-baseline { align-items: baseline; }
47
+ .justify-start { justify-content: flex-start; }
48
+ .justify-center { justify-content: center; }
49
+ .justify-end { justify-content: flex-end; }
50
+ .justify-between { justify-content: space-between; }
51
+ .justify-around { justify-content: space-around; }
52
+ .justify-evenly { justify-content: space-evenly; }
53
+ .self-start { align-self: flex-start; }
54
+ .self-center { align-self: center; }
55
+ .self-end { align-self: flex-end; }
56
+ .shrink-0 { flex-shrink: 0; }
57
+ .grow { flex-grow: 1; }
58
+
59
+ /* ── Grid ── */
60
+ .grid { display: grid; }
61
+ .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
62
+ .grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
63
+ .grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
64
+ .grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
65
+ .col-span-2 { grid-column: span 2 / span 2; }
66
+ .col-span-3 { grid-column: span 3 / span 3; }
67
+ .col-span-full { grid-column: 1 / -1; }
68
+
69
+ /* ── Gap ── */
70
+ .gap-1 { gap: var(--sp-1); }
71
+ .gap-2 { gap: var(--sp-2); }
72
+ .gap-3 { gap: var(--sp-3); }
73
+ .gap-4 { gap: var(--sp-4); }
74
+ .gap-5 { gap: var(--sp-5); }
75
+ .gap-6 { gap: var(--sp-6); }
76
+ .gap-8 { gap: var(--sp-8); }
77
+
78
+ /* ── Padding ── */
79
+ .p-0 { padding: 0; }
80
+ .p-1 { padding: var(--sp-1); }
81
+ .p-2 { padding: var(--sp-2); }
82
+ .p-3 { padding: var(--sp-3); }
83
+ .p-4 { padding: var(--sp-4); }
84
+ .p-5 { padding: var(--sp-5); }
85
+ .p-6 { padding: var(--sp-6); }
86
+ .p-8 { padding: var(--sp-8); }
87
+ .px-1 { padding-left: var(--sp-1); padding-right: var(--sp-1); }
88
+ .px-2 { padding-left: var(--sp-2); padding-right: var(--sp-2); }
89
+ .px-3 { padding-left: var(--sp-3); padding-right: var(--sp-3); }
90
+ .px-4 { padding-left: var(--sp-4); padding-right: var(--sp-4); }
91
+ .px-6 { padding-left: var(--sp-6); padding-right: var(--sp-6); }
92
+ .py-1 { padding-top: var(--sp-1); padding-bottom: var(--sp-1); }
93
+ .py-2 { padding-top: var(--sp-2); padding-bottom: var(--sp-2); }
94
+ .py-3 { padding-top: var(--sp-3); padding-bottom: var(--sp-3); }
95
+ .py-4 { padding-top: var(--sp-4); padding-bottom: var(--sp-4); }
96
+ .py-6 { padding-top: var(--sp-6); padding-bottom: var(--sp-6); }
97
+ .pt-2 { padding-top: var(--sp-2); }
98
+ .pt-4 { padding-top: var(--sp-4); }
99
+ .pb-2 { padding-bottom: var(--sp-2); }
100
+ .pb-4 { padding-bottom: var(--sp-4); }
101
+ .pl-3 { padding-left: var(--sp-3); }
102
+ .pl-4 { padding-left: var(--sp-4); }
103
+ .pr-3 { padding-right: var(--sp-3); }
104
+
105
+ /* ── Margin ── */
106
+ .m-0 { margin: 0; }
107
+ .m-auto { margin: auto; }
108
+ .mx-auto { margin-left: auto; margin-right: auto; }
109
+ .mt-1 { margin-top: var(--sp-1); }
110
+ .mt-2 { margin-top: var(--sp-2); }
111
+ .mt-3 { margin-top: var(--sp-3); }
112
+ .mt-4 { margin-top: var(--sp-4); }
113
+ .mt-6 { margin-top: var(--sp-6); }
114
+ .mt-8 { margin-top: var(--sp-8); }
115
+ .mb-1 { margin-bottom: var(--sp-1); }
116
+ .mb-2 { margin-bottom: var(--sp-2); }
117
+ .mb-3 { margin-bottom: var(--sp-3); }
118
+ .mb-4 { margin-bottom: var(--sp-4); }
119
+ .mb-6 { margin-bottom: var(--sp-6); }
120
+ .ml-2 { margin-left: var(--sp-2); }
121
+ .ml-3 { margin-left: var(--sp-3); }
122
+ .mr-2 { margin-right: var(--sp-2); }
123
+ .mr-3 { margin-right: var(--sp-3); }
124
+
125
+ /* ── Width & Height ── */
126
+ .w-full { width: 100%; }
127
+ .w-auto { width: auto; }
128
+ .w-fit { width: fit-content; }
129
+ .max-w-sm { max-width: 24rem; }
130
+ .max-w-md { max-width: 28rem; }
131
+ .max-w-lg { max-width: 32rem; }
132
+ .max-w-xl { max-width: 36rem; }
133
+ .max-w-2xl { max-width: 42rem; }
134
+ .max-w-3xl { max-width: 48rem; }
135
+ .max-w-4xl { max-width: 56rem; }
136
+ .max-w-full { max-width: 100%; }
137
+ .max-w-prose { max-width: 65ch; }
138
+ .min-w-0 { min-width: 0; }
139
+ .h-auto { height: auto; }
140
+ .h-full { height: 100%; }
141
+ .min-h-0 { min-height: 0; }
142
+
143
+ /* ── Typography ── */
144
+ .text-xs { font-size: var(--text-xs); }
145
+ .text-sm { font-size: var(--text-sm); }
146
+ .text-base { font-size: var(--text-base); }
147
+ .text-lg { font-size: var(--text-lg); }
148
+ .text-xl { font-size: var(--text-xl); }
149
+ .text-2xl { font-size: var(--text-2xl); }
150
+ .text-3xl { font-size: var(--text-3xl); }
151
+ .font-sans { font-family: var(--font-sans); }
152
+ .font-mono { font-family: var(--font-mono); }
153
+ .font-normal { font-weight: 400; }
154
+ .font-medium { font-weight: 500; }
155
+ .font-semibold { font-weight: 600; }
156
+ .font-bold { font-weight: 700; }
157
+ .italic { font-style: italic; }
158
+ .not-italic { font-style: normal; }
159
+ .leading-none { line-height: 1; }
160
+ .leading-tight { line-height: 1.25; }
161
+ .leading-snug { line-height: 1.375; }
162
+ .leading-normal { line-height: 1.5; }
163
+ .leading-relaxed { line-height: 1.625; }
164
+ .tracking-tight { letter-spacing: -0.025em; }
165
+ .tracking-normal { letter-spacing: 0; }
166
+ .tracking-wide { letter-spacing: 0.025em; }
167
+ .tracking-wider { letter-spacing: 0.05em; }
168
+ .uppercase { text-transform: uppercase; }
169
+ .lowercase { text-transform: lowercase; }
170
+ .capitalize { text-transform: capitalize; }
171
+ .normal-case { text-transform: none; }
172
+ .text-left { text-align: left; }
173
+ .text-center { text-align: center; }
174
+ .text-right { text-align: right; }
175
+ .underline { text-decoration: underline; }
176
+ .no-underline { text-decoration: none; }
177
+ .line-through { text-decoration: line-through; }
178
+ .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
179
+ .whitespace-pre { white-space: pre; }
180
+ .whitespace-pre-wrap { white-space: pre-wrap; }
181
+ .whitespace-nowrap { white-space: nowrap; }
182
+ .break-words { word-break: break-word; overflow-wrap: break-word; }
183
+ .break-all { word-break: break-all; }
184
+ .tabular-nums { font-variant-numeric: tabular-nums; }
185
+
186
+ /* ── Text Colors ── */
187
+ .text-primary { color: var(--text-primary); }
188
+ .text-secondary { color: var(--text-secondary); }
189
+ .text-tertiary { color: var(--text-tertiary); }
190
+ .text-inverse { color: var(--text-inverse); }
191
+ .text-accent { color: var(--accent); }
192
+ .text-success { color: var(--success); }
193
+ .text-warning { color: var(--warning); }
194
+ .text-danger { color: var(--danger); }
195
+ .text-info { color: var(--info); }
196
+
197
+ /* ── Background Colors ── */
198
+ .bg-primary { background-color: var(--bg-primary); }
199
+ .bg-secondary { background-color: var(--bg-secondary); }
200
+ .bg-tertiary { background-color: var(--bg-tertiary); }
201
+ .bg-elevated { background-color: var(--bg-elevated); }
202
+ .bg-accent { background-color: var(--accent); }
203
+ .bg-accent-subtle { background-color: var(--accent-subtle); }
204
+ .bg-success { background-color: var(--success); }
205
+ .bg-success-subtle { background-color: var(--success-subtle); }
206
+ .bg-warning { background-color: var(--warning); }
207
+ .bg-warning-subtle { background-color: var(--warning-subtle); }
208
+ .bg-danger { background-color: var(--danger); }
209
+ .bg-danger-subtle { background-color: var(--danger-subtle); }
210
+ .bg-info { background-color: var(--info); }
211
+ .bg-info-subtle { background-color: var(--info-subtle); }
212
+ .bg-transparent { background-color: transparent; }
213
+
214
+ /* ── Borders ── */
215
+ .border { border: 1px solid var(--border); }
216
+ .border-2 { border: 2px solid var(--border); }
217
+ .border-t { border-top: 1px solid var(--border); }
218
+ .border-b { border-bottom: 1px solid var(--border); }
219
+ .border-l { border-left: 1px solid var(--border); }
220
+ .border-r { border-right: 1px solid var(--border); }
221
+ .border-strong { border-color: var(--border-strong); }
222
+ .border-accent { border-color: var(--accent); }
223
+ .border-success { border-color: var(--success); }
224
+ .border-warning { border-color: var(--warning); }
225
+ .border-danger { border-color: var(--danger); }
226
+ .border-transparent { border-color: transparent; }
227
+ .border-none { border: none; }
228
+
229
+ /* ── Border Radius ── */
230
+ .rounded-none { border-radius: 0; }
231
+ .rounded-sm { border-radius: var(--radius-sm); }
232
+ .rounded { border-radius: var(--radius-md); }
233
+ .rounded-md { border-radius: var(--radius-md); }
234
+ .rounded-lg { border-radius: var(--radius-lg); }
235
+ .rounded-full { border-radius: var(--radius-full); }
236
+
237
+ /* ── Shadows ── */
238
+ .shadow-sm { box-shadow: var(--shadow-sm); }
239
+ .shadow { box-shadow: var(--shadow-md); }
240
+ .shadow-md { box-shadow: var(--shadow-md); }
241
+ .shadow-lg { box-shadow: var(--shadow-lg); }
242
+ .shadow-none { box-shadow: none; }
243
+
244
+ /* ── Overflow ── */
245
+ .overflow-auto { overflow: auto; }
246
+ .overflow-hidden { overflow: hidden; }
247
+ .overflow-x-auto { overflow-x: auto; }
248
+ .overflow-y-auto { overflow-y: auto; }
249
+ .overflow-scroll { overflow: scroll; }
250
+
251
+ /* ── Position ── */
252
+ .relative { position: relative; }
253
+ .absolute { position: absolute; }
254
+ .fixed { position: fixed; }
255
+ .sticky { position: sticky; }
256
+ .inset-0 { top: 0; right: 0; bottom: 0; left: 0; }
257
+ .top-0 { top: 0; }
258
+ .right-0 { right: 0; }
259
+ .bottom-0 { bottom: 0; }
260
+ .left-0 { left: 0; }
261
+
262
+ /* ── Z-Index ── */
263
+ .z-0 { z-index: 0; }
264
+ .z-10 { z-index: 10; }
265
+ .z-20 { z-index: 20; }
266
+ .z-50 { z-index: 50; }
267
+
268
+ /* ── Opacity ── */
269
+ .opacity-0 { opacity: 0; }
270
+ .opacity-25 { opacity: 0.25; }
271
+ .opacity-50 { opacity: 0.5; }
272
+ .opacity-75 { opacity: 0.75; }
273
+ .opacity-100 { opacity: 1; }
274
+
275
+ /* ── Cursor ── */
276
+ .cursor-pointer { cursor: pointer; }
277
+ .cursor-default { cursor: default; }
278
+ .select-none { user-select: none; }
279
+
280
+ /* ── Transitions ── */
281
+ .transition { transition: all var(--duration-normal) var(--ease-out); }
282
+ .transition-colors { transition: color var(--duration-fast) var(--ease-out), background-color var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out); }
283
+
284
+ /* ── List ── */
285
+ .list-none { list-style: none; }
286
+ .list-disc { list-style-type: disc; }
287
+ .list-decimal { list-style-type: decimal; }
288
+ .list-inside { list-style-position: inside; }
289
+
290
+ /* ── Table ── */
291
+ .table { display: table; }
292
+ .table-auto { table-layout: auto; }
293
+ .table-fixed { table-layout: fixed; }
294
+ .border-collapse { border-collapse: collapse; }
295
+
296
+ /* ── SVG ── */
297
+ .fill-current { fill: currentColor; }
298
+ .stroke-current { stroke: currentColor; }
299
+
300
+ /* ── Responsive ── */
301
+ @media (min-width: 640px) {
302
+ .sm\\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
303
+ .sm\\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
304
+ .sm\\:flex-row { flex-direction: row; }
305
+ .sm\\:text-lg { font-size: var(--text-lg); }
306
+ .sm\\:text-xl { font-size: var(--text-xl); }
307
+ .sm\\:p-6 { padding: var(--sp-6); }
308
+ }
309
+ @media (min-width: 768px) {
310
+ .md\\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
311
+ .md\\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
312
+ .md\\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
313
+ .md\\:flex-row { flex-direction: row; }
314
+ .md\\:text-2xl { font-size: var(--text-2xl); }
315
+ .md\\:text-3xl { font-size: var(--text-3xl); }
316
+ .md\\:p-8 { padding: var(--sp-8); }
317
+ }
318
+ `;
319
+ }