diff --git a/.changeset/teaser-delay-reschedule.md b/.changeset/teaser-delay-reschedule.md
new file mode 100644
index 0000000..dd94e9a
--- /dev/null
+++ b/.changeset/teaser-delay-reschedule.md
@@ -0,0 +1,5 @@
+---
+"@perspective-ai/sdk": patch
+---
+
+Fix float teaser delay from dashboard embed settings being ignored. Script-tag embeds armed the welcome sequence at mount with the default 3s delay before the async `/embed/config` fetch resolved, and never rescheduled when the API config arrived with a custom `embedSettings.teaser.delay`. The teaser is now deferred until the config resolves (it can no longer fire early while the fetch is in flight) and then fires at the resolved delay measured from mount, crediting the time already waited. Delay changes on a still-pending teaser reschedule it against the same anchor; a delivered or cancelled teaser is never re-armed by a late config refresh.
diff --git a/packages/sdk/src/browser.test.ts b/packages/sdk/src/browser.test.ts
index f8542e1..9306e0e 100644
--- a/packages/sdk/src/browser.test.ts
+++ b/packages/sdk/src/browser.test.ts
@@ -874,7 +874,7 @@ describe("browser entry", () => {
expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();
});
- it("parses data-perspective-teaser-delay as teaser delay in ms", () => {
+ it("parses data-perspective-teaser-delay as teaser delay in ms", async () => {
vi.useFakeTimers();
document.body.innerHTML = `
{
`;
autoInit();
+ // The teaser is deferred until the config fetch resolves
+ await flushConfigFetch();
+ await flushConfigFetch();
+
vi.advanceTimersByTime(400);
expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();
vi.advanceTimersByTime(100);
expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();
});
+
+ 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
+ vi.stubGlobal("fetch", vi.fn().mockReturnValue(new Promise(() => {})));
+ document.body.innerHTML = `
+
+ `;
+ autoInit();
+
+ vi.advanceTimersByTime(10000);
+ expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();
+ });
});
});
diff --git a/packages/sdk/src/browser.ts b/packages/sdk/src/browser.ts
index 280c0b9..4f5e516 100644
--- a/packages/sdk/src/browser.ts
+++ b/packages/sdk/src/browser.ts
@@ -829,6 +829,11 @@ function autoInit(): void {
...(launcherConfig && { launcher: launcherConfig }),
...(teaserConfig && { teaser: teaserConfig }),
_apiConfig: DEFAULT_THEME,
+ // Defer the teaser until the fetched config below arrives — its
+ // embedSettings.teaser.delay must not be preempted by a shorter
+ // delay armed at mount. fetchConfig never rejects (it resolves with
+ // DEFAULT_THEME on error/timeout), so the deferral always ends.
+ _apiConfigPending: true,
} as InternalEmbedConfig);
fetchConfig(researchId).then((config) => {
diff --git a/packages/sdk/src/float.test.ts b/packages/sdk/src/float.test.ts
index e170e27..d36ac1b 100644
--- a/packages/sdk/src/float.test.ts
+++ b/packages/sdk/src/float.test.ts
@@ -885,6 +885,237 @@ describe("createFloatBubble", () => {
handle.unmount();
});
+
+ it("API embedSettings.teaser.delay reschedules a pending teaser armed at mount", () => {
+ vi.useFakeTimers();
+
+ const handle = createFloatBubble({
+ researchId: "test-research-id",
+ welcomeMessage: "Hello!",
+ });
+
+ // Async API config arrives after 1s with a longer delay from the
+ // dashboard — the sequence armed at mount with the 3s default must
+ // be rescheduled instead of firing at 3s.
+ vi.advanceTimersByTime(1000);
+ // 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: 30000 } },
+ },
+ });
+
+ // Would have fired at 3s under the default delay
+ vi.advanceTimersByTime(10000);
+ expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();
+
+ // Fires 30s after mount: 1s already elapsed + 29s remaining
+ vi.advanceTimersByTime(19000);
+ expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();
+
+ handle.unmount();
+ });
+
+ it("_apiConfigPending defers the teaser until the API config arrives, crediting the wait", () => {
+ vi.useFakeTimers();
+
+ const handle = createFloatBubble({
+ researchId: "test-research-id",
+ welcomeMessage: "Hello!",
+ _apiConfigPending: true,
+ });
+
+ // Nothing fires while the config fetch is in flight — not even the
+ // 3s default.
+ vi.advanceTimersByTime(10000);
+ expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();
+
+ // 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: 30000 } },
+ },
+ });
+
+ // Fires 30s after mount: 10s already elapsed + 20s remaining
+ vi.advanceTimersByTime(19000);
+ expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();
+
+ vi.advanceTimersByTime(1000);
+ expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();
+
+ handle.unmount();
+ });
+
+ it("a config resolving past the default delay shows the deferred teaser immediately", () => {
+ vi.useFakeTimers();
+
+ const handle = createFloatBubble({
+ researchId: "test-research-id",
+ welcomeMessage: "Hello!",
+ _apiConfigPending: true,
+ });
+
+ // Config resolves at 5s with no teaser settings — the default 3s delay
+ // has fully elapsed, so the teaser shows right away.
+ vi.advanceTimersByTime(5000);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (handle.update as any)({
+ _apiConfig: {
+ primaryColor: "#7c3aed",
+ textColor: "#ffffff",
+ darkPrimaryColor: "#a78bfa",
+ darkTextColor: "#ffffff",
+ },
+ });
+
+ vi.advanceTimersByTime(0);
+ expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();
+
+ handle.unmount();
+ });
+
+ it("a second delay change credits elapsed time from the first arm, not the last reschedule", () => {
+ vi.useFakeTimers();
+
+ const handle = createFloatBubble({
+ researchId: "test-research-id",
+ welcomeMessage: "Hello!",
+ });
+
+ // First reschedule at 1s (default 3s -> 30s)
+ vi.advanceTimersByTime(1000);
+ // 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: 30000 } },
+ },
+ });
+
+ // Second reschedule at 2s (30s -> 10s): must fire 10s after mount,
+ // i.e. 8s from now — not 10s from this reschedule.
+ vi.advanceTimersByTime(1000);
+ // 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: 10000 } },
+ },
+ });
+
+ vi.advanceTimersByTime(7000);
+ expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();
+
+ vi.advanceTimersByTime(1000);
+ expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();
+
+ handle.unmount();
+ });
+
+ it("re-enabling the teaser measures the delay from the re-enable, not the original arm", () => {
+ vi.useFakeTimers();
+
+ const handle = createFloatBubble({
+ researchId: "test-research-id",
+ welcomeMessage: "Hello!",
+ });
+
+ // Disable at 1s, re-enable at 2s with a 5s delay
+ vi.advanceTimersByTime(1000);
+ handle.update({ teaser: { enabled: false } });
+ vi.advanceTimersByTime(1000);
+ handle.update({ teaser: { enabled: true, delay: 5000 } });
+
+ // 5s measured from the re-enable: nothing at +4s, teaser at +5s
+ vi.advanceTimersByTime(4000);
+ expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();
+
+ vi.advanceTimersByTime(1000);
+ expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();
+
+ handle.unmount();
+ });
+
+ it("an _apiConfig refresh with an unchanged delay does not reset a visible teaser", () => {
+ vi.useFakeTimers();
+
+ const handle = createFloatBubble({
+ researchId: "test-research-id",
+ welcomeMessage: "Hello!",
+ teaser: { delay: 1000 },
+ });
+
+ vi.advanceTimersByTime(2000);
+ expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();
+
+ // 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: 1000 } },
+ },
+ });
+
+ expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();
+ expect(
+ document.querySelectorAll(".perspective-float-teaser")
+ ).toHaveLength(1);
+
+ handle.unmount();
+ });
+
+ it("a delay change after the teaser has shown does not re-arm it", () => {
+ vi.useFakeTimers();
+
+ const handle = createFloatBubble({
+ researchId: "test-research-id",
+ welcomeMessage: "Hello!",
+ teaser: { delay: 1000 },
+ });
+
+ vi.advanceTimersByTime(2000);
+ expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();
+
+ // Open (dismisses the teaser), then close — a late config refresh with
+ // a different delay must not bring the teaser back.
+ handle.open();
+ handle.close();
+ expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();
+
+ // 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();
+ });
});
describe("launcher config", () => {
diff --git a/packages/sdk/src/float.ts b/packages/sdk/src/float.ts
index 7dde17a..f45d1db 100644
--- a/packages/sdk/src/float.ts
+++ b/packages/sdk/src/float.ts
@@ -319,6 +319,13 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle {
let audioCtx: AudioContext | null = null;
let welcomeSequenceStarted = false;
let welcomeTimers: number[] = [];
+ // Delay the pending sequence was armed with, and when the current waiting
+ // period began (mount, or a re-enable after a disable). The anchor survives
+ // deferred arms and reschedules: the teaser always fires at the resolved
+ // delay measured from the anchor, crediting time already waited.
+ let armedTeaserDelay: number | null = null;
+ let delayAnchorMs: number | null = null;
+ let teaserDelivered = false;
const persistOpenState = (open: boolean) => {
setPersistedOpenState({
researchId,
@@ -453,7 +460,21 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle {
// (e.g. async API config arriving) can still kick off the sequence.
if (!teaserConfig.enabled) return;
+ // Anchor before any deferral so the eventual arm credits time already
+ // waited; ??= keeps the original anchor across reschedules.
+ delayAnchorMs ??= Date.now();
+
+ // While the config API fetch is in flight, don't arm with a delay it may
+ // override — a slow fetch must not set off the teaser early. update()
+ // clears the flag when the fetched config arrives and re-enters here.
+ if (currentConfig._apiConfigPending) return;
+
welcomeSequenceStarted = true;
+ armedTeaserDelay = teaserConfig.delay;
+ const timerDelay = Math.max(
+ 0,
+ teaserConfig.delay - (Date.now() - delayAnchorMs)
+ );
if (teaserConfig.sound) {
// The chime tracks the teaser: it announces the bubble 1s ahead of it
@@ -463,15 +484,16 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle {
if (isOpen) return;
playChime();
},
- Math.max(0, teaserConfig.delay - SOUND_LEAD_MS)
+ Math.max(0, timerDelay - SOUND_LEAD_MS)
);
welcomeTimers.push(soundTimer);
}
const teaserTimer = window.setTimeout(() => {
+ teaserDelivered = true;
if (isOpen) return;
renderTeaser(resolveWelcomeMessage(currentConfig));
- }, teaserConfig.delay);
+ }, timerDelay);
welcomeTimers.push(teaserTimer);
};
@@ -485,8 +507,29 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle {
clearWelcomeTimers();
removeTeaser();
welcomeSequenceStarted = false;
+ // Forget the arm tracking so a later re-enable starts a fresh sequence
+ // measured from the re-enable, not from the original anchor.
+ armedTeaserDelay = null;
+ delayAnchorMs = null;
+ teaserDelivered = false;
return;
}
+
+ // A delay change while the teaser is still pending (e.g. the API config
+ // updating a customer-configured delay) reschedules the timers; the arm
+ // credits time waited since the anchor. Never reschedule once the teaser
+ // has been delivered or the sequence was cancelled (e.g. by opening).
+ if (
+ welcomeSequenceStarted &&
+ !teaserDelivered &&
+ welcomeTimers.length > 0 &&
+ armedTeaserDelay !== null &&
+ teaserConfig.delay !== armedTeaserDelay
+ ) {
+ clearWelcomeTimers();
+ welcomeSequenceStarted = false;
+ }
+
maybeStartWelcomeSequence(teaserConfig);
};
@@ -664,6 +707,12 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle {
const prevApiTeaser = currentConfig._apiConfig?.embedSettings?.teaser;
currentConfig = { ...currentConfig, ...options };
+ // The fetched config arriving ends the deferral of delay-sensitive
+ // behavior (see _apiConfigPending).
+ if (options._apiConfig) {
+ currentConfig = { ...currentConfig, _apiConfigPending: false };
+ }
+
// An _apiConfig refresh that omits teaser settings keeps the previous
// ones — a later payload must not silently undo an earlier API disable.
if (
diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts
index 646f9f8..72153d0 100644
--- a/packages/sdk/src/types.ts
+++ b/packages/sdk/src/types.ts
@@ -348,7 +348,15 @@ export interface ThemeConfig {
}
/** EmbedConfig with _apiConfig from the config API (internal, subject to change) */
-export type InternalEmbedConfig = EmbedConfig & { _apiConfig?: ThemeConfig };
+export type InternalEmbedConfig = EmbedConfig & {
+ _apiConfig?: ThemeConfig;
+ /**
+ * Set when the embed mounts while the config API fetch is still in flight.
+ * Defers delay-sensitive behavior (the float teaser) until the fetched
+ * config arrives via update({ _apiConfig }), which clears the flag.
+ */
+ _apiConfigPending?: boolean;
+};
/** SDK global configuration */
export interface SDKConfig {