diff --git a/.changeset/app-owned-close-button.md b/.changeset/app-owned-close-button.md
new file mode 100644
index 0000000..ccdbf06
--- /dev/null
+++ b/.changeset/app-owned-close-button.md
@@ -0,0 +1,20 @@
+---
+"@perspective-ai/sdk": minor
+---
+
+Slider, popup, and float now hand their close (X) button to the interview app.
+
+The app renders its own X (it emits `perspective:close` when clicked), placed
+within its own header so it no longer overlaps the app's top-right UI such as
+the participant avatar or the `…` menu. The SDK now only draws a temporary X
+over the loading skeleton and removes it the moment the interview becomes
+visible, so there's never a double or overlapping X.
+
+- The `perspective:init` handshake now sends `renderCloseButton` (`true` unless
+ `disableClose` is set) for slider, popup, and float. `widget`/`fullpage` have
+ no X and don't send it.
+- The legacy `hasCloseButton` init flag is retired — the SDK no longer emits it.
+ It remains on the `InitMessage` type as `@deprecated` because the app may still
+ receive it from older deployed SDK clients.
+- Backdrop-click and Escape close handlers are unchanged; float's launcher bubble
+ still closes the window. `disableClose` continues to suppress the X entirely.
diff --git a/packages/sdk/e2e/embed.spec.ts b/packages/sdk/e2e/embed.spec.ts
index d3b190e..26cc732 100644
--- a/packages/sdk/e2e/embed.spec.ts
+++ b/packages/sdk/e2e/embed.spec.ts
@@ -232,7 +232,15 @@ test.describe("Script Tag Auto-Init", () => {
await bubble.click();
await expect(page.locator(".perspective-float-window")).toBeVisible();
- await page.locator(".perspective-float-window .perspective-close").click();
+ // Click the app's X (in the iframe) → perspective:close → window closes.
+ // Wait for the loading overlay to detach first so it doesn't cover the iframe.
+ await page
+ .locator(".perspective-float-window .perspective-loading")
+ .waitFor({ state: "detached" });
+ await page
+ .frameLocator(".perspective-float-window iframe[data-perspective]")
+ .locator("#app-close")
+ .click();
await expect(page.locator(".perspective-float-window")).not.toBeVisible();
await page.reload();
@@ -343,8 +351,15 @@ test.describe("Popup Lifecycle", () => {
await page.click("#open-popup-btn");
await expect(page.locator(".perspective-overlay")).toBeVisible();
- // Click close button
- await page.click(".perspective-close");
+ // Click the app's X (in the iframe) → perspective:close → popup closes.
+ // Wait for the loading overlay to detach first so it doesn't cover the iframe.
+ await page
+ .locator(".perspective-overlay .perspective-loading")
+ .waitFor({ state: "detached" });
+ await page
+ .frameLocator(".perspective-overlay iframe[data-perspective]")
+ .locator("#app-close")
+ .click();
// Popup should be closed
await expect(page.locator(".perspective-overlay")).not.toBeVisible();
@@ -404,8 +419,15 @@ test.describe("Slider Lifecycle", () => {
await page.click("#open-slider-btn");
await expect(page.locator(".perspective-slider")).toBeVisible();
- // Click close button (inside slider)
- await page.locator(".perspective-slider .perspective-close").click();
+ // Click the app's X (in the iframe) → perspective:close → slider closes.
+ // Wait for the loading overlay to detach first so it doesn't cover the iframe.
+ await page
+ .locator(".perspective-slider .perspective-loading")
+ .waitFor({ state: "detached" });
+ await page
+ .frameLocator(".perspective-slider iframe[data-perspective]")
+ .locator("#app-close")
+ .click();
// Slider should be closed
await expect(page.locator(".perspective-slider")).not.toBeVisible();
diff --git a/packages/sdk/e2e/fixtures/mock-iframe.html b/packages/sdk/e2e/fixtures/mock-iframe.html
index b53379b..b4d2728 100644
--- a/packages/sdk/e2e/fixtures/mock-iframe.html
+++ b/packages/sdk/e2e/fixtures/mock-iframe.html
@@ -34,6 +34,21 @@
Mock Interview
+
+
+
diff --git a/packages/sdk/src/float.test.ts b/packages/sdk/src/float.test.ts
index 7b62981..77d1d35 100644
--- a/packages/sdk/src/float.test.ts
+++ b/packages/sdk/src/float.test.ts
@@ -204,6 +204,64 @@ describe("createFloatBubble", () => {
expect(onClose).toHaveBeenCalled();
});
+ describe("close button handoff", () => {
+ const host = "https://getperspective.ai";
+ const researchId = "test-research-id";
+
+ const send = (iframe: HTMLIFrameElement, type: string) =>
+ window.dispatchEvent(
+ new MessageEvent("message", {
+ data: { type, researchId },
+ origin: host,
+ source: iframe.contentWindow,
+ })
+ );
+
+ it("removes the SDK close button as soon as the skeleton hides (visual-ready)", () => {
+ const handle = createFloatBubble({ researchId, host });
+ handle.open();
+
+ // Temporary X present while the skeleton is up...
+ expect(
+ document.querySelector(".perspective-float-window .perspective-close")
+ ).toBeTruthy();
+
+ send(handle.iframe!, "perspective:visual-ready");
+
+ // ...removed once the app is visible to draw its own (in its header).
+ expect(
+ document.querySelector(".perspective-float-window .perspective-close")
+ ).toBeFalsy();
+
+ handle.unmount();
+ });
+
+ it("sends renderCloseButton: true in init message", () => {
+ const handle = createFloatBubble({ researchId, host });
+ handle.open();
+
+ const postMessageSpy = vi.fn();
+ handle.iframe!.contentWindow!.postMessage = postMessageSpy;
+
+ send(handle.iframe!, "perspective:ready");
+
+ const initCall = postMessageSpy.mock.calls.find(
+ (args: unknown[]) =>
+ (args[0] as { type: string }).type === "perspective:init"
+ );
+ expect(initCall).toBeTruthy();
+ const init = initCall![0] as {
+ renderCloseButton: boolean;
+ hasCloseButton?: boolean;
+ };
+ expect(init.renderCloseButton).toBe(true);
+ // Legacy flag retired — float no longer sends it either.
+ expect(init.hasCloseButton).toBeUndefined();
+
+ handle.unmount();
+ });
+ });
+
it("unmount removes bubble and window", () => {
const handle = createFloatBubble({
researchId: "test-research-id",
diff --git a/packages/sdk/src/float.ts b/packages/sdk/src/float.ts
index bc743f0..57cad47 100644
--- a/packages/sdk/src/float.ts
+++ b/packages/sdk/src/float.ts
@@ -449,12 +449,16 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle {
getThemeClass(currentConfig.theme)
);
- // Create close button
+ // Temporary X over the loading skeleton, removed at skeleton-hide once the
+ // app draws its own in its header (see hideSkeleton). Hidden if disableClose.
const closeBtn = document.createElement("button");
closeBtn.className = "perspective-close";
closeBtn.innerHTML = CLOSE_ICON;
closeBtn.setAttribute("aria-label", "Close chat");
closeBtn.addEventListener("click", () => closeFloat());
+ if (currentConfig.disableClose) {
+ closeBtn.style.display = "none";
+ }
// Create loading indicator with theme and brand colors
const loading = createLoadingIndicator({
@@ -489,6 +493,9 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle {
loading.style.opacity = "0";
iframe!.style.opacity = "1";
setTimeout(() => loading.remove(), 150);
+ // The app now draws its own X in its header, so drop ours. The launcher
+ // bubble still closes the window.
+ closeBtn.remove();
};
// Set up message listener with loading state handling
@@ -522,7 +529,7 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle {
},
iframe,
host,
- { skipResize: true, hasCloseButton: true }
+ { skipResize: true, renderCloseButton: !currentConfig.disableClose }
);
// Register iframe for theme change notifications
diff --git a/packages/sdk/src/iframe.ts b/packages/sdk/src/iframe.ts
index df30ae2..019247a 100644
--- a/packages/sdk/src/iframe.ts
+++ b/packages/sdk/src/iframe.ts
@@ -332,7 +332,10 @@ export function setupMessageListener(
config: Partial,
iframe: HTMLIFrameElement,
host: string,
- options?: { skipResize?: boolean; hasCloseButton?: boolean }
+ options?: {
+ skipResize?: boolean;
+ renderCloseButton?: boolean;
+ }
): () => void {
if (!hasDom()) {
return () => {};
@@ -373,13 +376,16 @@ export function setupMessageListener(
type: MESSAGE_TYPES.anonId,
anonId: getOrCreateAnonId(),
});
- // Send init message with version/features for handshake
+ // Init handshake. `renderCloseButton` asks the app to draw its own
+ // close X (slider/popup/float); widget/fullpage have none and omit it.
sendMessage(iframe, host, {
type: MESSAGE_TYPES.init,
version: SDK_VERSION,
features: CURRENT_FEATURES,
researchId,
- hasCloseButton: options?.hasCloseButton ?? false,
+ ...(options?.renderCloseButton !== undefined && {
+ renderCloseButton: options.renderCloseButton,
+ }),
});
// Layer 2 → Layer 1 relay: on iframe load, send any cached token from
// parent's first-party localStorage back to the iframe. On Safari this
diff --git a/packages/sdk/src/popup.test.ts b/packages/sdk/src/popup.test.ts
index 29e815a..0ee1cdb 100644
--- a/packages/sdk/src/popup.test.ts
+++ b/packages/sdk/src/popup.test.ts
@@ -430,7 +430,7 @@ describe("openPopup", () => {
expect(document.querySelector(".perspective-overlay")).toBeFalsy();
});
- it("sends hasCloseButton: false in init message when disableClose is true", () => {
+ it("sends renderCloseButton: false in init message when disableClose is true", () => {
const host = "https://getperspective.ai";
const researchId = "test-research-id";
const postMessageSpy = vi.fn();
@@ -458,14 +458,18 @@ describe("openPopup", () => {
(args[0] as { type: string }).type === "perspective:init"
);
expect(initCall).toBeTruthy();
- expect((initCall![0] as { hasCloseButton: boolean }).hasCloseButton).toBe(
- false
- );
+ const init = initCall![0] as {
+ renderCloseButton: boolean;
+ hasCloseButton?: boolean;
+ };
+ expect(init.renderCloseButton).toBe(false);
+ // Slider/popup no longer send the legacy flag — app prefers renderCloseButton
+ expect(init.hasCloseButton).toBeUndefined();
handle.unmount();
});
- it("sends hasCloseButton: true in init message when disableClose is not set", () => {
+ it("sends renderCloseButton: true in init message when disableClose is not set", () => {
const host = "https://getperspective.ai";
const researchId = "test-research-id";
const postMessageSpy = vi.fn();
@@ -490,10 +494,37 @@ describe("openPopup", () => {
(args[0] as { type: string }).type === "perspective:init"
);
expect(initCall).toBeTruthy();
- expect((initCall![0] as { hasCloseButton: boolean }).hasCloseButton).toBe(
- true
+ const init = initCall![0] as {
+ renderCloseButton: boolean;
+ hasCloseButton?: boolean;
+ };
+ expect(init.renderCloseButton).toBe(true);
+ expect(init.hasCloseButton).toBeUndefined();
+
+ handle.unmount();
+ });
+
+ it("removes the SDK close button as soon as the skeleton hides (visual-ready)", () => {
+ const host = "https://getperspective.ai";
+ const researchId = "test-research-id";
+
+ const handle = openPopup({ researchId, host });
+
+ // The temporary X is present while the skeleton is up...
+ expect(document.querySelector(".perspective-close")).toBeTruthy();
+
+ // ...and removed at skeleton-hide — which fires on `visual-ready`, before
+ // `ready` — so it never lingers over the now-visible app's own X.
+ window.dispatchEvent(
+ new MessageEvent("message", {
+ data: { type: "perspective:visual-ready", researchId },
+ origin: host,
+ source: handle.iframe!.contentWindow,
+ })
);
+ expect(document.querySelector(".perspective-close")).toBeFalsy();
+
handle.unmount();
});
});
diff --git a/packages/sdk/src/popup.ts b/packages/sdk/src/popup.ts
index efe2935..58358e5 100644
--- a/packages/sdk/src/popup.ts
+++ b/packages/sdk/src/popup.ts
@@ -55,7 +55,8 @@ export function openPopup(config: InternalEmbedConfig): EmbedHandle {
const modal = document.createElement("div");
modal.className = "perspective-modal";
- // Create close button (hidden when disableClose is enabled)
+ // Temporary X over the loading skeleton, removed at skeleton-hide once the
+ // app draws its own (see hideSkeleton). Hidden when disableClose is set.
const closeBtn = document.createElement("button");
closeBtn.className = "perspective-close";
closeBtn.innerHTML = CLOSE_ICON;
@@ -104,6 +105,9 @@ export function openPopup(config: InternalEmbedConfig): EmbedHandle {
loading.style.opacity = "0";
iframe.style.opacity = "1";
setTimeout(() => loading.remove(), 150);
+ // The app now draws its own X, so drop ours. A brief no-X gap reads as
+ // loading; an SDK X left over the app's avatar/menu looks like a bug.
+ closeBtn.remove();
};
const persistOpenState = (open: boolean) => {
setPersistedOpenState({
@@ -169,7 +173,7 @@ export function openPopup(config: InternalEmbedConfig): EmbedHandle {
},
iframe,
host,
- { skipResize: true, hasCloseButton: !config.disableClose }
+ { skipResize: true, renderCloseButton: !config.disableClose }
);
// Close handlers (disabled when disableClose is enabled)
diff --git a/packages/sdk/src/slider.test.ts b/packages/sdk/src/slider.test.ts
index 4ae7187..1948971 100644
--- a/packages/sdk/src/slider.test.ts
+++ b/packages/sdk/src/slider.test.ts
@@ -108,6 +108,30 @@ describe("openSlider", () => {
expect(onClose).toHaveBeenCalled();
});
+ it("removes the SDK close button as soon as the skeleton hides (visual-ready)", () => {
+ const host = "https://getperspective.ai";
+ const researchId = "test-research-id";
+
+ const handle = openSlider({ researchId, host });
+
+ // The temporary X is present while the skeleton is up...
+ expect(document.querySelector(".perspective-close")).toBeTruthy();
+
+ // ...and removed at skeleton-hide — which fires on `visual-ready`, before
+ // `ready` — so it never lingers over the now-visible app's own X.
+ window.dispatchEvent(
+ new MessageEvent("message", {
+ data: { type: "perspective:visual-ready", researchId },
+ origin: host,
+ source: handle.iframe!.contentWindow,
+ })
+ );
+
+ expect(document.querySelector(".perspective-close")).toBeFalsy();
+
+ handle.unmount();
+ });
+
it("closes on backdrop click", () => {
const onClose = vi.fn();
openSlider({
diff --git a/packages/sdk/src/slider.ts b/packages/sdk/src/slider.ts
index ad62fb2..0c5356f 100644
--- a/packages/sdk/src/slider.ts
+++ b/packages/sdk/src/slider.ts
@@ -67,7 +67,8 @@ export function openSlider(config: InternalEmbedConfig): EmbedHandle {
getThemeClass(config.theme)
);
- // Create close button (hidden when disableClose is enabled)
+ // Temporary X over the loading skeleton, removed at skeleton-hide once the
+ // app draws its own (see hideSkeleton). Hidden when disableClose is set.
const closeBtn = document.createElement("button");
closeBtn.className = "perspective-close";
closeBtn.innerHTML = CLOSE_ICON;
@@ -142,6 +143,9 @@ export function openSlider(config: InternalEmbedConfig): EmbedHandle {
loading.style.opacity = "0";
iframe.style.opacity = "1";
setTimeout(() => loading.remove(), 150);
+ // The app now draws its own X, so drop ours. A brief no-X gap reads as
+ // loading; an SDK X left over the app's avatar/menu looks like a bug.
+ closeBtn.remove();
};
const persistOpenState = (open: boolean) => {
setPersistedOpenState({
@@ -209,7 +213,7 @@ export function openSlider(config: InternalEmbedConfig): EmbedHandle {
},
iframe,
host,
- { skipResize: true, hasCloseButton: !config.disableClose }
+ { skipResize: true, renderCloseButton: !config.disableClose }
);
// Close handlers (disabled when disableClose is enabled)
diff --git a/packages/sdk/src/styles.ts b/packages/sdk/src/styles.ts
index 2c22792..f9ee75c 100644
--- a/packages/sdk/src/styles.ts
+++ b/packages/sdk/src/styles.ts
@@ -11,7 +11,6 @@ const LIGHT_THEME = `
--perspective-overlay-bg: rgba(0, 0, 0, 0.5);
--perspective-modal-bg: #ffffff;
--perspective-modal-text: #151B23;
- --perspective-close-bg: rgba(0, 0, 0, 0.1);
--perspective-close-text: #666666;
--perspective-close-hover-bg: rgba(0, 0, 0, 0.2);
--perspective-close-hover-text: #333333;
@@ -26,7 +25,6 @@ const DARK_THEME = `
--perspective-overlay-bg: rgba(0, 0, 0, 0.7);
--perspective-modal-bg: #02040a;
--perspective-modal-text: #ffffff;
- --perspective-close-bg: rgba(255, 255, 255, 0.1);
--perspective-close-text: #a0a0a0;
--perspective-close-hover-bg: rgba(255, 255, 255, 0.2);
--perspective-close-hover-text: #ffffff;
@@ -193,7 +191,8 @@ export function injectStyles(): void {
border: none;
}
- /* Close button */
+ /* Temporary close X — ghost style (no background) to match the app's own X,
+ so the handoff at skeleton-hide has no visual pop. Subtle bg on hover. */
.perspective-close {
position: absolute;
top: 1rem;
@@ -201,7 +200,7 @@ export function injectStyles(): void {
width: 2rem;
height: 2rem;
border: none;
- background: var(--perspective-close-bg);
+ background: transparent;
color: var(--perspective-close-text);
border-radius: 50%;
cursor: pointer;
diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts
index 1971975..32c87d0 100644
--- a/packages/sdk/src/types.ts
+++ b/packages/sdk/src/types.ts
@@ -261,8 +261,16 @@ export interface InitMessage {
version: string;
features: number;
researchId: string;
- /** Whether the SDK rendered a close button over the iframe. Older SDKs (pre-1.4) don't send this field — fall back to embed-type-based logic when undefined. */
+ /**
+ * @deprecated The SDK no longer sends this (it sends `renderCloseButton`).
+ * Kept because the app may still receive it from older SDK clients.
+ */
hasCloseButton?: boolean;
+ /**
+ * Whether the app should render its own close X. Sent for slider/popup/float;
+ * `false` when `disableClose` is set. widget/fullpage have no X and omit it.
+ */
+ renderCloseButton?: boolean;
}
/** Messages sent from iframe to SDK */