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
46 changes: 46 additions & 0 deletions .changeset/generic-loading-skeleton.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
"@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
- a **circular loader** centered in the card (40px) — a conic-gradient arc
with a fading tail over a faint track, tinted with the brand primary
resolved with the SDK's usual precedence: local `brand.primary` override →
API embed config (`primaryColor` / `darkPrimaryColor`) → Perspective
default (`#7c3aed` / `#a78bfa`). 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 CSS **container
queries** on the slot the consumer provides — not viewport media queries. The
card's sizing mirrors the interview app's own container queries (full-bleed
below 672px width; a centered `max-width: 672px` box above it, with vertical
padding stepping 4px → 48px → 80px at the app's 448px/768px container-height
breakpoints), so the card doesn't jump at handoff. Breakpoints are in px, not
the app's rem: the overlay lives in the host page, so rem would resolve against
the host's (uncontrolled) root font-size, whereas the card must match the
iframe's fixed pixels.

`LoadingOptions` gains optional `researchId` and `host` (used for the scene
URL) and `apiConfig` (used for the loader tint); 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 @@ -692,6 +693,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 @@ -772,6 +777,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 Down Expand Up @@ -41,6 +42,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
13 changes: 10 additions & 3 deletions packages/sdk/src/float.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
ensureGlobalListeners,
ensureHostPreconnect,
} from "./iframe";
import { createLoadingIndicator } from "./loading";
import { createLoadingIndicator, prefetchSceneImage } from "./loading";
import { injectStyles, AUDIO_ICON, MESSAGES_ICON, CLOSE_ICON } from "./styles";
import { getPersistedOpenState, setPersistedOpenState } from "./state";
import {
Expand Down Expand Up @@ -519,6 +519,9 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle {
const loading = createLoadingIndicator({
theme: currentConfig.theme,
brand: currentConfig.brand,
apiConfig: currentConfig._apiConfig,
researchId,
host,
});
loading.style.borderRadius = "16px";

Expand All @@ -531,8 +534,7 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle {
currentConfig.brand,
currentConfig.theme
);
iframe.style.opacity = "0";
iframe.style.transition = "opacity 0.15s ease";
iframe.style.opacity = "0"; // snaps visible at handoff — see widget.ts

floatWindow.appendChild(closeBtn);
floatWindow.appendChild(loading);
Expand Down Expand Up @@ -617,6 +619,11 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle {
currentConfig.onClose?.();
};

// Warm the scene image on intent so it's cached when the window opens.
const warmScene = () => prefetchSceneImage(researchId, host);
bubble.addEventListener("pointerenter", warmScene, { once: true });
bubble.addEventListener("focus", warmScene, { once: true });

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

Expand All @@ -66,8 +69,7 @@ export function createFullpage(config: InternalEmbedConfig): EmbedHandle {
config.brand,
config.theme
);
iframe.style.opacity = "0";
iframe.style.transition = "opacity 0.15s ease";
iframe.style.opacity = "0"; // snaps visible at handoff — see widget.ts

container.appendChild(iframe);
document.body.appendChild(container);
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