@mcp-html-bridge/ui-engine 0.3.0 → 0.5.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 +1 -1
- package/src/engine.ts +50 -104
- package/src/index.ts +9 -13
- package/src/llm-renderer.ts +129 -0
- package/src/renderer.ts +380 -0
- package/src/types.ts +0 -23
- package/src/utilities.ts +319 -0
- package/src/data-sniffer.ts +0 -177
- package/src/renderers/composite.ts +0 -73
- package/src/renderers/data-grid.ts +0 -159
- package/src/renderers/json-tree.ts +0 -141
- package/src/renderers/metrics-card.ts +0 -108
- package/src/renderers/reading-block.ts +0 -114
package/src/utilities.ts
ADDED
|
@@ -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
|
+
}
|
package/src/data-sniffer.ts
DELETED
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
// ── Data Sniffer: confidence-scored heuristic engine ──
|
|
2
|
-
import type { SniffResult, RenderIntent } from './types.js';
|
|
3
|
-
|
|
4
|
-
/** Measure maximum nesting depth of a value */
|
|
5
|
-
function measureDepth(val: unknown, current = 0): number {
|
|
6
|
-
if (current > 20) return current; // guard
|
|
7
|
-
if (Array.isArray(val)) {
|
|
8
|
-
return val.reduce<number>((max, item) => Math.max(max, measureDepth(item, current + 1)), current);
|
|
9
|
-
}
|
|
10
|
-
if (val !== null && typeof val === 'object') {
|
|
11
|
-
return Object.values(val as Record<string, unknown>).reduce<number>(
|
|
12
|
-
(max, v) => Math.max(max, measureDepth(v, current + 1)),
|
|
13
|
-
current
|
|
14
|
-
);
|
|
15
|
-
}
|
|
16
|
-
return current;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Check if an array of objects has consistent keys */
|
|
20
|
-
function keyConsistency(arr: Record<string, unknown>[]): number {
|
|
21
|
-
if (arr.length === 0) return 0;
|
|
22
|
-
const firstKeys = new Set(Object.keys(arr[0]));
|
|
23
|
-
let matchCount = 0;
|
|
24
|
-
for (let i = 1; i < arr.length; i++) {
|
|
25
|
-
const keys = Object.keys(arr[i]);
|
|
26
|
-
const overlap = keys.filter((k) => firstKeys.has(k)).length;
|
|
27
|
-
matchCount += overlap / Math.max(firstKeys.size, keys.length);
|
|
28
|
-
}
|
|
29
|
-
return matchCount / (arr.length - 1);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const READING_KEYS = new Set([
|
|
33
|
-
'description', 'content', 'body', 'text', 'message',
|
|
34
|
-
'summary', 'readme', 'notes', 'details', 'markdown',
|
|
35
|
-
]);
|
|
36
|
-
|
|
37
|
-
/** Detect data-grid intent */
|
|
38
|
-
function detectDataGrid(data: unknown): SniffResult | null {
|
|
39
|
-
if (!Array.isArray(data) || data.length === 0) return null;
|
|
40
|
-
const objects = data.filter(
|
|
41
|
-
(item): item is Record<string, unknown> =>
|
|
42
|
-
item !== null && typeof item === 'object' && !Array.isArray(item)
|
|
43
|
-
);
|
|
44
|
-
if (objects.length < 2) return null;
|
|
45
|
-
|
|
46
|
-
const consistency = keyConsistency(objects);
|
|
47
|
-
if (consistency < 0.5) return null;
|
|
48
|
-
|
|
49
|
-
const confidence = Math.min(0.95, 0.5 + consistency * 0.3 + Math.min(objects.length / 20, 0.15));
|
|
50
|
-
return {
|
|
51
|
-
intent: 'data-grid',
|
|
52
|
-
confidence,
|
|
53
|
-
metadata: {
|
|
54
|
-
rowCount: objects.length,
|
|
55
|
-
columns: Object.keys(objects[0]),
|
|
56
|
-
keyConsistency: consistency,
|
|
57
|
-
},
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Detect metrics-card intent */
|
|
62
|
-
function detectMetricsCard(data: unknown): SniffResult | null {
|
|
63
|
-
if (data === null || typeof data !== 'object' || Array.isArray(data)) return null;
|
|
64
|
-
const entries = Object.entries(data as Record<string, unknown>);
|
|
65
|
-
if (entries.length === 0 || entries.length > 12) return null;
|
|
66
|
-
|
|
67
|
-
const numericCount = entries.filter(([, v]) => typeof v === 'number').length;
|
|
68
|
-
const ratio = numericCount / entries.length;
|
|
69
|
-
|
|
70
|
-
if (numericCount < 1 || ratio < 0.3) return null;
|
|
71
|
-
|
|
72
|
-
const confidence = Math.min(0.9, 0.4 + ratio * 0.4 + (entries.length <= 6 ? 0.1 : 0));
|
|
73
|
-
return {
|
|
74
|
-
intent: 'metrics-card',
|
|
75
|
-
confidence,
|
|
76
|
-
metadata: {
|
|
77
|
-
numericKeys: entries.filter(([, v]) => typeof v === 'number').map(([k]) => k),
|
|
78
|
-
totalKeys: entries.length,
|
|
79
|
-
},
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Detect json-tree intent */
|
|
84
|
-
function detectJsonTree(data: unknown): SniffResult | null {
|
|
85
|
-
const depth = measureDepth(data);
|
|
86
|
-
if (depth < 3) return null;
|
|
87
|
-
|
|
88
|
-
const confidence = Math.min(0.85, 0.3 + (depth - 2) * 0.1);
|
|
89
|
-
return {
|
|
90
|
-
intent: 'json-tree',
|
|
91
|
-
confidence,
|
|
92
|
-
metadata: { depth },
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Detect reading-block intent */
|
|
97
|
-
function detectReadingBlock(data: unknown): SniffResult | null {
|
|
98
|
-
if (typeof data === 'string' && data.length > 200) {
|
|
99
|
-
return {
|
|
100
|
-
intent: 'reading-block',
|
|
101
|
-
confidence: 0.85,
|
|
102
|
-
metadata: { length: data.length },
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
if (data !== null && typeof data === 'object' && !Array.isArray(data)) {
|
|
106
|
-
const entries = Object.entries(data as Record<string, unknown>);
|
|
107
|
-
const longTextEntries = entries.filter(
|
|
108
|
-
([k, v]) =>
|
|
109
|
-
(typeof v === 'string' && v.length > 200) || READING_KEYS.has(k.toLowerCase())
|
|
110
|
-
);
|
|
111
|
-
if (longTextEntries.length > 0) {
|
|
112
|
-
const confidence = Math.min(0.8, 0.4 + longTextEntries.length * 0.15);
|
|
113
|
-
return {
|
|
114
|
-
intent: 'reading-block',
|
|
115
|
-
confidence,
|
|
116
|
-
metadata: { textKeys: longTextEntries.map(([k]) => k) },
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Main sniff function: returns ranked intents */
|
|
124
|
-
export function sniff(data: unknown): SniffResult[] {
|
|
125
|
-
const results: SniffResult[] = [];
|
|
126
|
-
|
|
127
|
-
const grid = detectDataGrid(data);
|
|
128
|
-
if (grid) results.push(grid);
|
|
129
|
-
|
|
130
|
-
const metrics = detectMetricsCard(data);
|
|
131
|
-
if (metrics) results.push(metrics);
|
|
132
|
-
|
|
133
|
-
const tree = detectJsonTree(data);
|
|
134
|
-
if (tree) results.push(tree);
|
|
135
|
-
|
|
136
|
-
const reading = detectReadingBlock(data);
|
|
137
|
-
if (reading) results.push(reading);
|
|
138
|
-
|
|
139
|
-
// If multiple intents detected with similar confidence, suggest composite
|
|
140
|
-
if (results.length > 1) {
|
|
141
|
-
const topConfidence = Math.max(...results.map((r) => r.confidence));
|
|
142
|
-
const close = results.filter((r) => topConfidence - r.confidence < 0.2);
|
|
143
|
-
if (close.length > 1) {
|
|
144
|
-
results.push({
|
|
145
|
-
intent: 'composite',
|
|
146
|
-
confidence: topConfidence * 0.9,
|
|
147
|
-
metadata: { subIntents: close.map((r) => r.intent) },
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Fallback: if nothing detected, default to json-tree
|
|
153
|
-
if (results.length === 0) {
|
|
154
|
-
results.push({
|
|
155
|
-
intent: 'json-tree',
|
|
156
|
-
confidence: 0.3,
|
|
157
|
-
metadata: { fallback: true },
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
return results.sort((a, b) => b.confidence - a.confidence);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** Detect intent from JSON Schema (for form rendering) */
|
|
165
|
-
export function sniffSchema(schema: Record<string, unknown>): SniffResult {
|
|
166
|
-
const props = schema['properties'] as Record<string, unknown> | undefined;
|
|
167
|
-
const propCount = props ? Object.keys(props).length : 0;
|
|
168
|
-
|
|
169
|
-
return {
|
|
170
|
-
intent: 'form',
|
|
171
|
-
confidence: 0.95,
|
|
172
|
-
metadata: {
|
|
173
|
-
propertyCount: propCount,
|
|
174
|
-
required: schema['required'] ?? [],
|
|
175
|
-
},
|
|
176
|
-
};
|
|
177
|
-
}
|
|
@@ -1,73 +0,0 @@
|
|
|
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
|
-
}
|