Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/float-teaser-dismiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@perspective-ai/sdk": minor
"@perspective-ai/sdk-react": minor
---

Add an × dismiss button to the float teaser bubble so users can close it without opening the chat. Dismissing removes the teaser and the notification dot, and persists for the rest of the browser session (per agent). Configurable via `teaser.dismissible` (default `true`) or `data-perspective-teaser-dismissible="false"`.
6 changes: 5 additions & 1 deletion packages/sdk-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,10 @@ import { FloatBubble } from "@perspective-ai/sdk-react";

// Disable the welcome sequence entirely (no teaser, chime, or dot)
<FloatBubble researchId="your-research-id" teaser={{ enabled: false }} />

// Hide the teaser's × button (by default users can dismiss the teaser
// without opening the chat; a dismissal holds for the browser session)
<FloatBubble researchId="your-research-id" teaser={{ dismissible: false }} />
```

## Hook Options
Expand All @@ -298,7 +302,7 @@ interface UsePopupOptions {
theme?: "light" | "dark" | "system";
channel?: "TEXT" | "VOICE" | ["TEXT", "VOICE"]; // Interaction mode
welcomeMessage?: string; // Teaser text (float only)
teaser?: TeaserConfig; // Teaser on/off, delay (ms), and chime sound (float only)
teaser?: TeaserConfig; // Teaser on/off, delay (ms), chime sound, and dismissibility (float only)
buttonText?: string; // Trigger button text (popup/slider)
params?: Record<string, string>;
brand?: {
Expand Down
62 changes: 33 additions & 29 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ interface EmbedConfig {
theme?: "light" | "dark" | "system"; // Theme preference (default: "system")
channel?: "TEXT" | "VOICE" | ["TEXT", "VOICE"]; // Interaction mode (default: from server config)
welcomeMessage?: string; // Teaser text next to float button (float-type only)
teaser?: TeaserConfig; // Teaser on/off, delay, and sound (float-type only)
teaser?: TeaserConfig; // Teaser on/off, delay, sound, and dismissibility (float-type only)
buttonText?: string; // Custom text for popup/slider trigger buttons
params?: Record<string, string>; // Custom URL parameters for tracking
brand?: {
Expand Down Expand Up @@ -397,7 +397,7 @@ createFloatBubble({
});
```

The teaser appears after 3 seconds with a chime sound. Click the teaser or the float button to open the chat. Only applies to float-type embeds.
The teaser appears after 3 seconds with a chime sound. Click the teaser or the float button to open the chat, or the teaser's × button to dismiss it without opening the chat (the dismissal holds for the rest of the browser session). Only applies to float-type embeds.

### Teaser Control

Expand All @@ -411,13 +411,15 @@ createFloatBubble({
enabled: true, // false disables the whole welcome sequence (teaser, chime, dot)
delay: 5000, // ms after mount before the teaser appears (default: 3000)
sound: false, // mute the chime (default: true)
dismissible: false, // hide the teaser's × button (default: true)
},
});
```

- `enabled: false` turns off the teaser bubble, the chime sound, and the notification dot entirely.
- `delay` sets when the teaser bubble appears, in milliseconds. The chime tracks the teaser, playing 1 second before it appears (or immediately, when `delay` is under a second).
- `sound: false` mutes the chime while keeping the teaser bubble.
- `dismissible: false` removes the × button from the teaser bubble. By default, dismissing the teaser also clears the notification dot and keeps the teaser hidden for the rest of the browser session (per agent, stored in `sessionStorage`).

Or declaratively via data attributes:

Expand All @@ -434,11 +436,12 @@ Or declaratively via data attributes:
></div>
```

| Data Attribute | Description |
| ------------------------------- | --------------------------------------- |
| `data-perspective-teaser` | `"false"` disables the welcome sequence |
| `data-perspective-teaser-delay` | Milliseconds before the teaser appears |
| `data-perspective-teaser-sound` | `"false"` mutes the chime |
| Data Attribute | Description |
| ------------------------------------- | --------------------------------------- |
| `data-perspective-teaser` | `"false"` disables the welcome sequence |
| `data-perspective-teaser-delay` | Milliseconds before the teaser appears |
| `data-perspective-teaser-sound` | `"false"` mutes the chime |
| `data-perspective-teaser-dismissible` | `"false"` hides the teaser's × button |

Teaser settings configured in the Perspective dashboard (delivered via the config API) take precedence over values passed in code.

Expand Down Expand Up @@ -661,28 +664,29 @@ For non-module environments, use the browser bundle:

### Data Attributes Reference

| Attribute | Description |
| --------------------------------- | ------------------------------------------------------- |
| `data-perspective-widget` | Inline widget embed |
| `data-perspective-frame` | Widget frame: `"layout=fill,radius=4px,shadow=none,…"` |
| `data-perspective-popup` | Popup trigger button |
| `data-perspective-slider` | Slider trigger button |
| `data-perspective-float` | Floating chat bubble |
| `data-perspective-fullpage` | Full page embed |
| `data-perspective-params` | Custom params: `"key1=value1,key2=value2"` |
| `data-perspective-theme` | Theme: `"light"`, `"dark"`, or `"system"` |
| `data-perspective-brand` | Light mode colors: `"primary=#xxx,bg=#yyy"` |
| `data-perspective-brand-dark` | Dark mode colors |
| `data-perspective-no-style` | Disable auto-styling on trigger buttons |
| `data-perspective-disable-close` | Prevent user from closing popup/slider |
| `data-perspective-auto-open` | Auto-open trigger: `"timeout:5000"` or `"exit-intent"` |
| `data-perspective-show-once` | Show-once dedup: `"session"`, `"visitor"`, or `"false"` |
| `data-perspective-launcher-icon` | Launcher icon: `"avatar"`, `"default"`, or image URL |
| `data-perspective-launcher-style` | Launcher CSS: `"width:64px;border-radius:12px"` |
| `data-perspective-launcher-class` | CSS class(es) for the launcher button |
| `data-perspective-teaser` | `"false"` disables the float welcome teaser |
| `data-perspective-teaser-delay` | Milliseconds before the teaser appears (default 3000) |
| `data-perspective-teaser-sound` | `"false"` mutes the teaser chime |
| Attribute | Description |
| ------------------------------------- | ------------------------------------------------------- |
| `data-perspective-widget` | Inline widget embed |
| `data-perspective-frame` | Widget frame: `"layout=fill,radius=4px,shadow=none,…"` |
| `data-perspective-popup` | Popup trigger button |
| `data-perspective-slider` | Slider trigger button |
| `data-perspective-float` | Floating chat bubble |
| `data-perspective-fullpage` | Full page embed |
| `data-perspective-params` | Custom params: `"key1=value1,key2=value2"` |
| `data-perspective-theme` | Theme: `"light"`, `"dark"`, or `"system"` |
| `data-perspective-brand` | Light mode colors: `"primary=#xxx,bg=#yyy"` |
| `data-perspective-brand-dark` | Dark mode colors |
| `data-perspective-no-style` | Disable auto-styling on trigger buttons |
| `data-perspective-disable-close` | Prevent user from closing popup/slider |
| `data-perspective-auto-open` | Auto-open trigger: `"timeout:5000"` or `"exit-intent"` |
| `data-perspective-show-once` | Show-once dedup: `"session"`, `"visitor"`, or `"false"` |
| `data-perspective-launcher-icon` | Launcher icon: `"avatar"`, `"default"`, or image URL |
| `data-perspective-launcher-style` | Launcher CSS: `"width:64px;border-radius:12px"` |
| `data-perspective-launcher-class` | CSS class(es) for the launcher button |
| `data-perspective-teaser` | `"false"` disables the float welcome teaser |
| `data-perspective-teaser-delay` | Milliseconds before the teaser appears (default 3000) |
| `data-perspective-teaser-sound` | `"false"` mutes the teaser chime |
| `data-perspective-teaser-dismissible` | `"false"` hides the teaser's × button |

### Auto-Trigger (Data Attributes)

Expand Down
19 changes: 19 additions & 0 deletions packages/sdk/src/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,25 @@ describe("browser entry", () => {
expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();
});

it("parses data-perspective-teaser-dismissible='false' to hide the dismiss button", async () => {
vi.useFakeTimers();
document.body.innerHTML = `
<div data-perspective-float="test-id"
data-perspective-teaser-dismissible="false"></div>
`;
autoInit();

// The teaser is deferred until the config fetch resolves
await flushConfigFetch();
await flushConfigFetch();

vi.advanceTimersByTime(3000);
expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();
expect(
document.querySelector(".perspective-float-teaser-dismiss")
).toBeFalsy();
});

it("does not arm the teaser before the config fetch resolves", () => {
vi.useFakeTimers();
// Never-resolving fetch: the config stays in flight for the whole test
Expand Down
8 changes: 7 additions & 1 deletion packages/sdk/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,14 +379,16 @@ function extractLauncherConfig(
* Extract teaser config from element data attributes:
* data-perspective-teaser="false" disables the welcome sequence,
* data-perspective-teaser-delay="5000" sets the teaser delay in ms,
* data-perspective-teaser-sound="false" mutes the chime.
* data-perspective-teaser-sound="false" mutes the chime,
* data-perspective-teaser-dismissible="false" hides the teaser's × button.
*/
function extractTeaserConfig(
el: HTMLElement
): EmbedConfig["teaser"] | undefined {
const enabledAttr = el.getAttribute(DATA_ATTRS.teaser);
const delayAttr = el.getAttribute(DATA_ATTRS.teaserDelay);
const soundAttr = el.getAttribute(DATA_ATTRS.teaserSound);
const dismissibleAttr = el.getAttribute(DATA_ATTRS.teaserDismissible);

const teaser: NonNullable<EmbedConfig["teaser"]> = {};

Expand All @@ -405,6 +407,10 @@ function extractTeaserConfig(
teaser.sound = false;
}

if (dismissibleAttr === "false") {
teaser.dismissible = false;
}

return Object.keys(teaser).length > 0 ? teaser : undefined;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export const DATA_ATTRS = {
teaser: "data-perspective-teaser",
teaserDelay: "data-perspective-teaser-delay",
teaserSound: "data-perspective-teaser-sound",
teaserDismissible: "data-perspective-teaser-dismissible",
disableJsonLdAttribution: "data-perspective-disable-jsonld-attribution",
} as const;

Expand Down Expand Up @@ -200,4 +201,5 @@ export const STORAGE_KEYS = {
triggerShown: "perspective-trigger-shown",
embedAuthToken: "perspective-embed-auth-token",
embedState: "perspective-embed-state",
teaserDismissed: "perspective-teaser-dismissed",
} as const;
148 changes: 148 additions & 0 deletions packages/sdk/src/float.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1118,6 +1118,154 @@ describe("createFloatBubble", () => {
});
});

describe("teaser dismiss", () => {
it("dismiss button removes the teaser and dot without opening the chat", () => {
vi.useFakeTimers();

const handle = createFloatBubble({
researchId: "test-research-id",
welcomeMessage: "Hello!",
});

vi.advanceTimersByTime(3000);
expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();

const dismissBtn = document.querySelector(
".perspective-float-teaser-dismiss"
) as HTMLButtonElement;
expect(dismissBtn).toBeTruthy();
expect(dismissBtn.getAttribute("aria-label")).toBe("Dismiss message");

dismissBtn.click();

expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();
expect(
document.querySelector(".perspective-float-notification-dot")
).toBeFalsy();
// Dismissing is not engaging — the chat must stay closed.
expect(handle.isOpen).toBe(false);
expect(document.querySelector(".perspective-float-window")).toBeFalsy();

handle.unmount();
});

it("teaser.dismissible=false hides the dismiss button", () => {
vi.useFakeTimers();

const handle = createFloatBubble({
researchId: "test-research-id",
welcomeMessage: "Hello!",
teaser: { dismissible: false },
});

vi.advanceTimersByTime(3000);
expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();
expect(
document.querySelector(".perspective-float-teaser-dismiss")
).toBeFalsy();

handle.unmount();
});

it("a dismissal is not re-armed by a later config update", () => {
vi.useFakeTimers();

const handle = createFloatBubble({
researchId: "test-research-id",
welcomeMessage: "Hello!",
teaser: { delay: 1000 },
});

vi.advanceTimersByTime(1000);
(
document.querySelector(
".perspective-float-teaser-dismiss"
) as HTMLButtonElement
).click();

// A config refresh with a different delay would normally reschedule
// the sequence — after a dismissal it must stay quiet.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(handle.update as any)({
_apiConfig: {
primaryColor: "#7c3aed",
textColor: "#ffffff",
darkPrimaryColor: "#a78bfa",
darkTextColor: "#ffffff",
embedSettings: { teaser: { delay: 5000 } },
},
});

vi.advanceTimersByTime(10000);
expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();

handle.unmount();
});

it("a dismissal persists across mounts in the same session (no teaser, no chime)", () => {
vi.useFakeTimers();
const audioCtxCtor = vi.fn(() => createFakeAudioContext());
vi.stubGlobal("AudioContext", audioCtxCtor);

const first = createFloatBubble({
researchId: "test-research-id",
welcomeMessage: "Hello!",
});

vi.advanceTimersByTime(3000);
(
document.querySelector(
".perspective-float-teaser-dismiss"
) as HTMLButtonElement
).click();
first.unmount();
audioCtxCtor.mockClear();

// Simulates a page navigation: a fresh mount in the same session.
const second = createFloatBubble({
researchId: "test-research-id",
welcomeMessage: "Hello!",
});

vi.advanceTimersByTime(10000);
expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();
expect(
document.querySelector(".perspective-float-notification-dot")
).toBeFalsy();
expect(audioCtxCtor).not.toHaveBeenCalled();

second.unmount();
vi.unstubAllGlobals();
});

it("a dismissal is scoped to the researchId", () => {
vi.useFakeTimers();

const first = createFloatBubble({
researchId: "research-a",
welcomeMessage: "Hello!",
});

vi.advanceTimersByTime(3000);
(
document.querySelector(
".perspective-float-teaser-dismiss"
) as HTMLButtonElement
).click();
first.unmount();

const second = createFloatBubble({
researchId: "research-b",
welcomeMessage: "Hello!",
});

vi.advanceTimersByTime(3000);
expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();

second.unmount();
});
});

describe("launcher config", () => {
afterEach(() => {
document.body.innerHTML = "";
Expand Down
Loading
Loading