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 */