@tpitre/story-ui 4.13.0 → 4.13.2
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/dist/cli/index.js +0 -0
- package/dist/cli/setup.js +1 -1
- package/dist/story-generator/llm-providers/claude-provider.d.ts.map +1 -1
- package/dist/story-generator/llm-providers/claude-provider.js +31 -6
- package/dist/story-generator/llm-providers/settings-manager.d.ts.map +1 -1
- package/dist/story-generator/llm-providers/settings-manager.js +3 -2
- package/dist/story-generator/llm-providers/story-llm-service.js +2 -2
- package/dist/templates/StoryUI/StoryUIPanel.d.ts.map +1 -1
- package/dist/templates/StoryUI/StoryUIPanel.js +3 -1
- package/dist/templates/StoryUI/StoryUIPanel.tsx +3 -1
- package/dist/templates/StoryUI/voice/VoiceCanvas.d.ts.map +1 -1
- package/dist/templates/StoryUI/voice/VoiceCanvas.js +42 -9
- package/dist/templates/StoryUI/voice/VoiceCanvas.tsx +49 -8
- package/package.json +1 -1
- package/templates/StoryUI/StoryUIPanel.tsx +3 -1
- package/templates/StoryUI/voice/VoiceCanvas.tsx +49 -8
- package/dist/mcp-server/routes/convertToStory.d.ts +0 -17
- package/dist/mcp-server/routes/convertToStory.d.ts.map +0 -1
- package/dist/mcp-server/routes/convertToStory.js +0 -730
- package/dist/mcp-server/routes/voiceRender.d.ts +0 -19
- package/dist/mcp-server/routes/voiceRender.d.ts.map +0 -1
- package/dist/mcp-server/routes/voiceRender.js +0 -329
|
@@ -1,730 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Convert Voice Canvas HTML to Storybook Story (Hybrid Mode)
|
|
3
|
-
*
|
|
4
|
-
* Generates a hybrid story file with two exports:
|
|
5
|
-
* - Reference: iframe-based render of the exact voice canvas HTML (100% visual fidelity, no LLM)
|
|
6
|
-
* - Default: LLM-generated component-based implementation using the project's design system
|
|
7
|
-
*
|
|
8
|
-
* Unlike dumping HTML into the prompt field, this endpoint:
|
|
9
|
-
* - Parses the HTML structure and extracts semantic intent
|
|
10
|
-
* - Maps inline styles to design system tokens/props
|
|
11
|
-
* - Generates component-based code using actual imports
|
|
12
|
-
* - Preserves visual fidelity through structural constraints
|
|
13
|
-
* - Provides a pixel-perfect reference via iframe for comparison
|
|
14
|
-
*/
|
|
15
|
-
import * as cheerio from 'cheerio';
|
|
16
|
-
import * as crypto from 'crypto';
|
|
17
|
-
import { loadUserConfig } from '../../story-generator/configLoader.js';
|
|
18
|
-
import { generateStory as saveStory } from '../../story-generator/generateStory.js';
|
|
19
|
-
import { logger } from '../../story-generator/logger.js';
|
|
20
|
-
import { getProviderRegistry, initializeFromEnv, } from '../../story-generator/llm-providers/index.js';
|
|
21
|
-
import { EnhancedComponentDiscovery } from '../../story-generator/enhancedComponentDiscovery.js';
|
|
22
|
-
import { detectProjectFramework, } from '../../story-generator/promptGenerator.js';
|
|
23
|
-
// --- Design system token mappings ---
|
|
24
|
-
/** Common design system spacing scales (px -> token name) */
|
|
25
|
-
const SPACING_MAP = {
|
|
26
|
-
2: 'xxs', 4: 'xs', 8: 'sm', 12: 'md', 16: 'md', 20: 'lg', 24: 'lg',
|
|
27
|
-
32: 'xl', 40: 'xl', 48: '2xl', 64: '3xl', 80: '4xl', 96: '5xl',
|
|
28
|
-
};
|
|
29
|
-
/** Common font sizes (px -> token name) */
|
|
30
|
-
const FONT_SIZE_MAP = {
|
|
31
|
-
10: 'xs', 11: 'xs', 12: 'sm', 13: 'sm', 14: 'md', 15: 'md', 16: 'md',
|
|
32
|
-
18: 'lg', 20: 'xl', 24: '2xl', 28: '2xl', 30: '3xl', 32: '3xl',
|
|
33
|
-
36: '4xl', 40: '4xl', 48: '5xl',
|
|
34
|
-
};
|
|
35
|
-
/** Common border radii (px -> token name) */
|
|
36
|
-
const RADIUS_MAP = {
|
|
37
|
-
0: 'none', 2: 'xs', 4: 'sm', 6: 'md', 8: 'md', 10: 'lg', 12: 'lg',
|
|
38
|
-
16: 'xl', 20: 'xl', 24: '2xl', 9999: 'full',
|
|
39
|
-
};
|
|
40
|
-
/** Font weight names */
|
|
41
|
-
const WEIGHT_MAP = {
|
|
42
|
-
100: 'thin', 200: 'extralight', 300: 'light', 400: 'normal',
|
|
43
|
-
500: 'medium', 600: 'semibold', 700: 'bold', 800: 'extrabold', 900: 'black',
|
|
44
|
-
};
|
|
45
|
-
/** Known dark background colors (hex, lowercased) */
|
|
46
|
-
const DARK_BG_COLORS = [
|
|
47
|
-
'#0d1117', '#1a1b2e', '#111111', '#0a0a0a', '#121212', '#1e1e1e',
|
|
48
|
-
'#181818', '#1a1a2e', '#16213e', '#0f0f0f', '#0b0d17', '#1c1c1c',
|
|
49
|
-
'#212121', '#2d2d2d', '#1f1f1f', '#0e0e10', '#18181b',
|
|
50
|
-
];
|
|
51
|
-
/** Parse a hex color to RGB values */
|
|
52
|
-
function hexToRgb(hex) {
|
|
53
|
-
const clean = hex.replace('#', '');
|
|
54
|
-
let r, g, b;
|
|
55
|
-
if (clean.length === 3) {
|
|
56
|
-
r = parseInt(clean[0] + clean[0], 16);
|
|
57
|
-
g = parseInt(clean[1] + clean[1], 16);
|
|
58
|
-
b = parseInt(clean[2] + clean[2], 16);
|
|
59
|
-
}
|
|
60
|
-
else if (clean.length === 6) {
|
|
61
|
-
r = parseInt(clean.slice(0, 2), 16);
|
|
62
|
-
g = parseInt(clean.slice(2, 4), 16);
|
|
63
|
-
b = parseInt(clean.slice(4, 6), 16);
|
|
64
|
-
}
|
|
65
|
-
else {
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
return { r, g, b };
|
|
69
|
-
}
|
|
70
|
-
/** Map a hex color to the nearest design system semantic color */
|
|
71
|
-
function mapColorToToken(hex) {
|
|
72
|
-
const rgb = hexToRgb(hex);
|
|
73
|
-
if (!rgb)
|
|
74
|
-
return { token: hex, confidence: 'low' };
|
|
75
|
-
const { r, g, b } = rgb;
|
|
76
|
-
// Detect common color categories
|
|
77
|
-
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
|
78
|
-
const saturation = Math.max(r, g, b) - Math.min(r, g, b);
|
|
79
|
-
// Near-white
|
|
80
|
-
if (brightness > 240 && saturation < 20)
|
|
81
|
-
return { token: 'white', confidence: 'high' };
|
|
82
|
-
// Near-black
|
|
83
|
-
if (brightness < 30 && saturation < 20)
|
|
84
|
-
return { token: 'dark.9', confidence: 'high' };
|
|
85
|
-
// Grays (low saturation)
|
|
86
|
-
if (saturation < 30) {
|
|
87
|
-
if (brightness > 200)
|
|
88
|
-
return { token: 'gray.1', confidence: 'high' };
|
|
89
|
-
if (brightness > 160)
|
|
90
|
-
return { token: 'gray.3', confidence: 'high' };
|
|
91
|
-
if (brightness > 120)
|
|
92
|
-
return { token: 'gray.5', confidence: 'high' };
|
|
93
|
-
if (brightness > 80)
|
|
94
|
-
return { token: 'gray.7', confidence: 'high' };
|
|
95
|
-
return { token: 'gray.9', confidence: 'high' };
|
|
96
|
-
}
|
|
97
|
-
// Blues (common for primary)
|
|
98
|
-
if (b > r && b > g && b > 120) {
|
|
99
|
-
if (b > 200 && r < 100)
|
|
100
|
-
return { token: 'blue.5 (primary)', confidence: 'medium' };
|
|
101
|
-
if (b > 150)
|
|
102
|
-
return { token: 'blue.7', confidence: 'medium' };
|
|
103
|
-
return { token: 'blue.9', confidence: 'medium' };
|
|
104
|
-
}
|
|
105
|
-
// Greens (common for success)
|
|
106
|
-
if (g > r && g > b && g > 120) {
|
|
107
|
-
return { token: 'green.6 (success)', confidence: 'medium' };
|
|
108
|
-
}
|
|
109
|
-
// Reds (common for error/danger)
|
|
110
|
-
if (r > g && r > b && r > 150) {
|
|
111
|
-
return { token: 'red.6 (error)', confidence: 'medium' };
|
|
112
|
-
}
|
|
113
|
-
// Yellows/oranges (common for warning)
|
|
114
|
-
if (r > 180 && g > 120 && b < 100) {
|
|
115
|
-
return { token: 'yellow.6 (warning)', confidence: 'medium' };
|
|
116
|
-
}
|
|
117
|
-
// Purple
|
|
118
|
-
if (r > 100 && b > 100 && g < 100) {
|
|
119
|
-
return { token: 'violet.6', confidence: 'medium' };
|
|
120
|
-
}
|
|
121
|
-
return { token: hex, confidence: 'low' };
|
|
122
|
-
}
|
|
123
|
-
/** Parse CSS value to pixel number */
|
|
124
|
-
function parsePx(value) {
|
|
125
|
-
const match = value.match(/(\d+(?:\.\d+)?)\s*px/);
|
|
126
|
-
return match ? parseFloat(match[1]) : null;
|
|
127
|
-
}
|
|
128
|
-
/** Map inline CSS values to design system tokens */
|
|
129
|
-
function mapStylesToTokens(html) {
|
|
130
|
-
const $ = cheerio.load(html, { xml: false });
|
|
131
|
-
const mappings = [];
|
|
132
|
-
const seen = new Set();
|
|
133
|
-
$('[style]').each((_, el) => {
|
|
134
|
-
const style = $(el).attr('style') || '';
|
|
135
|
-
// Colors
|
|
136
|
-
const colorMatches = style.match(/#[0-9a-f]{3,8}/gi) || [];
|
|
137
|
-
for (const color of colorMatches) {
|
|
138
|
-
if (seen.has(`color:${color}`))
|
|
139
|
-
continue;
|
|
140
|
-
seen.add(`color:${color}`);
|
|
141
|
-
const mapped = mapColorToToken(color);
|
|
142
|
-
mappings.push({
|
|
143
|
-
cssValue: color,
|
|
144
|
-
tokenType: 'color',
|
|
145
|
-
suggestedToken: mapped.token,
|
|
146
|
-
confidence: mapped.confidence,
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
// Spacing (padding, margin, gap)
|
|
150
|
-
const spacingProps = style.match(/(?:padding|margin|gap)(?:-(?:top|right|bottom|left))?:\s*([^;]+)/gi) || [];
|
|
151
|
-
for (const prop of spacingProps) {
|
|
152
|
-
const px = parsePx(prop);
|
|
153
|
-
if (px !== null && !seen.has(`spacing:${px}`)) {
|
|
154
|
-
seen.add(`spacing:${px}`);
|
|
155
|
-
const nearest = Object.keys(SPACING_MAP).map(Number).sort((a, b) => Math.abs(a - px) - Math.abs(b - px))[0];
|
|
156
|
-
if (nearest !== undefined) {
|
|
157
|
-
mappings.push({
|
|
158
|
-
cssValue: `${px}px`,
|
|
159
|
-
tokenType: 'spacing',
|
|
160
|
-
suggestedToken: SPACING_MAP[nearest],
|
|
161
|
-
confidence: px === nearest ? 'high' : 'medium',
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
// Font sizes
|
|
167
|
-
const fontSizeMatch = style.match(/font-size:\s*([^;]+)/i);
|
|
168
|
-
if (fontSizeMatch) {
|
|
169
|
-
const px = parsePx(fontSizeMatch[1]);
|
|
170
|
-
if (px !== null && !seen.has(`fontSize:${px}`)) {
|
|
171
|
-
seen.add(`fontSize:${px}`);
|
|
172
|
-
const nearest = Object.keys(FONT_SIZE_MAP).map(Number).sort((a, b) => Math.abs(a - px) - Math.abs(b - px))[0];
|
|
173
|
-
if (nearest !== undefined) {
|
|
174
|
-
mappings.push({
|
|
175
|
-
cssValue: `${px}px`,
|
|
176
|
-
tokenType: 'fontSize',
|
|
177
|
-
suggestedToken: FONT_SIZE_MAP[nearest],
|
|
178
|
-
confidence: px === nearest ? 'high' : 'medium',
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
// Font weight
|
|
184
|
-
const weightMatch = style.match(/font-weight:\s*(\d+)/i);
|
|
185
|
-
if (weightMatch) {
|
|
186
|
-
const w = parseInt(weightMatch[1]);
|
|
187
|
-
if (!seen.has(`weight:${w}`)) {
|
|
188
|
-
seen.add(`weight:${w}`);
|
|
189
|
-
if (WEIGHT_MAP[w]) {
|
|
190
|
-
mappings.push({
|
|
191
|
-
cssValue: String(w),
|
|
192
|
-
tokenType: 'fontWeight',
|
|
193
|
-
suggestedToken: WEIGHT_MAP[w],
|
|
194
|
-
confidence: 'high',
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
// Border radius
|
|
200
|
-
const radiusMatch = style.match(/border-radius:\s*([^;]+)/i);
|
|
201
|
-
if (radiusMatch) {
|
|
202
|
-
const px = parsePx(radiusMatch[1]);
|
|
203
|
-
if (px !== null && !seen.has(`radius:${px}`)) {
|
|
204
|
-
seen.add(`radius:${px}`);
|
|
205
|
-
const nearest = Object.keys(RADIUS_MAP).map(Number).sort((a, b) => Math.abs(a - px) - Math.abs(b - px))[0];
|
|
206
|
-
if (nearest !== undefined) {
|
|
207
|
-
mappings.push({
|
|
208
|
-
cssValue: `${px}px`,
|
|
209
|
-
tokenType: 'radius',
|
|
210
|
-
suggestedToken: RADIUS_MAP[nearest],
|
|
211
|
-
confidence: px === nearest ? 'high' : 'medium',
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
});
|
|
217
|
-
return mappings;
|
|
218
|
-
}
|
|
219
|
-
/** Detect whether the HTML uses a dark theme based on background colors */
|
|
220
|
-
function detectDarkTheme(html) {
|
|
221
|
-
const $ = cheerio.load(html, { xml: false });
|
|
222
|
-
// Check body-level and root-level background styles
|
|
223
|
-
const allStyles = [];
|
|
224
|
-
$('body, [style]').each((_, el) => {
|
|
225
|
-
const style = $(el).attr('style') || '';
|
|
226
|
-
if (style)
|
|
227
|
-
allStyles.push(style.toLowerCase());
|
|
228
|
-
});
|
|
229
|
-
const allStyleText = allStyles.join(' ');
|
|
230
|
-
// Check for known dark background hex colors
|
|
231
|
-
for (const darkColor of DARK_BG_COLORS) {
|
|
232
|
-
if (allStyleText.includes(darkColor))
|
|
233
|
-
return true;
|
|
234
|
-
}
|
|
235
|
-
// Check for dark background via rgb values
|
|
236
|
-
const bgColorMatches = allStyleText.match(/background(?:-color)?:\s*([^;]+)/gi) || [];
|
|
237
|
-
for (const bgMatch of bgColorMatches) {
|
|
238
|
-
const hexColors = bgMatch.match(/#[0-9a-f]{3,8}/gi) || [];
|
|
239
|
-
for (const hex of hexColors) {
|
|
240
|
-
const rgb = hexToRgb(hex);
|
|
241
|
-
if (rgb) {
|
|
242
|
-
const brightness = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000;
|
|
243
|
-
if (brightness < 50)
|
|
244
|
-
return true;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
// Check rgb() values
|
|
248
|
-
const rgbMatch = bgMatch.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/i);
|
|
249
|
-
if (rgbMatch) {
|
|
250
|
-
const r = parseInt(rgbMatch[1]);
|
|
251
|
-
const g = parseInt(rgbMatch[2]);
|
|
252
|
-
const b = parseInt(rgbMatch[3]);
|
|
253
|
-
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
|
254
|
-
if (brightness < 50)
|
|
255
|
-
return true;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
return false;
|
|
259
|
-
}
|
|
260
|
-
/** Extract background gradients from HTML styles */
|
|
261
|
-
function extractGradients(html) {
|
|
262
|
-
const $ = cheerio.load(html, { xml: false });
|
|
263
|
-
const gradients = [];
|
|
264
|
-
const seen = new Set();
|
|
265
|
-
$('[style]').each((_, el) => {
|
|
266
|
-
const style = $(el).attr('style') || '';
|
|
267
|
-
const gradientMatches = style.match(/(?:linear|radial|conic)-gradient\([^)]+(?:\([^)]*\))*[^)]*\)/gi) || [];
|
|
268
|
-
for (const gradient of gradientMatches) {
|
|
269
|
-
if (!seen.has(gradient)) {
|
|
270
|
-
seen.add(gradient);
|
|
271
|
-
gradients.push(gradient);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
});
|
|
275
|
-
return gradients;
|
|
276
|
-
}
|
|
277
|
-
/** Analyze the HTML structure to build conversion constraints */
|
|
278
|
-
function analyzeHtml(html) {
|
|
279
|
-
const $ = cheerio.load(html, { xml: false });
|
|
280
|
-
// Extract sections
|
|
281
|
-
const sections = [];
|
|
282
|
-
$('[data-section]').each((_, el) => {
|
|
283
|
-
sections.push($(el).attr('data-section') || 'unknown');
|
|
284
|
-
});
|
|
285
|
-
// Extract unique element types
|
|
286
|
-
const elements = new Set();
|
|
287
|
-
$('*').each((_, el) => {
|
|
288
|
-
const tag = el.tagName;
|
|
289
|
-
if (tag && !['html', 'head', 'body', 'div', 'span'].includes(tag)) {
|
|
290
|
-
elements.add(tag);
|
|
291
|
-
}
|
|
292
|
-
});
|
|
293
|
-
// Extract inline style patterns
|
|
294
|
-
const colors = new Set();
|
|
295
|
-
const spacing = new Set();
|
|
296
|
-
const typography = new Set();
|
|
297
|
-
$('[style]').each((_, el) => {
|
|
298
|
-
const style = $(el).attr('style') || '';
|
|
299
|
-
// Colors
|
|
300
|
-
const colorMatches = style.match(/#[0-9a-f]{3,8}|rgb[a]?\([^)]+\)/gi);
|
|
301
|
-
if (colorMatches)
|
|
302
|
-
colorMatches.forEach(c => colors.add(c));
|
|
303
|
-
// Spacing
|
|
304
|
-
const spacingMatches = style.match(/(?:padding|margin|gap):\s*[^;]+/gi);
|
|
305
|
-
if (spacingMatches)
|
|
306
|
-
spacingMatches.forEach(s => spacing.add(s.trim()));
|
|
307
|
-
// Typography
|
|
308
|
-
const fontMatches = style.match(/font-(?:size|weight|family):\s*[^;]+/gi);
|
|
309
|
-
if (fontMatches)
|
|
310
|
-
fontMatches.forEach(f => typography.add(f.trim()));
|
|
311
|
-
});
|
|
312
|
-
// Detect layout type
|
|
313
|
-
let layout = 'vertical';
|
|
314
|
-
$('[style]').each((_, el) => {
|
|
315
|
-
const style = $(el).attr('style') || '';
|
|
316
|
-
if (style.includes('grid'))
|
|
317
|
-
layout = 'grid';
|
|
318
|
-
else if (style.includes('flex-direction: row') || style.includes('flex-direction:row'))
|
|
319
|
-
layout = 'horizontal';
|
|
320
|
-
});
|
|
321
|
-
// Map inline styles to design system tokens
|
|
322
|
-
const tokenMappings = mapStylesToTokens(html);
|
|
323
|
-
// Detect dark theme
|
|
324
|
-
const isDarkTheme = detectDarkTheme(html);
|
|
325
|
-
// Extract gradients
|
|
326
|
-
const backgroundGradients = extractGradients(html);
|
|
327
|
-
return {
|
|
328
|
-
sections,
|
|
329
|
-
elements: Array.from(elements),
|
|
330
|
-
layout,
|
|
331
|
-
colors: Array.from(colors).slice(0, 10),
|
|
332
|
-
spacing: Array.from(spacing).slice(0, 10),
|
|
333
|
-
typography: Array.from(typography).slice(0, 5),
|
|
334
|
-
tokenMappings,
|
|
335
|
-
isDarkTheme,
|
|
336
|
-
backgroundGradients,
|
|
337
|
-
};
|
|
338
|
-
}
|
|
339
|
-
/** Build a constraint document that guides the story generation LLM */
|
|
340
|
-
function buildConstraintDocument(html, analysis, designSystem) {
|
|
341
|
-
const $ = cheerio.load(html, { xml: false });
|
|
342
|
-
// Extract text content for each section to preserve copy
|
|
343
|
-
const sectionContents = [];
|
|
344
|
-
if (analysis.sections.length > 0) {
|
|
345
|
-
$('[data-section]').each((_, el) => {
|
|
346
|
-
const name = $(el).attr('data-section');
|
|
347
|
-
const texts = [];
|
|
348
|
-
$(el).find('h1, h2, h3, h4, h5, h6, p, span, a, button, label, li').each((_, textEl) => {
|
|
349
|
-
const text = $(textEl).text().trim();
|
|
350
|
-
if (text)
|
|
351
|
-
texts.push(text);
|
|
352
|
-
});
|
|
353
|
-
if (name && texts.length > 0) {
|
|
354
|
-
sectionContents.push(` - ${name}: ${texts.join(' | ')}`);
|
|
355
|
-
}
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
// Dark theme rules
|
|
359
|
-
const darkThemeRules = analysis.isDarkTheme
|
|
360
|
-
? `
|
|
361
|
-
DARK THEME DETECTED:
|
|
362
|
-
- The source HTML uses a dark color scheme. You MUST wrap the entire Implementation component in a MantineProvider with defaultColorScheme="dark" and forceColorScheme="dark".
|
|
363
|
-
- Example: <MantineProvider defaultColorScheme="dark" forceColorScheme="dark">...</MantineProvider>
|
|
364
|
-
- Import MantineProvider from ${designSystem === 'Mantine' ? '@mantine/core' : 'the design system provider'}.
|
|
365
|
-
- All text colors should be light (gray.1, gray.2, white) to contrast with dark backgrounds.
|
|
366
|
-
`
|
|
367
|
-
: '';
|
|
368
|
-
// Gradient rules
|
|
369
|
-
const gradientRules = analysis.backgroundGradients.length > 0
|
|
370
|
-
? `
|
|
371
|
-
GRADIENT PRESERVATION:
|
|
372
|
-
- The source HTML contains the following background gradients — preserve them as inline styles. Do NOT simplify gradients to flat colors:
|
|
373
|
-
${analysis.backgroundGradients.map(g => ` - ${g}`).join('\n')}
|
|
374
|
-
`
|
|
375
|
-
: '';
|
|
376
|
-
return `VOICE CANVAS CONVERSION — STRICT FIDELITY RULES
|
|
377
|
-
|
|
378
|
-
SOURCE HTML (the user created this via voice, it MUST be preserved exactly):
|
|
379
|
-
\`\`\`html
|
|
380
|
-
${html}
|
|
381
|
-
\`\`\`
|
|
382
|
-
|
|
383
|
-
STRUCTURAL ANALYSIS:
|
|
384
|
-
- Layout: ${analysis.layout}
|
|
385
|
-
- Sections: ${analysis.sections.length > 0 ? analysis.sections.join(', ') : 'unsectioned'}
|
|
386
|
-
- Element types: ${analysis.elements.join(', ')}
|
|
387
|
-
- Color palette: ${analysis.colors.join(', ') || 'default theme'}
|
|
388
|
-
- Theme: ${analysis.isDarkTheme ? 'DARK' : 'light'}
|
|
389
|
-
${darkThemeRules}
|
|
390
|
-
TEXT CONTENT (must appear exactly as shown):
|
|
391
|
-
${sectionContents.length > 0 ? sectionContents.join('\n') : ' (extract all text from the HTML above)'}
|
|
392
|
-
|
|
393
|
-
TOKEN MAPPING TABLE (use these exact mappings):
|
|
394
|
-
${analysis.tokenMappings.length > 0 ? analysis.tokenMappings.map(m => ` ${m.cssValue} -> ${m.suggestedToken} (${m.tokenType}, ${m.confidence} confidence)`).join('\n') : ' (no mappings — use design system defaults)'}
|
|
395
|
-
${gradientRules}
|
|
396
|
-
CONVERSION RULES:
|
|
397
|
-
1. PRESERVE EXACT TEXT — every heading, paragraph, label, and button text must match the source HTML character-for-character.
|
|
398
|
-
2. PRESERVE LAYOUT STRUCTURE — if the source has 3 columns, the story must have 3 columns. If it has a header above content, keep that order.
|
|
399
|
-
3. USE ${designSystem.toUpperCase()} COMPONENTS — map HTML elements to the closest design system component:
|
|
400
|
-
- <button> -> Button component
|
|
401
|
-
- <input> -> TextInput/Input component
|
|
402
|
-
- <img> -> Image component
|
|
403
|
-
- <h1-h6> -> Title/Heading component with appropriate level
|
|
404
|
-
- <p> -> Text component
|
|
405
|
-
- Cards, containers -> Card/Paper component
|
|
406
|
-
- Grid layouts -> Grid/SimpleGrid component
|
|
407
|
-
4. USE TOKEN MAPPINGS — apply the token mapping table above. For "high confidence" mappings, use the token directly. For "medium", use your best judgment. For "low" confidence, use the ORIGINAL CSS value as an inline style instead of guessing a token.
|
|
408
|
-
5. MAP SPACING TO SCALE — use the spacing tokens from the mapping table instead of pixel values.
|
|
409
|
-
6. DO NOT ADD ELEMENTS — do not add content, sections, or UI elements that aren't in the source HTML.
|
|
410
|
-
7. DO NOT REMOVE ELEMENTS — every visible element in the source must appear in the story.
|
|
411
|
-
8. DO NOT CHANGE COPY — the text content is final. Do not rephrase, shorten, or "improve" any text.
|
|
412
|
-
9. PRESERVE GRADIENTS — background gradients must be kept as inline styles exactly as they appear in the source. Do NOT flatten a gradient to a single solid color.
|
|
413
|
-
10. LOW CONFIDENCE FALLBACK — when a token mapping has "low" confidence, use the original CSS value as an inline style (e.g., style={{ backgroundColor: '#1a1b2e' }}) rather than guessing a design system token.`;
|
|
414
|
-
}
|
|
415
|
-
/** Escape backticks in HTML for embedding in a template literal */
|
|
416
|
-
function escapeForTemplateLiteral(html) {
|
|
417
|
-
return html.replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
|
418
|
-
}
|
|
419
|
-
/** Build the deterministic story file shell around the LLM-generated Implementation JSX */
|
|
420
|
-
function buildHybridStoryFile(opts) {
|
|
421
|
-
const { implementationJsx, escapedHtml, framework, importPath, storyPrefix, title, isDarkTheme, designSystem, } = opts;
|
|
422
|
-
const storybookFramework = framework === 'react' ? 'react' : framework;
|
|
423
|
-
// Build the iframe reference CSS shell (matches VoiceCanvas rendering environment)
|
|
424
|
-
const voiceHtmlLiteral = `\`<!DOCTYPE html>
|
|
425
|
-
<html><head><style>
|
|
426
|
-
*, *::before, *::after { box-sizing: border-box; }
|
|
427
|
-
body { margin: 0; padding: 24px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; line-height: 1.6; color: #c9d1d9; background: #0d1117; min-height: 100vh; }
|
|
428
|
-
img { max-width: 100%; height: auto; display: block; }
|
|
429
|
-
a { color: #58a6ff; text-decoration: none; }
|
|
430
|
-
</style></head><body>${escapedHtml}</body></html>\``;
|
|
431
|
-
// Only add MantineProvider import if the Implementation doesn't already import from the design system
|
|
432
|
-
const implHasDesignSystemImport = implementationJsx.includes(`from '${importPath}'`);
|
|
433
|
-
return `import React from 'react';
|
|
434
|
-
import type { Meta, StoryObj } from '@storybook/${storybookFramework}';
|
|
435
|
-
${implHasDesignSystemImport ? '' : `// Design system imports are included in the Implementation component below`}
|
|
436
|
-
|
|
437
|
-
const VOICE_HTML = ${voiceHtmlLiteral};
|
|
438
|
-
|
|
439
|
-
const VoiceReference = () => (
|
|
440
|
-
<iframe
|
|
441
|
-
srcDoc={VOICE_HTML}
|
|
442
|
-
style={{ width: '100%', height: '600px', border: 'none' }}
|
|
443
|
-
title="Voice Canvas Reference"
|
|
444
|
-
/>
|
|
445
|
-
);
|
|
446
|
-
|
|
447
|
-
${implementationJsx}
|
|
448
|
-
|
|
449
|
-
const meta: Meta = {
|
|
450
|
-
title: '${storyPrefix}${title}',
|
|
451
|
-
parameters: { layout: 'fullscreen' },
|
|
452
|
-
};
|
|
453
|
-
export default meta;
|
|
454
|
-
type Story = StoryObj;
|
|
455
|
-
|
|
456
|
-
export const Default: Story = {
|
|
457
|
-
render: () => <Implementation />,
|
|
458
|
-
};
|
|
459
|
-
|
|
460
|
-
export const Reference: Story = {
|
|
461
|
-
render: () => <VoiceReference />,
|
|
462
|
-
parameters: {
|
|
463
|
-
docs: {
|
|
464
|
-
description: {
|
|
465
|
-
story: 'Exact voice canvas output \\u2014 visual reference',
|
|
466
|
-
},
|
|
467
|
-
},
|
|
468
|
-
},
|
|
469
|
-
};
|
|
470
|
-
`;
|
|
471
|
-
}
|
|
472
|
-
function getProvider(providerType) {
|
|
473
|
-
let initialized = false;
|
|
474
|
-
if (!initialized) {
|
|
475
|
-
initializeFromEnv();
|
|
476
|
-
initialized = true;
|
|
477
|
-
}
|
|
478
|
-
const registry = getProviderRegistry();
|
|
479
|
-
if (providerType) {
|
|
480
|
-
const provider = registry.get(providerType);
|
|
481
|
-
if (provider?.isConfigured())
|
|
482
|
-
return provider;
|
|
483
|
-
}
|
|
484
|
-
const configured = registry.getConfiguredProviders();
|
|
485
|
-
if (configured.length > 0)
|
|
486
|
-
return configured[0];
|
|
487
|
-
throw new Error('No LLM provider configured');
|
|
488
|
-
}
|
|
489
|
-
export async function convertToStory(req, res) {
|
|
490
|
-
const { html, designSystem: requestedDs, provider: providerType, model: requestedModel, title: requestedTitle, } = req.body;
|
|
491
|
-
if (!html) {
|
|
492
|
-
res.status(400).json({ error: 'html is required' });
|
|
493
|
-
return;
|
|
494
|
-
}
|
|
495
|
-
// SSE headers
|
|
496
|
-
res.writeHead(200, {
|
|
497
|
-
'Content-Type': 'text/event-stream',
|
|
498
|
-
'Cache-Control': 'no-cache',
|
|
499
|
-
Connection: 'keep-alive',
|
|
500
|
-
'Access-Control-Allow-Origin': '*',
|
|
501
|
-
});
|
|
502
|
-
const sendEvent = (event, data) => {
|
|
503
|
-
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
504
|
-
};
|
|
505
|
-
try {
|
|
506
|
-
// Detect design system
|
|
507
|
-
let ds = requestedDs || 'Mantine';
|
|
508
|
-
let config = null;
|
|
509
|
-
try {
|
|
510
|
-
config = loadUserConfig();
|
|
511
|
-
if (!requestedDs && config?.importPath) {
|
|
512
|
-
if (config.importPath.includes('mantine'))
|
|
513
|
-
ds = 'Mantine';
|
|
514
|
-
else if (config.importPath.includes('vuetify'))
|
|
515
|
-
ds = 'Vuetify';
|
|
516
|
-
else if (config.importPath.includes('chakra'))
|
|
517
|
-
ds = 'Chakra UI';
|
|
518
|
-
else if (config.importPath.includes('mui') || config.importPath.includes('material'))
|
|
519
|
-
ds = 'Material UI';
|
|
520
|
-
else if (config.importPath.includes('flowbite'))
|
|
521
|
-
ds = 'Flowbite';
|
|
522
|
-
else if (config.importPath.includes('shoelace'))
|
|
523
|
-
ds = 'Shoelace';
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
catch {
|
|
527
|
-
// Use default
|
|
528
|
-
}
|
|
529
|
-
sendEvent('status', { phase: 'analyzing', message: 'Analyzing voice canvas HTML...' });
|
|
530
|
-
// Analyze the HTML structure
|
|
531
|
-
const analysis = analyzeHtml(html);
|
|
532
|
-
// Build constraint document
|
|
533
|
-
const constraintDoc = buildConstraintDocument(html, analysis, ds);
|
|
534
|
-
sendEvent('status', { phase: 'discovering', message: 'Discovering components...' });
|
|
535
|
-
// Discover available components
|
|
536
|
-
let componentList = '';
|
|
537
|
-
try {
|
|
538
|
-
if (config) {
|
|
539
|
-
const discovery = new EnhancedComponentDiscovery(config);
|
|
540
|
-
const components = await discovery.discoverAll();
|
|
541
|
-
if (components.length > 0) {
|
|
542
|
-
componentList = components.map((c) => c.name).join(', ');
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
catch {
|
|
547
|
-
componentList = '';
|
|
548
|
-
}
|
|
549
|
-
// Detect framework
|
|
550
|
-
const framework = config?.componentFramework || detectProjectFramework() || 'react';
|
|
551
|
-
const importPath = config?.importPath || '@mantine/core';
|
|
552
|
-
const importStyle = config?.importStyle || 'barrel';
|
|
553
|
-
const storyPrefix = config?.storyPrefix || 'Generated/';
|
|
554
|
-
sendEvent('status', { phase: 'converting', message: 'Converting to Storybook story...' });
|
|
555
|
-
const provider = getProvider(providerType);
|
|
556
|
-
const model = requestedModel || provider.getConfig().model;
|
|
557
|
-
// Generate a title from the HTML content
|
|
558
|
-
const titleFromHtml = requestedTitle || generateTitleFromHtml(html);
|
|
559
|
-
// Dark theme wrapping instructions for the LLM
|
|
560
|
-
const darkThemeInstructions = analysis.isDarkTheme && ds === 'Mantine'
|
|
561
|
-
? `
|
|
562
|
-
CRITICAL — DARK THEME: The source HTML uses a dark background. You MUST wrap all JSX inside the Implementation component with:
|
|
563
|
-
<MantineProvider defaultColorScheme="dark" forceColorScheme="dark">
|
|
564
|
-
...your component JSX...
|
|
565
|
-
</MantineProvider>
|
|
566
|
-
Import MantineProvider from '${importPath}' at the top of your code block.`
|
|
567
|
-
: '';
|
|
568
|
-
const systemPrompt = `You are a Storybook component generator. Convert the provided HTML into a React component named "Implementation" using ${ds} components.
|
|
569
|
-
|
|
570
|
-
FRAMEWORK: ${framework}
|
|
571
|
-
IMPORT PATH: ${importPath}
|
|
572
|
-
IMPORT STYLE: ${importStyle}
|
|
573
|
-
AVAILABLE COMPONENTS: ${componentList || 'Use standard ' + ds + ' components'}
|
|
574
|
-
|
|
575
|
-
OUTPUT FORMAT:
|
|
576
|
-
- Return ONLY the Implementation component definition and its required imports.
|
|
577
|
-
- The component must be a named function: const Implementation = () => ( ... );
|
|
578
|
-
- Include all necessary import statements at the top (design system components, React hooks, etc.)
|
|
579
|
-
- Do NOT include Meta, StoryObj, or story exports — those are handled externally.
|
|
580
|
-
- Do NOT include the VOICE_HTML constant or VoiceReference component — those are handled externally.
|
|
581
|
-
- Do NOT wrap your response in markdown code fences.
|
|
582
|
-
- Use TypeScript.
|
|
583
|
-
${darkThemeInstructions}
|
|
584
|
-
|
|
585
|
-
${constraintDoc}`;
|
|
586
|
-
const messages = [
|
|
587
|
-
{
|
|
588
|
-
role: 'user',
|
|
589
|
-
content: `Convert this voice-generated HTML into an Implementation component. Return ONLY the imports and the "const Implementation = () => (...)" definition. Follow the constraint document in the system prompt exactly.`,
|
|
590
|
-
},
|
|
591
|
-
];
|
|
592
|
-
// Stream the response
|
|
593
|
-
let fullResponse = '';
|
|
594
|
-
const startTime = Date.now();
|
|
595
|
-
if (!provider.chatStream) {
|
|
596
|
-
const response = await provider.chat(messages, {
|
|
597
|
-
model,
|
|
598
|
-
maxTokens: 8192,
|
|
599
|
-
temperature: 0.2,
|
|
600
|
-
systemPrompt,
|
|
601
|
-
});
|
|
602
|
-
fullResponse = response.content;
|
|
603
|
-
sendEvent('story_chunk', { content: fullResponse });
|
|
604
|
-
}
|
|
605
|
-
else {
|
|
606
|
-
const stream = provider.chatStream(messages, {
|
|
607
|
-
model,
|
|
608
|
-
maxTokens: 8192,
|
|
609
|
-
temperature: 0.2,
|
|
610
|
-
systemPrompt,
|
|
611
|
-
});
|
|
612
|
-
for await (const chunk of stream) {
|
|
613
|
-
if (chunk.type === 'text' && chunk.content) {
|
|
614
|
-
fullResponse += chunk.content;
|
|
615
|
-
sendEvent('story_chunk', { content: chunk.content });
|
|
616
|
-
}
|
|
617
|
-
else if (chunk.type === 'error') {
|
|
618
|
-
sendEvent('error', { message: chunk.error });
|
|
619
|
-
break;
|
|
620
|
-
}
|
|
621
|
-
else if (chunk.type === 'done') {
|
|
622
|
-
// done
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
const elapsed = Date.now() - startTime;
|
|
627
|
-
// Clean up markdown fences from LLM output
|
|
628
|
-
let implementationJsx = fullResponse.trim();
|
|
629
|
-
if (implementationJsx.startsWith('```tsx') || implementationJsx.startsWith('```typescript')) {
|
|
630
|
-
implementationJsx = implementationJsx.replace(/^```\w*\n?/, '');
|
|
631
|
-
}
|
|
632
|
-
else if (implementationJsx.startsWith('```')) {
|
|
633
|
-
implementationJsx = implementationJsx.slice(3);
|
|
634
|
-
}
|
|
635
|
-
if (implementationJsx.endsWith('```')) {
|
|
636
|
-
implementationJsx = implementationJsx.slice(0, -3);
|
|
637
|
-
}
|
|
638
|
-
implementationJsx = implementationJsx.trim();
|
|
639
|
-
// Remove imports that the shell already provides (React, Meta, StoryObj)
|
|
640
|
-
// The shell imports: React, Meta, StoryObj, and optionally MantineProvider
|
|
641
|
-
implementationJsx = implementationJsx.replace(/^import\s+React(?:\s*,\s*\{[^}]*\})?\s+from\s+['"]react['"];\s*\n?/gm, '');
|
|
642
|
-
implementationJsx = implementationJsx.replace(/^import\s+(?:type\s+)?\{[^}]*(?:Meta|StoryObj|StoryFn)[^}]*\}\s+from\s+['"]@storybook\/[^'"]+['"];\s*\n?/gm, '');
|
|
643
|
-
// Remove duplicate export default meta, type Story, export const
|
|
644
|
-
implementationJsx = implementationJsx.replace(/^(?:export\s+default\s+meta\s*;|type\s+Story\s*=\s*StoryObj[^;]*;|export\s+const\s+\w+:\s*Story\s*=\s*\{[^}]*\};\s*)\n?/gm, '');
|
|
645
|
-
// Remove const meta: Meta = { ... }; export default meta; blocks
|
|
646
|
-
implementationJsx = implementationJsx.replace(/^const\s+meta:\s*Meta[^=]*=\s*\{[^}]*\};\s*\n?/gm, '');
|
|
647
|
-
// Escape HTML for the template literal
|
|
648
|
-
const escapedHtml = escapeForTemplateLiteral(html);
|
|
649
|
-
// Build the complete hybrid story file deterministically
|
|
650
|
-
const cleanStory = buildHybridStoryFile({
|
|
651
|
-
implementationJsx,
|
|
652
|
-
escapedHtml,
|
|
653
|
-
framework,
|
|
654
|
-
importPath,
|
|
655
|
-
storyPrefix,
|
|
656
|
-
title: titleFromHtml,
|
|
657
|
-
isDarkTheme: analysis.isDarkTheme,
|
|
658
|
-
designSystem: ds,
|
|
659
|
-
});
|
|
660
|
-
// Save the story to disk
|
|
661
|
-
const storyId = `voice-${crypto.randomBytes(4).toString('hex')}`;
|
|
662
|
-
const safeTitle = titleFromHtml.replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-');
|
|
663
|
-
const fileName = `${safeTitle}-${storyId}.stories.tsx`;
|
|
664
|
-
let savedPath = '';
|
|
665
|
-
try {
|
|
666
|
-
if (config) {
|
|
667
|
-
savedPath = saveStory({ fileContents: cleanStory, fileName, config });
|
|
668
|
-
sendEvent('status', { phase: 'saved', message: `Story saved: ${fileName}` });
|
|
669
|
-
logger.log(`Voice story saved to: ${savedPath}`);
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
catch (saveErr) {
|
|
673
|
-
logger.error('Failed to save voice story:', saveErr instanceof Error ? saveErr.message : String(saveErr));
|
|
674
|
-
// Don't fail the whole request — still return the story code
|
|
675
|
-
}
|
|
676
|
-
sendEvent('complete', {
|
|
677
|
-
story: cleanStory,
|
|
678
|
-
title: titleFromHtml,
|
|
679
|
-
fileName,
|
|
680
|
-
storyId,
|
|
681
|
-
savedPath,
|
|
682
|
-
sourceHtml: html,
|
|
683
|
-
analysis: {
|
|
684
|
-
sections: analysis.sections,
|
|
685
|
-
layout: analysis.layout,
|
|
686
|
-
elementCount: analysis.elements.length,
|
|
687
|
-
isDarkTheme: analysis.isDarkTheme,
|
|
688
|
-
},
|
|
689
|
-
metrics: {
|
|
690
|
-
timeMs: elapsed,
|
|
691
|
-
model,
|
|
692
|
-
provider: provider.type,
|
|
693
|
-
},
|
|
694
|
-
});
|
|
695
|
-
logger.log(`Voice-to-story conversion: ${elapsed}ms using ${provider.type}/${model}`);
|
|
696
|
-
}
|
|
697
|
-
catch (error) {
|
|
698
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
699
|
-
logger.error('Convert-to-story error:', message);
|
|
700
|
-
sendEvent('error', { message });
|
|
701
|
-
}
|
|
702
|
-
finally {
|
|
703
|
-
res.end();
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
/** Generate a human-readable title from HTML content */
|
|
707
|
-
function generateTitleFromHtml(html) {
|
|
708
|
-
const $ = cheerio.load(html, { xml: false });
|
|
709
|
-
// Try to find a heading
|
|
710
|
-
const h1 = $('h1').first().text().trim();
|
|
711
|
-
if (h1)
|
|
712
|
-
return sanitizeTitle(h1);
|
|
713
|
-
const h2 = $('h2').first().text().trim();
|
|
714
|
-
if (h2)
|
|
715
|
-
return sanitizeTitle(h2);
|
|
716
|
-
// Try data-section attributes
|
|
717
|
-
const sections = $('[data-section]').map((_, el) => $(el).attr('data-section')).get();
|
|
718
|
-
if (sections.length > 0) {
|
|
719
|
-
return sections.map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' ');
|
|
720
|
-
}
|
|
721
|
-
return 'Voice Generated';
|
|
722
|
-
}
|
|
723
|
-
function sanitizeTitle(text) {
|
|
724
|
-
return text
|
|
725
|
-
.replace(/[^a-zA-Z0-9\s-]/g, '')
|
|
726
|
-
.trim()
|
|
727
|
-
.split(/\s+/)
|
|
728
|
-
.slice(0, 5)
|
|
729
|
-
.join(' ');
|
|
730
|
-
}
|