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
20 changes: 20 additions & 0 deletions .changeset/app-owned-close-button.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 27 additions & 5 deletions packages/sdk/e2e/embed.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
27 changes: 27 additions & 0 deletions packages/sdk/e2e/fixtures/mock-iframe.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ <h2>Mock Interview</h2>
<button id="send-resize">Send Resize Event</button>
<button id="send-error">Send Error Event</button>
</div>

<!-- App-rendered close X (mirrors codebase#4397), shown on renderCloseButton.
Pinned top-right so it stays in view/clickable in short containers. -->
<button
id="app-close"
style="
display: none;
position: fixed;
top: 8px;
right: 8px;
z-index: 10;
"
>
Close (app)
</button>
</div>

<script>
Expand Down Expand Up @@ -96,6 +111,14 @@ <h2>Mock Interview</h2>
});
});

// App-rendered close button emits perspective:close (mirrors codebase#4397)
document.getElementById("app-close").addEventListener("click", () => {
sendToParent({
type: "perspective:close",
researchId: researchId,
});
});

// Handle messages from parent
window.addEventListener("message", (event) => {
console.log("[MockIframe] Received message:", event.data);
Expand All @@ -106,6 +129,10 @@ <h2>Mock Interview</h2>
"[MockIframe] Init received, version:",
event.data.version
);
// The app renders its own X when the SDK asks it to.
if (event.data.renderCloseButton) {
document.getElementById("app-close").style.display = "block";
}
}
});
</script>
Expand Down
58 changes: 58 additions & 0 deletions packages/sdk/src/float.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 9 additions & 2 deletions packages/sdk/src/float.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions packages/sdk/src/iframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,10 @@ export function setupMessageListener(
config: Partial<EmbedConfig>,
iframe: HTMLIFrameElement,
host: string,
options?: { skipResize?: boolean; hasCloseButton?: boolean }
options?: {
skipResize?: boolean;
renderCloseButton?: boolean;
}
): () => void {
if (!hasDom()) {
return () => {};
Expand Down Expand Up @@ -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,
Comment thread
LEVI-RIVKIN marked this conversation as resolved.
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
Expand Down
45 changes: 38 additions & 7 deletions packages/sdk/src/popup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -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();
});
});
Expand Down
8 changes: 6 additions & 2 deletions packages/sdk/src/popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions packages/sdk/src/slider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading