@runtypelabs/persona 3.2.2 → 3.3.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.
@@ -0,0 +1,699 @@
1
+ import { createElement } from "../utils/dom";
2
+ import { createIconButton } from "../utils/buttons";
3
+ import { createToggleGroup } from "../utils/buttons";
4
+ import { renderLucideIcon } from "../utils/icons";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Types
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export interface DemoCarouselItem {
11
+ /** URL to load in the iframe (relative or absolute). */
12
+ url: string;
13
+ /** Display title shown in the toolbar. */
14
+ title: string;
15
+ /** Optional subtitle/description. */
16
+ description?: string;
17
+ }
18
+
19
+ export interface DemoCarouselOptions {
20
+ /** Demo pages to cycle through. */
21
+ items: DemoCarouselItem[];
22
+ /** Initial item index. Default: 0. */
23
+ initialIndex?: number;
24
+ /** Initial device viewport. Default: 'desktop'. */
25
+ initialDevice?: "desktop" | "mobile";
26
+ /** Initial color scheme for the iframe wrapper. Default: 'light'. */
27
+ initialColorScheme?: "light" | "dark";
28
+ /** Show zoom +/- controls. Default: true. */
29
+ showZoomControls?: boolean;
30
+ /** Show desktop/mobile toggle. Default: true. */
31
+ showDeviceToggle?: boolean;
32
+ /** Show light/dark scheme toggle. Default: true. */
33
+ showColorSchemeToggle?: boolean;
34
+ /** Called when the active demo changes. */
35
+ onChange?: (index: number, item: DemoCarouselItem) => void;
36
+ }
37
+
38
+ export interface DemoCarouselHandle {
39
+ /** Root element (already appended to the container). */
40
+ element: HTMLElement;
41
+ /** Navigate to a demo by index. */
42
+ goTo(index: number): void;
43
+ /** Go to the next demo. */
44
+ next(): void;
45
+ /** Go to the previous demo. */
46
+ prev(): void;
47
+ /** Current demo index. */
48
+ getIndex(): number;
49
+ /** Change the device viewport. */
50
+ setDevice(device: "desktop" | "mobile"): void;
51
+ /** Change the wrapper color scheme. */
52
+ setColorScheme(scheme: "light" | "dark"): void;
53
+ /** Override zoom level (null = auto-fit). */
54
+ setZoom(zoom: number | null): void;
55
+ /** Tear down listeners, observer, and DOM. */
56
+ destroy(): void;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Constants
61
+ // ---------------------------------------------------------------------------
62
+
63
+ const DEVICE_DIMENSIONS: Record<string, { w: number; h: number }> = {
64
+ desktop: { w: 1280, h: 800 },
65
+ mobile: { w: 390, h: 844 },
66
+ };
67
+
68
+ const ZOOM_STEP = 0.1;
69
+ const ZOOM_MIN = 0.15;
70
+ const ZOOM_MAX = 1.5;
71
+ const STAGE_PADDING = 24;
72
+ const SHADOW_MARGIN = 40;
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Injected CSS (self-contained, prefixed persona-dc-)
76
+ // ---------------------------------------------------------------------------
77
+
78
+ const CAROUSEL_CSS = /* css */ `
79
+ /* ── Root ── */
80
+ .persona-dc-root {
81
+ display: flex;
82
+ flex-direction: column;
83
+ width: 100%;
84
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
85
+ font-size: 14px;
86
+ color: #111827;
87
+ line-height: 1.4;
88
+ }
89
+
90
+ /* ── Toolbar ── */
91
+ .persona-dc-toolbar {
92
+ display: flex;
93
+ align-items: center;
94
+ justify-content: space-between;
95
+ gap: 12px;
96
+ padding: 8px 12px;
97
+ background: #fff;
98
+ border: 1px solid #e5e7eb;
99
+ border-bottom: none;
100
+ border-radius: 10px 10px 0 0;
101
+ flex-wrap: wrap;
102
+ }
103
+ .persona-dc-toolbar-lead {
104
+ display: flex;
105
+ align-items: center;
106
+ gap: 6px;
107
+ min-width: 0;
108
+ }
109
+ .persona-dc-toolbar-trail {
110
+ display: flex;
111
+ align-items: center;
112
+ gap: 8px;
113
+ flex-wrap: wrap;
114
+ }
115
+
116
+ .persona-dc-title-btn {
117
+ display: inline-flex;
118
+ align-items: center;
119
+ gap: 4px;
120
+ font-weight: 600;
121
+ font-size: 13px;
122
+ white-space: nowrap;
123
+ max-width: 240px;
124
+ background: none;
125
+ border: 1px solid transparent;
126
+ border-radius: 0.375rem;
127
+ padding: 4px 6px;
128
+ cursor: pointer;
129
+ color: inherit;
130
+ font-family: inherit;
131
+ transition: background-color 0.15s ease, border-color 0.15s ease;
132
+ }
133
+ .persona-dc-title-btn:hover {
134
+ background: #f3f4f6;
135
+ border-color: #e5e7eb;
136
+ }
137
+ .persona-dc-title-btn .persona-dc-title-text {
138
+ overflow: hidden;
139
+ text-overflow: ellipsis;
140
+ }
141
+ .persona-dc-title-btn .persona-dc-title-chevron {
142
+ flex-shrink: 0;
143
+ transition: transform 0.15s ease;
144
+ }
145
+ .persona-dc-title-btn[aria-expanded="true"] .persona-dc-title-chevron {
146
+ transform: rotate(180deg);
147
+ }
148
+
149
+ /* ── Title dropdown ── */
150
+ .persona-dc-dropdown {
151
+ position: absolute;
152
+ top: 100%;
153
+ left: 0;
154
+ margin-top: 4px;
155
+ min-width: 220px;
156
+ max-width: 320px;
157
+ background: #fff;
158
+ border: 1px solid #e5e7eb;
159
+ border-radius: 8px;
160
+ box-shadow: 0 8px 24px rgba(0,0,0,0.12), 0 2px 6px rgba(0,0,0,0.06);
161
+ padding: 4px;
162
+ z-index: 100;
163
+ max-height: 300px;
164
+ overflow-y: auto;
165
+ }
166
+ .persona-dc-root .persona-dc-dropdown button.persona-dc-dropdown-item {
167
+ display: flex;
168
+ flex-direction: column;
169
+ align-items: flex-start;
170
+ justify-content: flex-start;
171
+ width: 100%;
172
+ padding: 8px 10px;
173
+ border: none;
174
+ background: none;
175
+ border-radius: 6px;
176
+ cursor: pointer;
177
+ text-align: left;
178
+ font-family: inherit;
179
+ font-size: 13px;
180
+ font-weight: 500;
181
+ color: #111827;
182
+ transition: background-color 0.1s ease;
183
+ }
184
+ .persona-dc-root .persona-dc-dropdown button.persona-dc-dropdown-item:hover {
185
+ background: #f3f4f6;
186
+ }
187
+ .persona-dc-root .persona-dc-dropdown button.persona-dc-dropdown-item[aria-current="true"] {
188
+ background: #eff6ff;
189
+ color: #2563eb;
190
+ }
191
+ .persona-dc-root .persona-dc-dropdown-desc {
192
+ font-weight: 400;
193
+ font-size: 12px;
194
+ color: #6b7280;
195
+ margin-top: 1px;
196
+ text-align: left;
197
+ }
198
+ .persona-dc-root .persona-dc-dropdown button.persona-dc-dropdown-item[aria-current="true"] .persona-dc-dropdown-desc {
199
+ color: #60a5fa;
200
+ }
201
+ .persona-dc-counter {
202
+ font-size: 12px;
203
+ color: #6b7280;
204
+ white-space: nowrap;
205
+ }
206
+ .persona-dc-zoom-controls {
207
+ display: flex;
208
+ align-items: center;
209
+ gap: 2px;
210
+ }
211
+ .persona-dc-zoom-level {
212
+ font-size: 12px;
213
+ color: #6b7280;
214
+ min-width: 36px;
215
+ text-align: center;
216
+ user-select: none;
217
+ }
218
+ .persona-dc-separator {
219
+ width: 1px;
220
+ height: 20px;
221
+ background: #e5e7eb;
222
+ flex-shrink: 0;
223
+ }
224
+
225
+ /* ── Stage ── */
226
+ .persona-dc-stage {
227
+ height: 550px;
228
+ min-height: 400px;
229
+ padding: ${STAGE_PADDING}px;
230
+ overflow: auto;
231
+ background: #f0f1f3;
232
+ background-image: radial-gradient(circle, #e0e1e5 1px, transparent 1px);
233
+ background-size: 24px 24px;
234
+ border: 1px solid #e5e7eb;
235
+ border-radius: 0 0 10px 10px;
236
+ display: flex;
237
+ }
238
+
239
+ /* ── Iframe wrapper ── */
240
+ .persona-dc-iframe-wrapper {
241
+ position: relative;
242
+ overflow: hidden;
243
+ background: #fff;
244
+ border-radius: 10px;
245
+ box-shadow: 0 16px 64px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.06);
246
+ margin: auto;
247
+ flex-shrink: 0;
248
+ transition: border-radius 0.2s ease;
249
+ }
250
+ .persona-dc-iframe-wrapper[data-color-scheme="dark"] {
251
+ background: #0f172a;
252
+ }
253
+
254
+ /* ── Iframe ── */
255
+ .persona-dc-iframe {
256
+ border: none;
257
+ display: block;
258
+ background: #fff;
259
+ transform-origin: top left;
260
+ }
261
+ .persona-dc-iframe-wrapper[data-color-scheme="dark"] .persona-dc-iframe {
262
+ background: #0f172a;
263
+ }
264
+
265
+ /* ── Button/toggle base styles (standalone, no widget.css dependency) ── */
266
+ .persona-dc-root .persona-icon-btn {
267
+ display: inline-flex;
268
+ align-items: center;
269
+ justify-content: center;
270
+ padding: 0.25rem;
271
+ border-radius: 0.375rem;
272
+ border: 1px solid #e5e7eb;
273
+ background: #ffffff;
274
+ color: #111827;
275
+ cursor: pointer;
276
+ line-height: 1;
277
+ transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
278
+ }
279
+ .persona-dc-root .persona-icon-btn:hover {
280
+ background: #f3f4f6;
281
+ }
282
+ .persona-dc-root .persona-icon-btn:focus-visible {
283
+ outline: 2px solid #3b82f6;
284
+ outline-offset: 2px;
285
+ }
286
+ .persona-dc-root .persona-icon-btn[aria-pressed="true"] {
287
+ background: #f3f4f6;
288
+ border-color: #d1d5db;
289
+ }
290
+ .persona-dc-root .persona-toggle-group {
291
+ display: inline-flex;
292
+ gap: 0;
293
+ }
294
+ .persona-dc-root .persona-toggle-group > .persona-icon-btn {
295
+ border-radius: 0;
296
+ }
297
+ .persona-dc-root .persona-toggle-group > .persona-icon-btn:first-child {
298
+ border-top-left-radius: 0.375rem;
299
+ border-bottom-left-radius: 0.375rem;
300
+ }
301
+ .persona-dc-root .persona-toggle-group > .persona-icon-btn:last-child {
302
+ border-top-right-radius: 0.375rem;
303
+ border-bottom-right-radius: 0.375rem;
304
+ }
305
+
306
+ /* ── Responsive ── */
307
+ @media (max-width: 640px) {
308
+ .persona-dc-toolbar {
309
+ gap: 8px;
310
+ }
311
+ .persona-dc-zoom-controls {
312
+ display: none;
313
+ }
314
+ .persona-dc-stage {
315
+ height: 400px;
316
+ min-height: 300px;
317
+ }
318
+ }
319
+ `;
320
+
321
+ function injectStyles(): void {
322
+ if (document.querySelector("style[data-persona-dc-styles]")) return;
323
+ const style = document.createElement("style");
324
+ style.setAttribute("data-persona-dc-styles", "");
325
+ style.textContent = CAROUSEL_CSS;
326
+ document.head.appendChild(style);
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Scale helpers (ported from theme editor)
331
+ // ---------------------------------------------------------------------------
332
+
333
+ function computeFitScale(
334
+ stage: HTMLElement,
335
+ dims: { w: number; h: number },
336
+ ): number {
337
+ const availW = stage.clientWidth - STAGE_PADDING * 2 - SHADOW_MARGIN;
338
+ const availH = stage.clientHeight - STAGE_PADDING * 2 - SHADOW_MARGIN;
339
+ if (availW <= 0 || availH <= 0) return 1;
340
+ return Math.min(availW / dims.w, availH / dims.h, 1);
341
+ }
342
+
343
+ function applyScale(
344
+ wrapper: HTMLElement,
345
+ iframe: HTMLIFrameElement,
346
+ dims: { w: number; h: number },
347
+ scale: number,
348
+ device: string,
349
+ ): void {
350
+ wrapper.style.width = `${dims.w * scale}px`;
351
+ wrapper.style.height = `${dims.h * scale}px`;
352
+ wrapper.style.borderRadius =
353
+ device === "mobile" ? `${32 * scale}px` : "10px";
354
+
355
+ iframe.style.width = `${dims.w}px`;
356
+ iframe.style.height = `${dims.h}px`;
357
+ iframe.style.transformOrigin = "top left";
358
+ iframe.style.transform = `scale(${scale})`;
359
+ }
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // Factory
363
+ // ---------------------------------------------------------------------------
364
+
365
+ export function createDemoCarousel(
366
+ container: HTMLElement,
367
+ options: DemoCarouselOptions,
368
+ ): DemoCarouselHandle {
369
+ const {
370
+ items,
371
+ initialIndex = 0,
372
+ initialDevice = "desktop",
373
+ initialColorScheme = "light",
374
+ showZoomControls = true,
375
+ showDeviceToggle = true,
376
+ showColorSchemeToggle = true,
377
+ onChange,
378
+ } = options;
379
+
380
+ if (items.length === 0) {
381
+ throw new Error("createDemoCarousel: items array must not be empty");
382
+ }
383
+
384
+ injectStyles();
385
+
386
+ // ── State ──
387
+ let currentIndex = Math.max(0, Math.min(initialIndex, items.length - 1));
388
+ let currentDevice = initialDevice;
389
+ let currentScheme = initialColorScheme;
390
+ let zoomOverride: number | null = null;
391
+ let lastAutoScale = 1;
392
+ let destroyed = false;
393
+
394
+ // ── DOM ──
395
+ const root = createElement("div", "persona-dc-root");
396
+
397
+ // Toolbar
398
+ const toolbar = createElement("div", "persona-dc-toolbar");
399
+ const toolbarLead = createElement("div", "persona-dc-toolbar-lead");
400
+ const toolbarTrail = createElement("div", "persona-dc-toolbar-trail");
401
+
402
+ // Prev / title / next / counter
403
+ const prevBtn = createIconButton({
404
+ icon: "chevron-left",
405
+ label: "Previous demo",
406
+ size: 14,
407
+ onClick: () => navigate(-1),
408
+ });
409
+
410
+ // Title button with dropdown
411
+ const titleWrap = createElement("div");
412
+ titleWrap.style.position = "relative";
413
+
414
+ const titleBtn = createElement("button", "persona-dc-title-btn");
415
+ titleBtn.type = "button";
416
+ titleBtn.setAttribute("aria-expanded", "false");
417
+ titleBtn.setAttribute("aria-haspopup", "listbox");
418
+ const titleText = createElement("span", "persona-dc-title-text");
419
+ const titleChevron = createElement("span", "persona-dc-title-chevron");
420
+ const chevronSvg = renderLucideIcon("chevron-down", 12, "currentColor", 2);
421
+ if (chevronSvg) titleChevron.appendChild(chevronSvg);
422
+ titleBtn.append(titleText, titleChevron);
423
+
424
+ // Dropdown list
425
+ const dropdown = createElement("div", "persona-dc-dropdown");
426
+ dropdown.setAttribute("role", "listbox");
427
+ dropdown.style.display = "none";
428
+ let dropdownOpen = false;
429
+
430
+ function buildDropdownItems(): void {
431
+ dropdown.innerHTML = "";
432
+ for (let i = 0; i < items.length; i++) {
433
+ const item = items[i];
434
+ const btn = createElement("button", "persona-dc-dropdown-item");
435
+ btn.type = "button";
436
+ btn.setAttribute("role", "option");
437
+ btn.setAttribute("aria-current", i === currentIndex ? "true" : "false");
438
+ const titleSpan = createElement("span");
439
+ titleSpan.textContent = item.title;
440
+ btn.appendChild(titleSpan);
441
+ if (item.description) {
442
+ const desc = createElement("span", "persona-dc-dropdown-desc");
443
+ desc.textContent = item.description;
444
+ btn.appendChild(desc);
445
+ }
446
+ btn.addEventListener("click", () => {
447
+ closeDropdown();
448
+ goTo(i);
449
+ });
450
+ dropdown.appendChild(btn);
451
+ }
452
+ }
453
+
454
+ function toggleDropdown(): void {
455
+ dropdownOpen = !dropdownOpen;
456
+ dropdown.style.display = dropdownOpen ? "" : "none";
457
+ titleBtn.setAttribute("aria-expanded", dropdownOpen ? "true" : "false");
458
+ if (dropdownOpen) buildDropdownItems();
459
+ }
460
+
461
+ function closeDropdown(): void {
462
+ if (!dropdownOpen) return;
463
+ dropdownOpen = false;
464
+ dropdown.style.display = "none";
465
+ titleBtn.setAttribute("aria-expanded", "false");
466
+ }
467
+
468
+ titleBtn.addEventListener("click", (e) => {
469
+ e.stopPropagation();
470
+ toggleDropdown();
471
+ });
472
+
473
+ // Close on outside click
474
+ const onDocClick = (): void => closeDropdown();
475
+ document.addEventListener("click", onDocClick);
476
+
477
+ titleWrap.append(titleBtn, dropdown);
478
+
479
+ const nextBtn = createIconButton({
480
+ icon: "chevron-right",
481
+ label: "Next demo",
482
+ size: 14,
483
+ onClick: () => navigate(1),
484
+ });
485
+
486
+ const counterEl = createElement("span", "persona-dc-counter");
487
+
488
+ toolbarLead.append(prevBtn, titleWrap, nextBtn, counterEl);
489
+
490
+ // Device toggle
491
+ let deviceToggle: ReturnType<typeof createToggleGroup> | null = null;
492
+ if (showDeviceToggle) {
493
+ deviceToggle = createToggleGroup({
494
+ items: [
495
+ { id: "desktop", icon: "monitor", label: "Desktop" },
496
+ { id: "mobile", icon: "smartphone", label: "Mobile" },
497
+ ],
498
+ selectedId: currentDevice,
499
+ onSelect: (id) => {
500
+ currentDevice = id as "desktop" | "mobile";
501
+ wrapper.dataset.device = currentDevice;
502
+ zoomOverride = null;
503
+ rescale();
504
+ },
505
+ });
506
+ toolbarTrail.appendChild(deviceToggle.element);
507
+ }
508
+
509
+ // Zoom controls
510
+ let zoomLevelEl: HTMLSpanElement | null = null;
511
+ if (showZoomControls) {
512
+ const zoomWrap = createElement("div", "persona-dc-zoom-controls");
513
+ const zoomOut = createIconButton({
514
+ icon: "minus",
515
+ label: "Zoom out",
516
+ size: 14,
517
+ onClick: () => {
518
+ const current = zoomOverride ?? lastAutoScale;
519
+ zoomOverride = Math.max(ZOOM_MIN, current - ZOOM_STEP);
520
+ rescale();
521
+ },
522
+ });
523
+ zoomLevelEl = createElement("span", "persona-dc-zoom-level");
524
+ zoomLevelEl.title = "Reset to 100%";
525
+ zoomLevelEl.style.cursor = "pointer";
526
+ zoomLevelEl.addEventListener("click", () => {
527
+ zoomOverride = 1;
528
+ rescale();
529
+ });
530
+ const zoomIn = createIconButton({
531
+ icon: "plus",
532
+ label: "Zoom in",
533
+ size: 14,
534
+ onClick: () => {
535
+ const current = zoomOverride ?? lastAutoScale;
536
+ zoomOverride = Math.min(ZOOM_MAX, current + ZOOM_STEP);
537
+ rescale();
538
+ },
539
+ });
540
+ const zoomFit = createIconButton({
541
+ icon: "maximize",
542
+ label: "Fit to view",
543
+ size: 14,
544
+ onClick: () => {
545
+ zoomOverride = null;
546
+ rescale();
547
+ },
548
+ });
549
+ zoomWrap.append(zoomOut, zoomLevelEl, zoomIn, zoomFit);
550
+ toolbarTrail.appendChild(zoomWrap);
551
+ }
552
+
553
+ // Color scheme toggle
554
+ if (showColorSchemeToggle) {
555
+ const sep = createElement("div", "persona-dc-separator");
556
+ toolbarTrail.appendChild(sep);
557
+ const schemeToggle = createToggleGroup({
558
+ items: [
559
+ { id: "light", icon: "sun", label: "Light" },
560
+ { id: "dark", icon: "moon", label: "Dark" },
561
+ ],
562
+ selectedId: currentScheme,
563
+ onSelect: (id) => {
564
+ currentScheme = id as "light" | "dark";
565
+ wrapper.dataset.colorScheme = currentScheme;
566
+ applySchemeToIframe();
567
+ },
568
+ });
569
+ toolbarTrail.appendChild(schemeToggle.element);
570
+ }
571
+
572
+ // Open in new tab
573
+ const sep2 = createElement("div", "persona-dc-separator");
574
+ toolbarTrail.appendChild(sep2);
575
+ const openBtn = createIconButton({
576
+ icon: "external-link",
577
+ label: "Open in new tab",
578
+ size: 14,
579
+ onClick: () => {
580
+ window.open(items[currentIndex].url, "_blank");
581
+ },
582
+ });
583
+ toolbarTrail.appendChild(openBtn);
584
+
585
+ toolbar.append(toolbarLead, toolbarTrail);
586
+
587
+ // Stage + iframe
588
+ const stage = createElement("div", "persona-dc-stage");
589
+ const wrapper = createElement("div", "persona-dc-iframe-wrapper");
590
+ wrapper.dataset.device = currentDevice;
591
+ wrapper.dataset.colorScheme = currentScheme;
592
+
593
+ const iframe = createElement("iframe", "persona-dc-iframe");
594
+ iframe.setAttribute("sandbox", "allow-scripts allow-same-origin");
595
+ iframe.setAttribute("loading", "lazy");
596
+ iframe.title = items[currentIndex].title;
597
+
598
+ wrapper.appendChild(iframe);
599
+ stage.appendChild(wrapper);
600
+ root.append(toolbar, stage);
601
+ container.appendChild(root);
602
+
603
+ // ── Logic ──
604
+
605
+ function applySchemeToIframe(): void {
606
+ try {
607
+ const body = iframe.contentDocument?.body;
608
+ if (!body) return;
609
+ if (currentScheme === "dark") {
610
+ body.classList.add("theme-dark");
611
+ } else {
612
+ body.classList.remove("theme-dark");
613
+ }
614
+ } catch {
615
+ // Cross-origin iframe — silently ignore
616
+ }
617
+ }
618
+
619
+ // Re-apply scheme after iframe loads new content
620
+ iframe.addEventListener("load", () => applySchemeToIframe());
621
+
622
+ function updateDisplay(): void {
623
+ const item = items[currentIndex];
624
+ titleText.textContent = item.title;
625
+ counterEl.textContent = `${currentIndex + 1} / ${items.length}`;
626
+ iframe.title = item.title;
627
+ }
628
+
629
+ function navigate(delta: number): void {
630
+ const next = ((currentIndex + delta) % items.length + items.length) % items.length;
631
+ goTo(next);
632
+ }
633
+
634
+ function goTo(index: number): void {
635
+ if (index < 0 || index >= items.length) return;
636
+ currentIndex = index;
637
+ iframe.src = items[currentIndex].url;
638
+ updateDisplay();
639
+ onChange?.(currentIndex, items[currentIndex]);
640
+ }
641
+
642
+ function rescale(): void {
643
+ if (destroyed) return;
644
+ const dims = DEVICE_DIMENSIONS[currentDevice] ?? DEVICE_DIMENSIONS.desktop;
645
+ lastAutoScale = computeFitScale(stage, dims);
646
+ const scale = Math.max(
647
+ ZOOM_MIN,
648
+ Math.min(ZOOM_MAX, zoomOverride ?? lastAutoScale),
649
+ );
650
+ applyScale(wrapper, iframe, dims, scale, currentDevice);
651
+ if (zoomLevelEl) {
652
+ zoomLevelEl.textContent = `${Math.round(scale * 100)}%`;
653
+ }
654
+ }
655
+
656
+ // ResizeObserver
657
+ const resizeObserver = new ResizeObserver(() => rescale());
658
+ resizeObserver.observe(stage);
659
+
660
+ // Initial render
661
+ updateDisplay();
662
+ iframe.src = items[currentIndex].url;
663
+ // Defer initial scale to next frame so stage has layout dimensions
664
+ requestAnimationFrame(() => rescale());
665
+
666
+ // ── Handle ──
667
+
668
+ function destroy(): void {
669
+ if (destroyed) return;
670
+ destroyed = true;
671
+ resizeObserver.disconnect();
672
+ document.removeEventListener("click", onDocClick);
673
+ root.remove();
674
+ }
675
+
676
+ return {
677
+ element: root,
678
+ goTo,
679
+ next: () => navigate(1),
680
+ prev: () => navigate(-1),
681
+ getIndex: () => currentIndex,
682
+ setDevice(device: "desktop" | "mobile") {
683
+ currentDevice = device;
684
+ wrapper.dataset.device = device;
685
+ deviceToggle?.setSelected(device);
686
+ zoomOverride = null;
687
+ rescale();
688
+ },
689
+ setColorScheme(scheme: "light" | "dark") {
690
+ currentScheme = scheme;
691
+ wrapper.dataset.colorScheme = scheme;
692
+ },
693
+ setZoom(zoom: number | null) {
694
+ zoomOverride = zoom;
695
+ rescale();
696
+ },
697
+ destroy,
698
+ };
699
+ }
@@ -92,7 +92,7 @@ export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
92
92
  }
93
93
  }
94
94
 
95
- const headerCopy = createElement("div", "persona-flex persona-flex-col");
95
+ const headerCopy = createElement("div", "persona-flex persona-flex-col persona-flex-1 persona-min-w-0");
96
96
  const title = createElement("span", "persona-text-base persona-font-semibold");
97
97
  title.style.color = HEADER_THEME_CSS.titleColor;
98
98
  title.textContent = config?.launcher?.title ?? "Chat Assistant";