@runtypelabs/persona 3.5.1 → 3.6.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.
Files changed (45) hide show
  1. package/dist/index.cjs +30 -30
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +14 -0
  4. package/dist/index.d.ts +14 -0
  5. package/dist/index.global.js +41 -41
  6. package/dist/index.global.js.map +1 -1
  7. package/dist/index.js +29 -29
  8. package/dist/index.js.map +1 -1
  9. package/dist/theme-editor.cjs +17728 -0
  10. package/dist/theme-editor.d.cts +3857 -0
  11. package/dist/theme-editor.d.ts +3857 -0
  12. package/dist/theme-editor.js +17623 -0
  13. package/dist/theme-reference.cjs +1 -1
  14. package/dist/theme-reference.d.cts +14 -0
  15. package/dist/theme-reference.d.ts +14 -0
  16. package/dist/theme-reference.js +1 -1
  17. package/dist/widget.css +29 -25
  18. package/package.json +9 -7
  19. package/src/components/artifact-card.ts +1 -1
  20. package/src/components/composer-builder.ts +16 -29
  21. package/src/components/demo-carousel.ts +4 -4
  22. package/src/components/event-stream-view.ts +1 -1
  23. package/src/components/header-builder.ts +2 -2
  24. package/src/components/launcher.ts +9 -0
  25. package/src/components/message-bubble.ts +9 -3
  26. package/src/components/suggestions.ts +1 -1
  27. package/src/defaults.ts +9 -9
  28. package/src/styles/widget.css +29 -25
  29. package/src/theme-editor/color-utils.ts +252 -0
  30. package/src/theme-editor/index.ts +130 -0
  31. package/src/theme-editor/presets.ts +144 -0
  32. package/src/theme-editor/preview-utils.ts +265 -0
  33. package/src/theme-editor/preview.ts +445 -0
  34. package/src/theme-editor/role-mappings.ts +331 -0
  35. package/src/theme-editor/sections.ts +952 -0
  36. package/src/theme-editor/state.ts +298 -0
  37. package/src/theme-editor/types.ts +177 -0
  38. package/src/theme-editor.ts +2 -0
  39. package/src/types/theme.ts +1 -0
  40. package/src/ui.ts +53 -58
  41. package/src/utils/plugins.ts +1 -1
  42. package/src/utils/theme.test.ts +10 -8
  43. package/src/utils/theme.ts +11 -11
  44. package/src/utils/tokens.ts +88 -41
  45. package/widget.css +0 -1
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Imperative preview renderer for the theme editor.
3
+ * Manages iframe-based widget previews with device frames, zoom, scenes, and compare mode.
4
+ * No external DOM dependencies — only needs a container element to mount into.
5
+ *
6
+ * For advanced preview needs (background URLs, inline editing, contrast checking),
7
+ * use the lifecycle hooks in `ThemePreviewOptions` and import shared building blocks
8
+ * from `./preview-utils` directly.
9
+ */
10
+
11
+ import type { AgentWidgetConfig } from '../types';
12
+ import type { DeepPartial, PersonaTheme } from '../types/theme';
13
+ import type { AgentWidgetController } from '../ui';
14
+ import { createAgentExperience } from '../ui';
15
+ import { createWidgetHostLayout } from '../runtime/host-layout';
16
+ import { isDockedMountMode } from '../utils/dock';
17
+
18
+ import {
19
+ DEVICE_DIMENSIONS,
20
+ ZOOM_MIN,
21
+ ZOOM_MAX,
22
+ escapeHtml,
23
+ applyShellTheme,
24
+ buildSrcdoc as buildSrcdocDefault,
25
+ buildPreviewConfig as buildPreviewConfigFromOptions,
26
+ type PreviewScene,
27
+ } from './preview-utils';
28
+
29
+ // ─── Public Types ───────────────────────────────────────────────
30
+
31
+ export type PreviewDevice = 'desktop' | 'mobile';
32
+ export type { PreviewScene } from './preview-utils';
33
+ export type PreviewShellMode = 'light' | 'dark';
34
+ export type CompareMode = 'off' | 'baseline' | 'themes';
35
+
36
+ /** Context passed to lifecycle hooks after mounting or updating */
37
+ export interface PreviewLifecycleContext {
38
+ iframes: HTMLIFrameElement[];
39
+ controllers: AgentWidgetController[];
40
+ }
41
+
42
+ export interface ThemePreviewOptions {
43
+ /** Device frame dimensions */
44
+ device?: PreviewDevice;
45
+ /** Widget state */
46
+ scene?: PreviewScene;
47
+ /** Browser chrome appearance */
48
+ shellMode?: PreviewShellMode;
49
+ /** Side-by-side comparison */
50
+ compareMode?: CompareMode;
51
+ /** Widget config */
52
+ config?: Partial<AgentWidgetConfig>;
53
+ /** Light mode theme */
54
+ theme?: DeepPartial<PersonaTheme>;
55
+ /** Dark mode theme */
56
+ darkTheme?: DeepPartial<PersonaTheme>;
57
+ /** Zoom level (0.15–1.5), or undefined for auto-fit */
58
+ zoom?: number;
59
+ /** Path to widget.css (defaults to looking for /widget-dist/widget.css) */
60
+ widgetCssPath?: string;
61
+
62
+ // ─── Baseline compare support ──────────────────────────────
63
+ /** Config for the baseline side of a baseline comparison */
64
+ baselineConfig?: Partial<AgentWidgetConfig>;
65
+ /** Theme for the baseline side of a baseline comparison */
66
+ baselineTheme?: DeepPartial<PersonaTheme>;
67
+ /** Dark theme for the baseline side of a baseline comparison */
68
+ baselineDarkTheme?: DeepPartial<PersonaTheme>;
69
+
70
+ // ─── Lifecycle hooks (all optional) ────────────────────────
71
+ /** Called after all iframes load and widgets mount */
72
+ onAfterMount?: (ctx: PreviewLifecycleContext) => void;
73
+ /** Called after fast-path controller updates */
74
+ onAfterUpdate?: (ctx: PreviewLifecycleContext) => void;
75
+ /** Called before controllers are destroyed */
76
+ onBeforeDestroy?: () => void;
77
+ /** Called whenever the preview scale changes */
78
+ onScaleChange?: (scale: number) => void;
79
+
80
+ // ─── Custom rendering overrides ────────────────────────────
81
+ /** Override iframe srcdoc generation (for background URLs, etc.) */
82
+ buildSrcdoc?: (mountId: string, shellMode: PreviewShellMode, docked: boolean, cssPath: string) => string;
83
+ /** Override container HTML injection (for Idiomorph, etc.) */
84
+ morphContainer?: (container: HTMLElement, html: string) => void;
85
+ }
86
+
87
+ export interface ThemePreviewHandle {
88
+ /** Update the preview (fast path when possible, full remount when needed) */
89
+ update(options: Partial<ThemePreviewOptions>): void;
90
+ /** Destroy preview and clean up */
91
+ destroy(): void;
92
+ /** Get live widget controllers */
93
+ getControllers(): AgentWidgetController[];
94
+ /** Recalculate auto-fit zoom */
95
+ fitToContainer(): void;
96
+ /** Get all preview iframes */
97
+ getIframes(): HTMLIFrameElement[];
98
+ /** Get current computed scale */
99
+ getScale(): number;
100
+ /** Set explicit zoom (or undefined to auto-fit) */
101
+ setZoom(zoom: number | undefined): void;
102
+ }
103
+
104
+ // ─── Preview Spec ───────────────────────────────────────────────
105
+
106
+ interface PreviewSpec {
107
+ mountId: string;
108
+ label: string;
109
+ config: AgentWidgetConfig;
110
+ shellMode: PreviewShellMode;
111
+ }
112
+
113
+ function buildSpecs(options: ThemePreviewOptions): PreviewSpec[] {
114
+ const compare = options.compareMode ?? 'off';
115
+ const shellMode = options.shellMode ?? 'light';
116
+
117
+ if (compare === 'themes') {
118
+ return [
119
+ { mountId: 'preview-light', label: 'Light', config: buildPreviewConfigFromOptions(options, 'light'), shellMode: 'light' },
120
+ { mountId: 'preview-dark', label: 'Dark', config: buildPreviewConfigFromOptions(options, 'dark'), shellMode: 'dark' },
121
+ ];
122
+ }
123
+
124
+ if (compare === 'baseline' && (options.baselineConfig || options.baselineTheme)) {
125
+ const baselineOptions = {
126
+ ...options,
127
+ config: options.baselineConfig ?? options.config,
128
+ theme: options.baselineTheme ?? options.theme,
129
+ darkTheme: options.baselineDarkTheme ?? options.darkTheme,
130
+ };
131
+ return [
132
+ { mountId: 'preview-baseline', label: 'Baseline', config: buildPreviewConfigFromOptions(baselineOptions, shellMode), shellMode },
133
+ { mountId: 'preview-current', label: 'Current', config: buildPreviewConfigFromOptions(options, shellMode), shellMode },
134
+ ];
135
+ }
136
+
137
+ return [
138
+ { mountId: 'preview-current', label: 'Current', config: buildPreviewConfigFromOptions(options, shellMode), shellMode },
139
+ ];
140
+ }
141
+
142
+ // ─── Main ───────────────────────────────────────────────────────
143
+
144
+ export function createThemePreview(
145
+ container: HTMLElement,
146
+ initialOptions: ThemePreviewOptions
147
+ ): ThemePreviewHandle {
148
+ let options = { ...initialOptions };
149
+ let controllers: AgentWidgetController[] = [];
150
+ let layoutCleanups: (() => void)[] = [];
151
+ let resizeObserver: ResizeObserver | null = null;
152
+ let destroyed = false;
153
+ let lastAutoScale = 1;
154
+ let currentScale = 1;
155
+ let renderToken = 0;
156
+
157
+ function getDevice(): PreviewDevice {
158
+ return options.device ?? 'desktop';
159
+ }
160
+
161
+ function getZoom(): number {
162
+ return options.zoom ?? lastAutoScale;
163
+ }
164
+
165
+ function computeFitScale(): number {
166
+ const style = getComputedStyle(container);
167
+ const padX = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
168
+ const padY = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
169
+ const margin = 40;
170
+ const compare = (options.compareMode ?? 'off') !== 'off';
171
+ const availW = (container.clientWidth - padX - margin) / (compare ? 2 : 1);
172
+ const availH = container.clientHeight - padY - margin;
173
+ if (availW <= 0 || availH <= 0) return 1;
174
+
175
+ const dims = DEVICE_DIMENSIONS[getDevice()] ?? DEVICE_DIMENSIONS.desktop;
176
+ return Math.min(availW / dims.w, availH / dims.h, 1);
177
+ }
178
+
179
+ function applyScale(): void {
180
+ lastAutoScale = computeFitScale();
181
+ const scale = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, getZoom()));
182
+ currentScale = scale;
183
+
184
+ const wrappers = Array.from(container.querySelectorAll<HTMLElement>('.preview-iframe-wrapper'));
185
+ for (const wrapper of wrappers) {
186
+ const device = wrapper.dataset.device ?? 'desktop';
187
+ const dims = DEVICE_DIMENSIONS[device] ?? DEVICE_DIMENSIONS.desktop;
188
+
189
+ wrapper.style.width = `${dims.w * scale}px`;
190
+ wrapper.style.height = `${dims.h * scale}px`;
191
+ if (device === 'mobile') wrapper.style.borderRadius = `${32 * scale}px`;
192
+
193
+ const iframe = wrapper.querySelector('iframe') as HTMLIFrameElement | null;
194
+ if (iframe) {
195
+ iframe.style.width = `${dims.w}px`;
196
+ iframe.style.height = `${dims.h}px`;
197
+ iframe.style.transformOrigin = 'top left';
198
+ iframe.style.transition = 'none';
199
+ iframe.style.transform = `scale(${scale})`;
200
+ }
201
+ }
202
+
203
+ options.onScaleChange?.(scale);
204
+ }
205
+
206
+ function destroyControllers(): void {
207
+ options.onBeforeDestroy?.();
208
+ for (const c of controllers) c.destroy();
209
+ for (const fn of layoutCleanups) fn();
210
+ controllers = [];
211
+ layoutCleanups = [];
212
+ }
213
+
214
+ function getIframeList(): HTMLIFrameElement[] {
215
+ return Array.from(container.querySelectorAll<HTMLIFrameElement>('iframe[data-mount-id]'));
216
+ }
217
+
218
+ function mountWidgets(): void {
219
+ if (destroyed) return;
220
+ destroyControllers();
221
+
222
+ const token = ++renderToken;
223
+ const specs = buildSpecs(options);
224
+ const device = getDevice();
225
+ const compare = (options.compareMode ?? 'off') !== 'off';
226
+ const isMinimized = (options.scene ?? 'conversation') === 'minimized';
227
+ const widgetCssPath = options.widgetCssPath ?? '/widget-dist/widget.css';
228
+ const srcdocBuilder = options.buildSrcdoc ?? buildSrcdocDefault;
229
+
230
+ // Build container HTML
231
+ const wrapperClass = device === 'mobile' ? 'preview-iframe-wrapper preview-iframe-wrapper-mobile' : 'preview-iframe-wrapper';
232
+ const frameMarkup = (spec: PreviewSpec) =>
233
+ `<div class="${wrapperClass}" data-mount-id="${spec.mountId}" data-device="${device}" data-shell-mode="${spec.shellMode}">
234
+ ${compare ? `<div class="preview-frame-meta"><span class="preview-frame-label">${escapeHtml(spec.label)}</span></div>` : ''}
235
+ <iframe class="preview-iframe" sandbox="allow-scripts allow-same-origin" data-mount-id="${spec.mountId}"></iframe>
236
+ </div>`;
237
+
238
+ const html = compare
239
+ ? `<div class="preview-compare-grid">${specs.map(s => `<div class="preview-compare-cell">${frameMarkup(s)}</div>`).join('')}</div>`
240
+ : `<div class="preview-single">${frameMarkup(specs[0])}</div>`;
241
+
242
+ if (options.morphContainer) {
243
+ options.morphContainer(container, html);
244
+ } else {
245
+ container.innerHTML = html;
246
+ }
247
+
248
+ applyScale();
249
+
250
+ // Mount widgets inside iframes after they load
251
+ const iframes = getIframeList();
252
+ let loaded = 0;
253
+ const total = iframes.length;
254
+
255
+ const mountAll = (): void => {
256
+ if (destroyed || token !== renderToken) return;
257
+
258
+ for (const iframe of iframes) {
259
+ const mountId = iframe.dataset.mountId;
260
+ if (!mountId || !iframe.contentDocument) continue;
261
+ const spec = specs.find(s => s.mountId === mountId);
262
+ if (!spec) continue;
263
+
264
+ let cleanup = () => {};
265
+ const docked = isDockedMountMode(spec.config);
266
+
267
+ const mount = docked
268
+ ? (() => {
269
+ const contentRoot = iframe.contentDocument?.getElementById(`preview-content-${mountId}`) as HTMLElement | null;
270
+ if (!contentRoot) return null;
271
+ const hostLayout = createWidgetHostLayout(contentRoot, spec.config);
272
+ const m = iframe.contentDocument!.createElement('div');
273
+ m.id = mountId;
274
+ m.style.height = '100%';
275
+ m.style.display = 'flex';
276
+ m.style.flexDirection = 'column';
277
+ m.style.flex = '1';
278
+ m.style.minHeight = '0';
279
+ hostLayout.host.appendChild(m);
280
+ const syncDock = () => hostLayout.syncWidgetState(controller.getState());
281
+ const prevCleanup = cleanup;
282
+ cleanup = () => { hostLayout.destroy(); prevCleanup(); };
283
+ (m as any).__syncDock = syncDock;
284
+ (m as any).__hostLayout = hostLayout;
285
+ return m;
286
+ })()
287
+ : iframe.contentDocument.getElementById(mountId);
288
+
289
+ if (!mount) continue;
290
+
291
+ const controller = createAgentExperience(mount, spec.config);
292
+ controllers.push(controller);
293
+
294
+ if (docked && (mount as any).__syncDock) {
295
+ const syncDock = (mount as any).__syncDock as () => void;
296
+ const openUnsub = controller.on('widget:opened', syncDock);
297
+ const closeUnsub = controller.on('widget:closed', syncDock);
298
+ const prevCleanup = cleanup;
299
+ cleanup = () => { openUnsub(); closeUnsub(); prevCleanup(); };
300
+ syncDock();
301
+ }
302
+
303
+ layoutCleanups.push(cleanup);
304
+
305
+ if (isMinimized) controller.close();
306
+ }
307
+
308
+ // Inject artifacts if needed
309
+ const scene = options.scene ?? 'conversation';
310
+ if (scene === 'artifact' || options.config?.features?.artifacts?.enabled) {
311
+ for (const c of controllers) {
312
+ c.upsertArtifact({
313
+ id: 'preview-sample',
314
+ artifactType: 'markdown',
315
+ title: 'Sample Document',
316
+ content: '# Sample Artifact\n\nThis is a preview of the artifact sidebar.\n\n## Features\n\n- Markdown rendering\n- Document toolbar\n- Resizable panes',
317
+ });
318
+ }
319
+ }
320
+
321
+ options.onAfterMount?.({ iframes, controllers: [...controllers] });
322
+ };
323
+
324
+ for (const iframe of iframes) {
325
+ const mountId = iframe.dataset.mountId;
326
+ if (!mountId) continue;
327
+ const spec = specs.find(s => s.mountId === mountId);
328
+ if (!spec) continue;
329
+
330
+ iframe.addEventListener('load', () => {
331
+ loaded++;
332
+ if (loaded >= total) mountAll();
333
+ }, { once: true });
334
+ iframe.srcdoc = srcdocBuilder(mountId, spec.shellMode, isDockedMountMode(spec.config), widgetCssPath);
335
+ }
336
+
337
+ if (total === 0) mountAll();
338
+ }
339
+
340
+ function updateWidgets(): void {
341
+ if (destroyed) return;
342
+
343
+ const specs = buildSpecs(options);
344
+
345
+ // Check if we can do a fast update (no structural changes)
346
+ if (controllers.length !== specs.length) {
347
+ mountWidgets();
348
+ return;
349
+ }
350
+
351
+ // Check shell mode changes
352
+ const hasShellMismatch = specs.some(spec => {
353
+ const wrapper = container.querySelector<HTMLElement>(`.preview-iframe-wrapper[data-mount-id="${spec.mountId}"]`);
354
+ return !wrapper || wrapper.dataset.shellMode !== spec.shellMode;
355
+ });
356
+
357
+ if (hasShellMismatch) {
358
+ mountWidgets();
359
+ return;
360
+ }
361
+
362
+ // Fast path: update controllers in place
363
+ controllers.forEach((controller, index) => {
364
+ controller.update(specs[index].config);
365
+ if ((options.scene ?? 'conversation') === 'minimized') {
366
+ controller.close();
367
+ }
368
+ });
369
+
370
+ // Update shell themes
371
+ for (const spec of specs) {
372
+ const iframe = container.querySelector<HTMLIFrameElement>(`iframe[data-mount-id="${spec.mountId}"]`);
373
+ if (iframe) applyShellTheme(iframe, spec.shellMode);
374
+ }
375
+
376
+ options.onAfterUpdate?.({ iframes: getIframeList(), controllers: [...controllers] });
377
+ }
378
+
379
+ // ─── Setup ──────────────────────────────────────────────────
380
+
381
+ // Auto-fit on resize
382
+ if (typeof ResizeObserver !== 'undefined') {
383
+ resizeObserver = new ResizeObserver(() => {
384
+ if (!destroyed) applyScale();
385
+ });
386
+ resizeObserver.observe(container);
387
+ }
388
+
389
+ // Initial mount
390
+ mountWidgets();
391
+
392
+ // ─── Handle ─────────────────────────────────────────────────
393
+
394
+ return {
395
+ update(newOptions: Partial<ThemePreviewOptions>): void {
396
+ if (destroyed) return;
397
+
398
+ const needsRemount =
399
+ newOptions.device !== undefined && newOptions.device !== options.device ||
400
+ newOptions.scene !== undefined && newOptions.scene !== options.scene ||
401
+ newOptions.compareMode !== undefined && newOptions.compareMode !== options.compareMode ||
402
+ newOptions.widgetCssPath !== undefined && newOptions.widgetCssPath !== options.widgetCssPath;
403
+
404
+ options = { ...options, ...newOptions };
405
+
406
+ if (needsRemount) {
407
+ mountWidgets();
408
+ } else {
409
+ updateWidgets();
410
+ }
411
+ },
412
+
413
+ destroy(): void {
414
+ if (destroyed) return;
415
+ destroyed = true;
416
+ destroyControllers();
417
+ resizeObserver?.disconnect();
418
+ container.innerHTML = '';
419
+ },
420
+
421
+ getControllers(): AgentWidgetController[] {
422
+ return [...controllers];
423
+ },
424
+
425
+ fitToContainer(): void {
426
+ if (destroyed) return;
427
+ options = { ...options, zoom: undefined };
428
+ applyScale();
429
+ },
430
+
431
+ getIframes(): HTMLIFrameElement[] {
432
+ return getIframeList();
433
+ },
434
+
435
+ getScale(): number {
436
+ return currentScale;
437
+ },
438
+
439
+ setZoom(zoom: number | undefined): void {
440
+ if (destroyed) return;
441
+ options = { ...options, zoom };
442
+ applyScale();
443
+ },
444
+ };
445
+ }