@runtypelabs/persona 3.18.0 → 3.19.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 (38) hide show
  1. package/README.md +1 -1
  2. package/dist/index.cjs +47 -47
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +281 -4
  5. package/dist/index.d.ts +281 -4
  6. package/dist/index.global.js +102 -1636
  7. package/dist/index.global.js.map +1 -1
  8. package/dist/index.js +47 -47
  9. package/dist/index.js.map +1 -1
  10. package/dist/theme-editor.cjs +1438 -619
  11. package/dist/theme-editor.d.cts +119 -1
  12. package/dist/theme-editor.d.ts +119 -1
  13. package/dist/theme-editor.js +1552 -619
  14. package/dist/widget.css +348 -0
  15. package/package.json +1 -1
  16. package/src/components/composer-builder.test.ts +52 -0
  17. package/src/components/composer-builder.ts +67 -490
  18. package/src/components/composer-parts.test.ts +152 -0
  19. package/src/components/composer-parts.ts +452 -0
  20. package/src/components/header-builder.ts +22 -299
  21. package/src/components/header-parts.ts +360 -0
  22. package/src/components/panel.test.ts +61 -0
  23. package/src/components/panel.ts +262 -5
  24. package/src/components/pill-composer-builder.test.ts +85 -0
  25. package/src/components/pill-composer-builder.ts +183 -0
  26. package/src/index.ts +4 -0
  27. package/src/runtime/init.ts +4 -2
  28. package/src/runtime/persist-state.test.ts +152 -0
  29. package/src/styles/widget.css +348 -0
  30. package/src/types.ts +121 -1
  31. package/src/ui.component-directive.test.ts +183 -0
  32. package/src/ui.composer-bar.test.ts +1009 -0
  33. package/src/ui.ts +809 -72
  34. package/src/utils/attachment-manager.ts +1 -1
  35. package/src/utils/dock.test.ts +45 -0
  36. package/src/utils/dock.ts +3 -0
  37. package/src/utils/icons.ts +314 -58
  38. package/src/utils/stream-animation.ts +7 -2
package/dist/widget.css CHANGED
@@ -3389,3 +3389,351 @@
3389
3389
  animation: none !important;
3390
3390
  }
3391
3391
  }
3392
+
3393
+ /* ===========================================================================
3394
+ * Composer-bar mode (`launcher.mountMode: "composer-bar"`)
3395
+ *
3396
+ * Geometry (position, width, height) is set per-state inline by
3397
+ * `applyComposerBarGeometry()` in ui.ts. The pill composer
3398
+ * (`pill-composer-builder.ts`) ships with its own clean className
3399
+ * (`persona-pill-composer`, no `persona-flex-col` / `persona-rounded-2xl`
3400
+ * baggage), so layout CSS does not need to fight utility classes — no
3401
+ * !important. The pill is the visible element in both collapsed and
3402
+ * expanded states; only the panel above it appears/disappears.
3403
+ *
3404
+ * Avoid `transform: scale(...)` on the wrapper — it breaks the textarea
3405
+ * caret/IME.
3406
+ * ======================================================================= */
3407
+ .persona-widget-wrapper[data-persona-composer-bar] {
3408
+ /* Flex column so the inner panel (which has `flex-1`) fills the wrapper.
3409
+ * Without this, the panel would shrink-wrap its children (chat container
3410
+ * + pill) and a gap would appear between the pill and the wrapper's
3411
+ * `bottom: 16px` edge. */
3412
+ display: flex;
3413
+ flex-direction: column;
3414
+ transition:
3415
+ max-width 220ms ease,
3416
+ bottom 220ms ease,
3417
+ top 220ms ease,
3418
+ left 220ms ease,
3419
+ right 220ms ease,
3420
+ transform 220ms ease,
3421
+ border-radius 220ms ease,
3422
+ height 220ms ease,
3423
+ width 220ms ease;
3424
+ }
3425
+
3426
+ /* Modal and anchored: the wrapper goes from collapsed (no inline geometry,
3427
+ * so `top/left/transform` resolve to their auto/none defaults) to its
3428
+ * expanded position. The default `transform 220ms ease` transition would
3429
+ * interpolate `none → translate(...)` and visibly slide the wrapper from
3430
+ * its static-default origin toward its target — diagonally for modal,
3431
+ * horizontally from the right for anchored. Neither mode is morphing
3432
+ * something the user can see (the wrapper is invisible when collapsed
3433
+ * because the pill lives in pillRoot, not the wrapper), so the slide
3434
+ * is pure motion noise. Disable geometry transitions for both; the
3435
+ * container's opacity fade-in keyframe is the reveal.
3436
+ *
3437
+ * Fullscreen intentionally keeps its geometry transition — that's the
3438
+ * one mode where the wrapper genuinely morphs (empty → full viewport),
3439
+ * and the staggered fade-in cascade below is built to mask the
3440
+ * outer-edge/inner-content desync during that morph. */
3441
+ .persona-widget-wrapper[data-persona-composer-bar][data-expanded-size="modal"],
3442
+ .persona-widget-wrapper[data-persona-composer-bar][data-expanded-size="anchored"] {
3443
+ transition: none;
3444
+ }
3445
+
3446
+ /* --- Pill composer chrome (always visible in composer-bar mode) -------- */
3447
+
3448
+ /* Pill is a single-row grid: paperclip · textarea · mic + send. */
3449
+ .persona-pill-composer {
3450
+ display: grid;
3451
+ grid-template-columns: auto 1fr auto;
3452
+ align-items: center;
3453
+ gap: 8px;
3454
+ padding: 6px 14px;
3455
+ border-radius: 9999px;
3456
+ background: var(--persona-surface, #ffffff);
3457
+ border: 1px solid var(--persona-border, #e5e7eb);
3458
+ box-shadow:
3459
+ 0 6px 24px rgba(15, 23, 42, 0.10),
3460
+ 0 1px 2px rgba(15, 23, 42, 0.04);
3461
+ }
3462
+
3463
+ .persona-pill-composer .persona-composer-textarea {
3464
+ /* Single line by default; the autoresize closure expands up to the
3465
+ * max-height set inline by pill-composer-builder (100px). Width 100%
3466
+ * fills the `1fr` middle grid cell so wrapping only happens once the
3467
+ * pill itself can't grow any wider. */
3468
+ min-height: 24px;
3469
+ width: 100%;
3470
+ padding: 4px 0;
3471
+ }
3472
+
3473
+ /* Responsive default width for the collapsed pill — applied only when
3474
+ * `applyComposerBarGeometry()` leaves the wrapper's inline width empty
3475
+ * (i.e., the user did NOT set `composerBar.collapsedMaxWidth`). Inline
3476
+ * width from a user config still wins via specificity (inline > class). */
3477
+ /* Pill responsive widths now live on the viewport-fixed pillRoot below;
3478
+ * the wrapper no longer doubles as the pill container. */
3479
+
3480
+ /* The action cells host inline-flex buttons; without explicit flex layout
3481
+ * on the cell, the buttons sit on the inline baseline and the cell extends
3482
+ * below them with descender space, making the buttons look top-aligned. */
3483
+ .persona-pill-composer__left,
3484
+ .persona-pill-composer__right {
3485
+ display: flex;
3486
+ align-items: center;
3487
+ gap: 4px;
3488
+ }
3489
+
3490
+ /* Footer wrapping the pill: transparent surface, vertical stack with
3491
+ * room above the pill for the previews row. */
3492
+ .persona-widget-footer--pill {
3493
+ background: transparent;
3494
+ border-top: none;
3495
+ padding: 0;
3496
+ display: flex;
3497
+ flex-direction: column;
3498
+ gap: 8px;
3499
+ }
3500
+
3501
+ /* Attachment previews row (above the pill). AttachmentManager toggles
3502
+ * display:flex when items are added; default is display:none. */
3503
+ .persona-pill-composer__previews {
3504
+ padding: 0 8px;
3505
+ }
3506
+
3507
+ /* --- Container chrome ---------------------------------------------------
3508
+ *
3509
+ * Container chrome (background, border, border-radius, box-shadow) is
3510
+ * applied inline by `applyFullHeightStyles()` in ui.ts so it can flow through
3511
+ * the same `theme.components.panel.{shadow,border,borderRadius}` contract
3512
+ * used by every other mount mode. Collapsed hides the container entirely
3513
+ * (only the pill is visible); expanded re-applies the chrome. The
3514
+ * `fullscreen` expanded variant intentionally stays chrome-less. */
3515
+
3516
+ /* Composer overlay (interactive sheets like ask_user_question) hidden in
3517
+ * collapsed; the pill has no room and the panel above is gone anyway. */
3518
+ [data-persona-composer-bar][data-state="collapsed"] .persona-composer-overlay {
3519
+ display: none;
3520
+ }
3521
+
3522
+ /* --- Pill root: viewport-fixed sibling of the wrapper ------------------
3523
+ *
3524
+ * In composer-bar mode the pill (`footer`) and peek banner live in a
3525
+ * viewport-fixed sibling of the wrapper, NOT inside the wrapper. Reason:
3526
+ * modal mode applies `transform: translate(-50%, -50%)` to the wrapper,
3527
+ * which establishes a containing block for `position: fixed` descendants —
3528
+ * a fixed pill inside a transformed wrapper would be positioned relative
3529
+ * to the wrapper, not the viewport.
3530
+ *
3531
+ * Bottom offset and (optionally) width are written inline by ui.ts based
3532
+ * on `launcher.composerBar.{bottomOffset, collapsedMaxWidth}`; otherwise
3533
+ * the responsive defaults below apply. The pillRoot mirrors the wrapper's
3534
+ * `[data-state]` and `[data-expanded-size]` attributes so peek/pill rules
3535
+ * keyed off those attributes still cascade. */
3536
+ .persona-widget-pill-root {
3537
+ position: fixed;
3538
+ bottom: 16px;
3539
+ left: 50%;
3540
+ transform: translateX(-50%);
3541
+ width: 90vw;
3542
+ max-width: calc(100vw - 32px);
3543
+ display: flex;
3544
+ flex-direction: column;
3545
+ gap: 8px;
3546
+ }
3547
+ @media (min-width: 640px) {
3548
+ .persona-widget-pill-root {
3549
+ width: 70vw;
3550
+ }
3551
+ }
3552
+ @media (min-width: 1024px) {
3553
+ .persona-widget-pill-root {
3554
+ width: 50vw;
3555
+ }
3556
+ }
3557
+
3558
+ /* Container hidden when the chat is collapsed — only the pill is visible. */
3559
+ [data-persona-composer-bar][data-state="collapsed"] .persona-widget-container {
3560
+ display: none;
3561
+ }
3562
+
3563
+ /* --- Peek banner ------------------------------------------------------- */
3564
+ /* The peek is the chrome-less row above the pill (composer-bar mode only)
3565
+ * that previews the trailing 100 chars of the most recent assistant
3566
+ * message. ui.ts toggles `--visible` based on streaming/hover/open state;
3567
+ * pointer-events are gated so the faded-out peek never swallows clicks
3568
+ * targeted at the pill below. */
3569
+ .persona-pill-peek {
3570
+ display: flex;
3571
+ align-items: center;
3572
+ gap: 8px;
3573
+ padding: 6px 14px;
3574
+ /* Frosted-glass chrome so the text stays readable over arbitrary host
3575
+ * backgrounds (busy hero images, dark sections, brand colors). The
3576
+ * translucent fill + backdrop blur turns the underlying area into a
3577
+ * neutral surface; the hairline border + soft shadow define the edge
3578
+ * without competing with the pill below. */
3579
+ background: rgba(255, 255, 255, 0.78);
3580
+ -webkit-backdrop-filter: blur(10px) saturate(1.5);
3581
+ backdrop-filter: blur(10px) saturate(1.5);
3582
+ border: 1px solid rgba(0, 0, 0, 0.06);
3583
+ border-radius: 9999px;
3584
+ box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
3585
+ color: var(--persona-primary, #111827);
3586
+ font: inherit;
3587
+ font-size: 13px;
3588
+ line-height: 1.4;
3589
+ cursor: pointer;
3590
+ opacity: 0;
3591
+ pointer-events: none;
3592
+ transform: translateY(2px);
3593
+ transition:
3594
+ opacity 150ms ease,
3595
+ transform 150ms ease,
3596
+ background-color 150ms ease;
3597
+ text-align: left;
3598
+ /* Full-width row that matches the pill below — text takes the middle
3599
+ * (flex: 1 1 auto) and the chevron pins to the right edge. */
3600
+ width: 100%;
3601
+ align-self: stretch;
3602
+ }
3603
+
3604
+ /* Browsers without backdrop-filter support (older Firefox, some webviews)
3605
+ * fall through to a slightly more opaque solid surface so the underlying
3606
+ * page can't bleed through. The non-supports rule above already gives a
3607
+ * usable look (78% opacity); this just hardens it for non-blur fallback. */
3608
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
3609
+ .persona-pill-peek {
3610
+ background: rgba(255, 255, 255, 0.95);
3611
+ }
3612
+ }
3613
+
3614
+ .persona-pill-peek--visible {
3615
+ opacity: 1;
3616
+ pointer-events: auto;
3617
+ transform: translateY(0);
3618
+ }
3619
+
3620
+ .persona-pill-peek:hover {
3621
+ background: rgba(255, 255, 255, 0.92);
3622
+ }
3623
+
3624
+ .persona-pill-peek__icon,
3625
+ .persona-pill-peek__caret {
3626
+ display: inline-flex;
3627
+ flex-shrink: 0;
3628
+ color: currentColor;
3629
+ }
3630
+
3631
+ .persona-pill-peek__text {
3632
+ overflow: hidden;
3633
+ text-overflow: ellipsis;
3634
+ white-space: nowrap;
3635
+ flex: 1 1 auto;
3636
+ min-width: 0;
3637
+ }
3638
+
3639
+ /* Per-char/per-word animated spans render as inline-block, which interacts
3640
+ * with the peek's nowrap+ellipsis chrome. Setting `vertical-align: baseline`
3641
+ * keeps the spans on the same baseline as surrounding text so reveal
3642
+ * transforms (translateY, scale) don't bump the line height. */
3643
+ [data-persona-root] .persona-pill-peek__text .persona-stream-char,
3644
+ [data-persona-root] .persona-pill-peek__text .persona-stream-word {
3645
+ vertical-align: baseline;
3646
+ }
3647
+
3648
+ /* Peek-scaled skeleton: the bubble-sized 260×10 bar would dwarf a 14px-line
3649
+ * pill. Inside the peek we shrink to a single, full-flex bar that hints at
3650
+ * "more is coming" without dominating the chrome. Used when
3651
+ * `streamAnimation.placeholder === "skeleton"` AND content trims to empty
3652
+ * (e.g. `buffer: "line"` between line completions). */
3653
+ [data-persona-root] .persona-pill-peek__text .persona-pill-peek__skeleton {
3654
+ display: inline-flex;
3655
+ vertical-align: middle;
3656
+ width: 100%;
3657
+ max-width: 200px;
3658
+ padding: 0;
3659
+ }
3660
+ [data-persona-root] .persona-pill-peek__text .persona-pill-peek__skeleton .persona-stream-skeleton-line {
3661
+ height: 8px;
3662
+ }
3663
+
3664
+ /* --- Expanded states: geometry safety nets ----------------------------- */
3665
+
3666
+ /* `fullscreen`: ui.ts clears all inline geometry so this rule lights up. */
3667
+ .persona-widget-wrapper[data-persona-composer-bar][data-state="expanded"][data-expanded-size="fullscreen"] {
3668
+ inset: 0;
3669
+ top: 0;
3670
+ right: 0;
3671
+ bottom: 0;
3672
+ left: 0;
3673
+ transform: none;
3674
+ max-width: none;
3675
+ width: 100%;
3676
+ height: 100%;
3677
+ border-radius: 0;
3678
+ }
3679
+
3680
+ /* Fullscreen "panel behind pill": the chat body fills the full viewport
3681
+ * (including the area behind the fixed pill) so messages scroll under the
3682
+ * pill. The body itself drops its bottom padding so its background extends
3683
+ * to the viewport edge; reachability for the last bubble comes from
3684
+ * padding-bottom on the messages wrapper (body's last child) — that
3685
+ * pushes the final message above the pill area rather than hiding it
3686
+ * behind. */
3687
+ .persona-widget-wrapper[data-persona-composer-bar][data-expanded-size="fullscreen"] .persona-widget-body {
3688
+ padding-bottom: 0;
3689
+ }
3690
+ .persona-widget-wrapper[data-persona-composer-bar][data-expanded-size="fullscreen"] .persona-widget-body > :last-child {
3691
+ padding-bottom: calc(var(--persona-pill-bottom, 16px) + var(--persona-pill-area-height, 80px) + 16px);
3692
+ }
3693
+
3694
+ /* `modal` and `anchored` get their geometry inline from ui.ts; nothing
3695
+ * needed in CSS for those. */
3696
+
3697
+ /* --- Expand fade-in cascade -------------------------------------------- */
3698
+ /*
3699
+ * Two staggered fades that mask the visual desync between the wrapper's
3700
+ * fast-traveling outer edges and the inner content (which is constrained
3701
+ * by `contentMaxWidth` + `margin: auto` and therefore moves at a fraction
3702
+ * of the wrapper's growth rate). Without this, the user perceives an
3703
+ * empty "gap" inside the wrapper as it morphs to fullscreen.
3704
+ *
3705
+ * - Chrome (container background, border, shadow, and the absolute close +
3706
+ * clear buttons) fades in across the same 220ms as the geometry transition,
3707
+ * so the box appears to materialize as it grows rather than snap into
3708
+ * existence at frame 0.
3709
+ * - Body content (intro card, messages, composer overlay) fades in AFTER
3710
+ * the geometry has settled — `animation-delay: 220ms` plus
3711
+ * `animation-fill-mode: both` keeps it at opacity 0 during the morph.
3712
+ *
3713
+ * Because `[data-state="collapsed"] .persona-widget-container { display:
3714
+ * none }`, the container leaves and re-enters the rendering tree on each
3715
+ * expand, which retriggers the keyframe animation cleanly without
3716
+ * needing a JS class toggle.
3717
+ */
3718
+ @keyframes persona-composer-bar-fade-in {
3719
+ from { opacity: 0; }
3720
+ to { opacity: 1; }
3721
+ }
3722
+
3723
+ .persona-widget-wrapper[data-persona-composer-bar][data-state="expanded"] .persona-widget-container {
3724
+ animation: persona-composer-bar-fade-in 220ms ease both;
3725
+ }
3726
+
3727
+ .persona-widget-wrapper[data-persona-composer-bar][data-state="expanded"] .persona-widget-body {
3728
+ animation: persona-composer-bar-fade-in 200ms ease 220ms both;
3729
+ }
3730
+
3731
+ @media (prefers-reduced-motion: reduce) {
3732
+ .persona-widget-wrapper[data-persona-composer-bar] {
3733
+ transition: none !important;
3734
+ }
3735
+ .persona-widget-wrapper[data-persona-composer-bar][data-state="expanded"] .persona-widget-container,
3736
+ .persona-widget-wrapper[data-persona-composer-bar][data-state="expanded"] .persona-widget-body {
3737
+ animation: none !important;
3738
+ }
3739
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "3.18.0",
3
+ "version": "3.19.0",
4
4
  "description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -0,0 +1,52 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { buildComposer } from "./composer-builder";
5
+ import type { AgentWidgetConfig } from "../types";
6
+
7
+ describe("buildComposer (full column-stacked composer)", () => {
8
+ it("returns the full ComposerElements shape with stable selectors", () => {
9
+ const config: AgentWidgetConfig = {
10
+ apiUrl: "/api",
11
+ voiceRecognition: { enabled: true, provider: { type: "runtype" } },
12
+ attachments: { enabled: true },
13
+ };
14
+ const elements = buildComposer({ config });
15
+
16
+ expect(elements.footer.classList.contains("persona-widget-footer")).toBe(true);
17
+ expect(elements.composerForm.tagName).toBe("FORM");
18
+ expect(elements.composerForm.getAttribute("data-persona-composer-form")).toBe("");
19
+ expect(elements.composerForm.classList.contains("persona-flex-col")).toBe(true);
20
+
21
+ expect(elements.textarea.getAttribute("data-persona-composer-input")).toBe("");
22
+ expect(elements.sendButton.getAttribute("data-persona-composer-submit")).toBe("");
23
+ expect(elements.statusText.getAttribute("data-persona-composer-status")).toBe("");
24
+
25
+ expect(elements.attachmentButton).not.toBeNull();
26
+ expect(elements.attachmentInput).not.toBeNull();
27
+ expect(elements.attachmentPreviewsContainer).not.toBeNull();
28
+ expect(elements.micButton).not.toBeNull();
29
+
30
+ expect(elements.actionsRow.classList.contains("persona-widget-composer__actions")).toBe(true);
31
+ expect(elements.leftActions.classList.contains("persona-widget-composer__left-actions")).toBe(true);
32
+ expect(elements.rightActions.classList.contains("persona-widget-composer__right-actions")).toBe(true);
33
+
34
+ expect(typeof elements.setSendButtonMode).toBe("function");
35
+ });
36
+
37
+ it("returns null for optional controls when their features are disabled", () => {
38
+ const elements = buildComposer({ config: { apiUrl: "/api" } });
39
+ expect(elements.micButton).toBeNull();
40
+ expect(elements.micButtonWrapper).toBeNull();
41
+ expect(elements.attachmentButton).toBeNull();
42
+ expect(elements.attachmentInput).toBeNull();
43
+ expect(elements.attachmentPreviewsContainer).toBeNull();
44
+ });
45
+
46
+ it("attaches the suggestions row, composer form, and status text to the footer in order", () => {
47
+ const elements = buildComposer({ config: { apiUrl: "/api" } });
48
+ expect(elements.footer.children[0]).toBe(elements.suggestions);
49
+ expect(elements.footer.children[1]).toBe(elements.composerForm);
50
+ expect(elements.footer.children[2]).toBe(elements.statusText);
51
+ });
52
+ });