Skip to content
Open
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
40 changes: 40 additions & 0 deletions .changeset/generic-loading-skeleton.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@perspective-ai/sdk": minor
---

Replace the loading skeleton with the interview's real chrome.

Instead of a skeleton that mirrors the interview UI's internal layout (welcome
card, message, input pill) — which drifts whenever that UI changes — the
loading state now renders what the interview actually looks like from the
outside in:

- the research's **scene image** (fetched from
`{host}/interview/{researchId}/scene-image`) behind
- a **frosted translucent card** (55% panel + backdrop blur, matching the
app's card treatment), with
- the **animated Perspective mark** centered in the card (30px) — the same
LogoSymbol animation the interview shows under the current message
(official colors and timing, driven by CSS keyframes rather than SMIL so it
reliably animates on dynamic insertion; as essential loading motion it is
not disabled by `prefers-reduced-motion`).

The scene image is applied only after it has actually loaded, layered over
the app's default surface color (`#f5f2f0` light, `#15171e` dark, or a custom
`brand.bg`) — so a research without a scene (404), a slow network, or a
blocked request cleanly degrades to a solid color.

Because the loading state renders in the host page DOM (an overlay sibling of
the cross-origin iframe), responsiveness is driven by a CSS **container
query** on the slot the consumer provides — not a viewport media query. At
the app's own 672px breakpoint the card switches between a centered box
(desktop) and full-bleed (mobile / slider / popup / float), so the handoff to
the live UI has no shape jump.

`LoadingOptions` gains optional `researchId` and `host` (used for the scene
URL); all embed types pass them automatically.

A new `prefetchSceneImage(researchId, host?)` export warms the scene image on
intent signals — the SDK wires it to float-bubble hover/focus and to
`data-perspective-popup`/`-slider` trigger elements — so deferred embeds open
with the scene already cached.
9 changes: 9 additions & 0 deletions packages/sdk/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
markShown,
} from "./triggers";
import { createWidget } from "./widget";
import { prefetchSceneImage } from "./loading";
import { openPopup } from "./popup";
import { openSlider } from "./slider";
import { createFloatBubble, createChatBubble } from "./float";
Expand Down Expand Up @@ -659,6 +660,10 @@ function autoInit(): void {
}
});
styleButton(el, DEFAULT_THEME, brandConfig);
// Warm the scene image on intent so it's cached when the embed opens.
const warmScene = () => prefetchSceneImage(researchId);
el.addEventListener("pointerenter", warmScene, { once: true });
el.addEventListener("focus", warmScene, { once: true });
el.addEventListener("click", (e) => {
e.preventDefault();
// Cancel any pending API auto-trigger (manual open takes precedence)
Expand Down Expand Up @@ -739,6 +744,10 @@ function autoInit(): void {
}
});
styleButton(el, DEFAULT_THEME, brandConfig);
// Warm the scene image on intent so it's cached when the embed opens.
const warmScene = () => prefetchSceneImage(researchId);
el.addEventListener("pointerenter", warmScene, { once: true });
el.addEventListener("focus", warmScene, { once: true });
el.addEventListener("click", (e) => {
e.preventDefault();
// Cancel any pending API auto-trigger (manual open takes precedence)
Expand Down
16 changes: 16 additions & 0 deletions packages/sdk/src/float.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createFloatBubble, createChatBubble } from "./float";
import { prefetchSceneImage } from "./loading";
import * as config from "./config";
import { getPersistedOpenState, setPersistedOpenState } from "./state";

Expand All @@ -15,6 +16,21 @@ describe("createFloatBubble", () => {
vi.restoreAllMocks();
});

it("prefetches the scene image on bubble hover", () => {
createFloatBubble({
researchId: "float-prefetch-test",
host: "https://s.getperspective.ai",
});
const bubble = document.querySelector(
".perspective-float-bubble"
) as HTMLElement;
bubble.dispatchEvent(new Event("pointerenter"));
// hover already kicked the fetch, so a manual prefetch is a dedup no-op
expect(
prefetchSceneImage("float-prefetch-test", "https://s.getperspective.ai")
).toBeNull();
});

it("creates float bubble", () => {
const handle = createFloatBubble({
researchId: "test-research-id",
Expand Down
10 changes: 9 additions & 1 deletion packages/sdk/src/float.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
ensureGlobalListeners,
ensureHostPreconnect,
} from "./iframe";
import { createLoadingIndicator } from "./loading";
import { createLoadingIndicator, prefetchSceneImage } from "./loading";
import { injectStyles, MIC_ICON, MESSAGES_ICON, CLOSE_ICON } from "./styles";
import { getPersistedOpenState, setPersistedOpenState } from "./state";
import { cn, getThemeClass, resolveIsDark, readableTextColor } from "./utils";
Expand Down Expand Up @@ -464,6 +464,8 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle {
const loading = createLoadingIndicator({
theme: currentConfig.theme,
brand: currentConfig.brand,
researchId,
host,
});
loading.style.borderRadius = "16px";

Expand Down Expand Up @@ -563,6 +565,12 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle {
};

// Toggle on bubble click
// Warm the scene image on intent (hover/focus), so it's cached by the time
// the float window opens and mounts the loading state.
const warmScene = () => prefetchSceneImage(researchId, host);
bubble.addEventListener("pointerenter", warmScene, { once: true });
bubble.addEventListener("focus", warmScene, { once: true });

bubble.addEventListener("click", () => {
if (isOpen) {
closeFloat();
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/fullpage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export function createFullpage(config: InternalEmbedConfig): EmbedHandle {
const loading = createLoadingIndicator({
theme: config.theme,
brand: config.brand,
researchId,
host,
});
container.appendChild(loading);

Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export { openPopup } from "./popup";
export { openSlider } from "./slider";
export { createFloatBubble, createChatBubble } from "./float";
export { createFullpage } from "./fullpage";
export { createLoadingIndicator } from "./loading";
export { createLoadingIndicator, prefetchSceneImage } from "./loading";

// Auto-open triggers
export {
Expand Down
Loading
Loading