/** * Shared preview building blocks for theme editor preview renderers. * Used by both `createThemePreview()` (simple API) and the configurator's * advanced preview system. Separate file for code-splitting. */ import type { AgentWidgetConfig } from '../types'; import type { AgentWidgetMessage } from '../types'; import { createTheme } from '../utils/theme'; import { DEFAULT_WIDGET_CONFIG } from '../defaults'; // ─── Constants ────────────────────────────────────────────────── export const DEVICE_DIMENSIONS: Record = { desktop: { w: 1280, h: 800 }, mobile: { w: 390, h: 844 }, }; export const ZOOM_MIN = 0.15; export const ZOOM_MAX = 1.5; export const SHELL_STYLE_ID = 'persona-preview-shell-theme'; export const PREVIEW_STORAGE_ADAPTER = { load: () => null, save: () => {}, clear: () => {}, }; export const HOME_SUGGESTION_CHIPS = [ 'How do I get started?', 'Pricing & plans', 'Talk to support', ]; // ─── HTML Escaping ────────────────────────────────────────────── export function escapeHtml(str: string): string { return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); } // ─── Shell Theme ──────────────────────────────────────────────── export type PreviewShellPalette = { pageBg: string; chromeBg: string; chromeBorder: string; dot: string; skeleton: string; cardBg: string; cardBorder: string; }; export function getShellPalette(shellMode: 'light' | 'dark'): PreviewShellPalette { return shellMode === 'dark' ? { pageBg: 'linear-gradient(180deg, #0f172a 0%, #020617 100%)', chromeBg: '#111827', chromeBorder: '#1f2937', dot: '#475569', skeleton: '#334155', cardBg: '#1e293b', cardBorder: 'rgba(148, 163, 184, 0.16)', } : { pageBg: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)', chromeBg: '#ffffff', chromeBorder: '#e5e7eb', dot: '#cbd5e1', skeleton: '#e2e8f0', cardBg: '#e2e8f0', cardBorder: 'rgba(148, 163, 184, 0.18)', }; } export function buildShellCss(shellMode: 'light' | 'dark'): string { const t = getShellPalette(shellMode); return `* { box-sizing: border-box; } html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; } html { color-scheme: ${shellMode}; } body { font-family: system-ui, sans-serif; background: ${t.pageBg}; } .preview-iframe-mock { min-height: 100%; } .preview-iframe-chrome { height: 44px; border-bottom: 1px solid ${t.chromeBorder}; background: ${t.chromeBg}; display: flex; align-items: center; gap: 8px; padding: 0 14px; } .preview-iframe-dot { width: 10px; height: 10px; border-radius: 50%; background: ${t.dot}; } .preview-iframe-copy { padding: 32px; } .preview-iframe-line { border-radius: 999px; background: ${t.skeleton}; margin-bottom: 12px; } .preview-iframe-line.hero { width: 48%; height: 16px; } .preview-iframe-line.body { width: 72%; height: 10px; } .preview-iframe-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 24px 0; } .preview-iframe-card { height: 84px; border-radius: 14px; background: ${t.cardBg}; box-shadow: inset 0 0 0 1px ${t.cardBorder}; } .preview-workspace-shell { height: 100%; min-height: 100%; display: flex; flex-direction: column; } .preview-workspace-topbar { height: 52px; flex-shrink: 0; border-bottom: 1px solid ${t.chromeBorder}; background: ${t.chromeBg}; display: flex; align-items: center; justify-content: space-between; padding: 0 18px; } .preview-workspace-topbar-left { display: flex; align-items: center; gap: 12px; } .preview-workspace-topbar-badge { width: 18px; height: 18px; border-radius: 6px; background: ${t.cardBg}; box-shadow: inset 0 0 0 1px ${t.cardBorder}; } .preview-workspace-topbar-line { width: 180px; height: 10px; border-radius: 999px; background: ${t.skeleton}; } .preview-workspace-topbar-pill { width: 64px; height: 28px; border-radius: 999px; background: ${t.cardBg}; box-shadow: inset 0 0 0 1px ${t.cardBorder}; } .preview-workspace-body { flex: 1; min-height: 0; display: flex; padding: 20px; } .preview-workspace-content { position: relative; display: flex; flex-direction: column; flex: 1; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; border-radius: 24px; background: rgba(255,255,255,0.72); box-shadow: inset 0 0 0 1px ${t.cardBorder}; } .preview-workspace-content-shell { position: relative; z-index: 1; flex: 1 1 auto; min-height: 100%; padding: 24px; } .preview-workspace-row { display: flex; gap: 16px; margin-top: 20px; } .preview-workspace-card { flex: 1; min-width: 0; height: 168px; border-radius: 18px; background: ${t.cardBg}; box-shadow: inset 0 0 0 1px ${t.cardBorder}; } .preview-workspace-card.short { height: 96px; }`; } export function applyShellTheme(iframe: HTMLIFrameElement, shellMode: 'light' | 'dark'): void { const doc = iframe.contentDocument; if (!doc?.documentElement) return; let style = doc.getElementById(SHELL_STYLE_ID) as HTMLStyleElement | null; if (!style) { style = doc.createElement('style'); style.id = SHELL_STYLE_ID; doc.head.appendChild(style); } style.textContent = buildShellCss(shellMode); } // ─── Mock HTML Templates ──────────────────────────────────────── /** Browser chrome mock with skeleton content cards */ export const MOCK_BROWSER_CONTENT = ` `; /** Docked workspace skeleton (cards + rows inside content area) */ export const MOCK_WORKSPACE_CONTENT = ` `; // ─── Srcdoc Builder ───────────────────────────────────────────── /** * Build a basic iframe srcdoc with mock page chrome and widget mount point. * For advanced use cases (background URLs, embed detection), build custom srcdoc * using the exported templates and shell CSS utilities. */ export function buildSrcdoc( mountId: string, shellMode: 'light' | 'dark', docked: boolean, widgetCssPath: string ): string { const floatingContent = ` ${MOCK_BROWSER_CONTENT}
`; const dockedContent = `
${MOCK_WORKSPACE_CONTENT}
`; return ` ${docked ? dockedContent : floatingContent} `; } // ─── Preview Messages ─────────────────────────────────────────── export type PreviewScene = 'home' | 'conversation' | 'minimized' | 'artifact'; export type PreviewTranscriptEntryPreset = | 'user-message' | 'assistant-message' | 'assistant-code-block' | 'assistant-markdown-table' | 'assistant-image' | 'reasoning-streaming' | 'reasoning-complete' | 'tool-running' | 'tool-complete' | 'approval-request'; const PREVIEW_TRANSCRIPT_PRESET_LABELS: Record = { 'user-message': 'User message', 'assistant-message': 'Assistant message', 'assistant-code-block': 'Assistant: code block', 'assistant-markdown-table': 'Assistant: markdown table', 'assistant-image': 'Assistant: image', 'reasoning-streaming': 'Reasoning (streaming)', 'reasoning-complete': 'Reasoning (complete)', 'tool-running': 'Tool call (running)', 'tool-complete': 'Tool call (complete)', 'approval-request': 'Approval request', }; export function getPreviewTranscriptPresetLabel(preset: PreviewTranscriptEntryPreset): string { return PREVIEW_TRANSCRIPT_PRESET_LABELS[preset]; } export function createPreviewTranscriptEntry( preset: PreviewTranscriptEntryPreset, index = 0 ): AgentWidgetMessage { const createdAt = new Date(Date.now() - Math.max(0, 60 - index) * 1000).toISOString(); const suffix = `${preset}-${index}`; switch (preset) { case 'user-message': return { id: `preview-seq-user-${suffix}`, role: 'user', content: 'Can you continue with the next step?', createdAt, }; case 'assistant-message': return { id: `preview-seq-assistant-${suffix}`, role: 'assistant', content: 'Absolutely. I can keep going and explain what happens next.', createdAt, }; case 'assistant-code-block': return { id: `preview-seq-assistant-code-${suffix}`, role: 'assistant', content: [ "Here's how you'd wire up a streaming animation:", '', '```ts', "import { createAgentExperience } from '@runtypelabs/persona';", '', 'createAgentExperience(el, {', ' features: {', ' streamAnimation: { type: "letter-rise", speed: 120 },', ' },', '});', '```', '', 'Swap the `type` value to try the other presets.', ].join('\n'), createdAt, }; case 'assistant-markdown-table': return { id: `preview-seq-assistant-table-${suffix}`, role: 'assistant', content: [ 'Here are the built-in streaming animations at a glance:', '', '| Preset | Wrap unit | Best for |', '| ------------ | --------- | --------------------------- |', '| Typewriter | Character | Classic terminal feel |', '| Letter rise | Character | Soft, staggered entrance |', '| Word fade | Word | Longer-form assistant replies |', '| Pop bubble | Bubble | Short, punchy affirmations |', ].join('\n'), createdAt, }; case 'assistant-image': return { id: `preview-seq-assistant-image-${suffix}`, role: 'assistant', content: [ "Here's the reference diagram you asked for: let me know if you'd like a different view:", '', '![Stream animation reference](https://placehold.co/320x200/png?text=Stream+Animation)', '', 'The gradient shows how per-unit delays stagger across the reply.', ].join('\n'), createdAt, }; case 'reasoning-streaming': return { id: `preview-seq-reasoning-stream-${suffix}`, role: 'assistant', content: '', createdAt, streaming: true, variant: 'reasoning', reasoning: { id: `preview-reasoning-stream-${suffix}`, status: 'streaming', chunks: ['Thinking through the next step in the workflow...'], }, }; case 'reasoning-complete': return { id: `preview-seq-reasoning-complete-${suffix}`, role: 'assistant', content: '', createdAt, streaming: false, variant: 'reasoning', reasoning: { id: `preview-reasoning-complete-${suffix}`, status: 'complete', chunks: ['Reviewed the requirements and finalized the reasoning output.'], durationMs: 1200, }, }; case 'approval-request': { // Use the `approval-` id convention so the optimistic status // flip in `session.resolveApproval` updates this bubble in place when the // Approve/Deny buttons are clicked. const approvalId = `preview-approval-${suffix}`; return { id: `approval-${approvalId}`, role: 'assistant', content: '', createdAt, streaming: false, variant: 'approval', approval: { id: approvalId, status: 'pending', agentId: 'preview-agent', executionId: `preview-exec-${suffix}`, toolName: 'add_to_cart', description: 'Add the selected item to the shopping cart. Approve to let the assistant continue.', parameters: { items: [{ productEntityId: 129, quantity: 1 }], }, }, }; } case 'tool-complete': return { id: `preview-seq-tool-complete-${suffix}`, role: 'assistant', content: '', createdAt, streaming: false, variant: 'tool', toolCall: { id: `preview-tool-complete-${suffix}`, name: 'Create build instructions', status: 'complete', chunks: ['Prepared the build instructions and validated the inputs.'], result: { ok: true }, duration: 420, }, }; case 'tool-running': default: return { id: `preview-seq-tool-running-${suffix}`, role: 'assistant', content: '', createdAt, streaming: true, variant: 'tool', toolCall: { id: `preview-tool-running-${suffix}`, name: 'Get platform documentation', status: 'running', chunks: ['Fetching the relevant platform documentation...'], }, }; } } export function appendPreviewTranscriptEntry( messages: AgentWidgetMessage[], preset: PreviewTranscriptEntryPreset ): AgentWidgetMessage[] { return [...messages, createPreviewTranscriptEntry(preset, messages.length)]; } /** Presets whose assistant content should stream in so Stream Animation settings engage. */ export function presetStreamsText(preset: PreviewTranscriptEntryPreset): boolean { return ( preset === 'assistant-message' || preset === 'assistant-code-block' || preset === 'assistant-markdown-table' || preset === 'assistant-image' ); } export interface TranscriptStreamFrame { /** Message to upsert into the session. */ message: AgentWidgetMessage; /** Delay from the previous frame in ms. The first frame uses 0. */ delayMs: number; /** True when this is the final frame (message is no longer streaming). */ done: boolean; } export interface BuildTranscriptStreamFramesOptions { /** Characters per progressive chunk. Default: 24. */ chunkSize?: number; /** Delay between chunks in ms. Default: 42. */ delayMs?: number; } /** * Builds progressive snapshots for a transcript preset suitable for feeding into * `injectTestMessage({ type: 'message', message })` on a timer. Each frame upserts * the same message id with more content, ending with `streaming: false`. * * - Streaming-capable presets (assistant text) yield many frames. * - All other presets yield a single `done` frame matching `createPreviewTranscriptEntry`. */ export function buildTranscriptStreamFrames( preset: PreviewTranscriptEntryPreset, suffix: number, options?: BuildTranscriptStreamFramesOptions ): TranscriptStreamFrame[] { const completed = createPreviewTranscriptEntry(preset, suffix); if (!presetStreamsText(preset) || typeof completed.content !== 'string') { return [{ message: completed, delayMs: 0, done: true }]; } const chunkSize = Math.max(1, options?.chunkSize ?? 24); const delayMs = Math.max(0, options?.delayMs ?? 42); const fullText = completed.content; const frames: TranscriptStreamFrame[] = []; // Seed with an empty streaming bubble so the animation plugin can attach from the first tick. frames.push({ message: { ...completed, content: '', streaming: true }, delayMs: 0, done: false, }); for (let i = chunkSize; i < fullText.length; i += chunkSize) { frames.push({ message: { ...completed, content: fullText.slice(0, i), streaming: true }, delayMs, done: false, }); } frames.push({ message: { ...completed, content: fullText, streaming: false }, delayMs, done: true, }); return frames; } const createAdvancedTranscriptPreviewMessages = (): AgentWidgetMessage[] => [ { id: "preview-adv-1", role: "user", content: "Can you create the product and gather the docs?", createdAt: new Date(Date.now() - 180000).toISOString(), }, { id: "preview-adv-2", role: "assistant", content: "", createdAt: new Date(Date.now() - 150000).toISOString(), streaming: true, variant: "reasoning", reasoning: { id: "preview-reasoning", status: "streaming", chunks: [ "Now let me get the Persona embed documentation and builtin tools catalog.", ], }, }, { id: "preview-adv-3", role: "assistant", content: "", createdAt: new Date(Date.now() - 120000).toISOString(), streaming: true, variant: "tool", toolCall: { id: "preview-tool-1", name: "Load tools", status: "running", chunks: ["Loaded tools, used Runtype integration"], }, }, { id: "preview-adv-4", role: "assistant", content: "", createdAt: new Date(Date.now() - 90000).toISOString(), streaming: true, variant: "tool", toolCall: { id: "preview-tool-2", name: "Get platform documentation", status: "running", chunks: ["Get platform documentation"], }, }, { id: "preview-adv-5", role: "assistant", content: "I loaded the tools and fetched the docs. Next I can assemble the product details.", createdAt: new Date(Date.now() - 30000).toISOString(), }, ]; const shouldSeedAdvancedTranscriptPreview = ( config?: Partial ): boolean => Boolean( config?.features?.toolCallDisplay?.activePreview || config?.features?.toolCallDisplay?.grouped || (config?.features?.toolCallDisplay?.collapsedMode && config.features.toolCallDisplay.collapsedMode !== "tool-call") || config?.features?.reasoningDisplay?.activePreview ); export function createPreviewMessages( scene: PreviewScene, config?: Partial, appendedMessages: AgentWidgetMessage[] = [] ): AgentWidgetMessage[] { if (scene === 'home') { return [{ id: 'preview-home-1', role: 'assistant', content: 'Hi there! How can we help today?', createdAt: new Date().toISOString() }]; } if (scene === 'minimized') { return [{ id: 'preview-min-1', role: 'assistant', content: 'We are here whenever you are ready.', createdAt: new Date().toISOString() }]; } if (scene === 'artifact') { return [ { id: 'preview-art-1', role: 'user', content: 'Can you draft a quick overview of the project?', createdAt: new Date(Date.now() - 120000).toISOString() }, { id: 'preview-art-2', role: 'assistant', content: 'Here\u2019s a project overview document for you.', createdAt: new Date(Date.now() - 60000).toISOString() }, ]; } if (scene === 'conversation' && shouldSeedAdvancedTranscriptPreview(config)) { return [...createAdvancedTranscriptPreviewMessages(), ...appendedMessages]; } return [ { id: 'preview-conv-1', role: 'assistant', content: 'Hello! How can I help you today?', createdAt: new Date(Date.now() - 180000).toISOString() }, { id: 'preview-conv-2', role: 'user', content: 'I want to customize the theme editor preview.', createdAt: new Date(Date.now() - 120000).toISOString() }, { id: 'preview-conv-3', role: 'assistant', content: 'Absolutely. Check out the [getting started guide](https://example.com) to see what\u2019s possible, then adjust colors and tokens to match your brand.', createdAt: new Date(Date.now() - 60000).toISOString() }, ...appendedMessages, ]; } // ─── Scene Config ─────────────────────────────────────────────── export function applySceneConfig( base: AgentWidgetConfig, scene: PreviewScene, appendedMessages: AgentWidgetMessage[] = [] ): AgentWidgetConfig { const launcher = { ...base.launcher, enabled: true, autoExpand: scene !== 'minimized' }; const config = { ...base, launcher, suggestionChips: scene === 'home' ? (base.suggestionChips?.length ? base.suggestionChips : HOME_SUGGESTION_CHIPS) : base.suggestionChips, initialMessages: createPreviewMessages(scene, base, appendedMessages), storageAdapter: PREVIEW_STORAGE_ADAPTER, } as AgentWidgetConfig; if (scene === 'artifact') { config.features = { ...config.features, artifacts: { ...config.features?.artifacts, enabled: true } }; } return config; } // ─── Preview Config Builder ───────────────────────────────────── import type { DeepPartial, PersonaTheme } from '../types/theme'; export interface PreviewConfigOptions { config?: Partial; theme?: DeepPartial; darkTheme?: DeepPartial; scene?: PreviewScene; appendedMessages?: AgentWidgetMessage[]; } function buildPreviewBaseConfig( options: PreviewConfigOptions, shellModeOverride?: 'light' | 'dark' ): AgentWidgetConfig { const theme = options.theme ? createTheme(options.theme, { validate: false }) : createTheme(); return { ...DEFAULT_WIDGET_CONFIG, ...options.config, theme, darkTheme: options.darkTheme, colorScheme: shellModeOverride ?? (options.config?.colorScheme as string) ?? 'light', } as AgentWidgetConfig; } export function buildPreviewConfig( options: PreviewConfigOptions, shellModeOverride?: 'light' | 'dark' ): AgentWidgetConfig { const scene = options.scene ?? 'conversation'; return applySceneConfig( buildPreviewBaseConfig(options, shellModeOverride), scene, options.appendedMessages ?? [] ); } export function buildPreviewConfigWithMessages( options: PreviewConfigOptions, messages: AgentWidgetMessage[], shellModeOverride?: 'light' | 'dark' ): AgentWidgetConfig { const scene = options.scene ?? 'conversation'; return applySceneConfig( buildPreviewBaseConfig(options, shellModeOverride), scene, messages ); }