From ca44f6ec211458672584d542af3af1e6edc7961e Mon Sep 17 00:00:00 2001 From: Levi Rivkin Date: Tue, 7 Jul 2026 18:50:12 +0000 Subject: [PATCH] feat(sdk): loading state = scene image + frosted card + circular loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the loading skeleton with the interview's real chrome, outside-in: the research's scene image, a frosted translucent card over it (55% panel + backdrop blur, matching the app's card treatment), and a 40px circular loader centered in the card (conic-gradient arc with a fading tail over a faint track, round cap at the leading edge). - Loader tint follows the SDK's usual precedence (same as the float launcher): local brand.primary → API embed config primaryColor / darkPrimaryColor → Perspective default (#7c3aed / #a78bfa). LoadingOptions gains `apiConfig`; all five embeds pass _apiConfig through. Browsers without color-mix() keep an arc-only fallback. - Scene image comes from {host}/interview/{researchId}/scene-image and is applied only after it actually loads, layered over the app's default surface color (#f5f2f0 light / #15171e dark, or brand.bg) — a research without a scene (404), slow network, or blocked request degrades to a clean solid color. prefetchSceneImage() warms it on intent signals (float-bubble hover/focus, popup/slider trigger elements). - Container-query responsive: the overlay lives in the host page DOM next to the cross-origin iframe, so layout keys off the consumer's slot, not the viewport. At the app's own 672px breakpoint the card switches between a centered box (height-relative percentage inset) and full-bleed. - The loader always animates — essential loading motion; a frozen spinner reads as hung — so there is no prefers-reduced-motion override. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016kJptBdnAQ2VfzrE9ob1Wb --- .changeset/generic-loading-skeleton.md | 42 +++ packages/sdk/src/browser.ts | 9 + packages/sdk/src/float.test.ts | 16 ++ packages/sdk/src/float.ts | 11 +- packages/sdk/src/fullpage.ts | 4 +- packages/sdk/src/index.ts | 2 +- packages/sdk/src/loading.test.ts | 243 +++++++++++------ packages/sdk/src/loading.ts | 354 +++++++++++++++---------- packages/sdk/src/popup.ts | 4 +- packages/sdk/src/slider.ts | 4 +- packages/sdk/src/widget.ts | 4 +- 11 files changed, 463 insertions(+), 230 deletions(-) create mode 100644 .changeset/generic-loading-skeleton.md diff --git a/.changeset/generic-loading-skeleton.md b/.changeset/generic-loading-skeleton.md new file mode 100644 index 0000000..6dc3440 --- /dev/null +++ b/.changeset/generic-loading-skeleton.md @@ -0,0 +1,42 @@ +--- +"@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 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) 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. diff --git a/packages/sdk/src/browser.ts b/packages/sdk/src/browser.ts index e1bef5f..e3be0b4 100644 --- a/packages/sdk/src/browser.ts +++ b/packages/sdk/src/browser.ts @@ -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"; @@ -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) @@ -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) diff --git a/packages/sdk/src/float.test.ts b/packages/sdk/src/float.test.ts index 77d1d35..f9a208f 100644 --- a/packages/sdk/src/float.test.ts +++ b/packages/sdk/src/float.test.ts @@ -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"; @@ -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", diff --git a/packages/sdk/src/float.ts b/packages/sdk/src/float.ts index 57cad47..ed166c9 100644 --- a/packages/sdk/src/float.ts +++ b/packages/sdk/src/float.ts @@ -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"; @@ -460,10 +460,12 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle { closeBtn.style.display = "none"; } - // Create loading indicator with theme and brand colors const loading = createLoadingIndicator({ theme: currentConfig.theme, brand: currentConfig.brand, + apiConfig: currentConfig._apiConfig, + researchId, + host, }); loading.style.borderRadius = "16px"; @@ -562,6 +564,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) { diff --git a/packages/sdk/src/fullpage.ts b/packages/sdk/src/fullpage.ts index bca26cb..d0015d7 100644 --- a/packages/sdk/src/fullpage.ts +++ b/packages/sdk/src/fullpage.ts @@ -50,10 +50,12 @@ export function createFullpage(config: InternalEmbedConfig): EmbedHandle { getThemeClass(config.theme) ); - // Create loading indicator with theme and brand colors const loading = createLoadingIndicator({ theme: config.theme, brand: config.brand, + apiConfig: config._apiConfig, + researchId, + host, }); container.appendChild(loading); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index c48b49f..dba1599 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -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 { diff --git a/packages/sdk/src/loading.test.ts b/packages/sdk/src/loading.test.ts index 59daca1..891f13a 100644 --- a/packages/sdk/src/loading.test.ts +++ b/packages/sdk/src/loading.test.ts @@ -1,5 +1,12 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { createLoadingIndicator } from "./loading"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createLoadingIndicator, prefetchSceneImage } from "./loading"; + +/** The shared stylesheet is injected once; read it back for assertions. */ +function injectedStyles(): string { + return ( + document.getElementById("perspective-loading-styles")?.textContent ?? "" + ); +} describe("createLoadingIndicator", () => { beforeEach(() => { @@ -8,6 +15,7 @@ describe("createLoadingIndicator", () => { afterEach(() => { document.body.innerHTML = ""; + vi.unstubAllGlobals(); }); it("returns an element with perspective-loading class", () => { @@ -15,126 +23,205 @@ describe("createLoadingIndicator", () => { expect(el.className).toBe("perspective-loading"); }); - it("renders header with logo and progress bar", () => { - const el = createLoadingIndicator(); - const header = el.children[0] as HTMLElement; - expect(header.children.length).toBe(2); // logo + progress bar - }); - - it("renders welcome card with avatar, name, title, and body lines", () => { + it("renders scene layer, then content > frosted card > ring loader", () => { const el = createLoadingIndicator(); - const card = el.children[1] as HTMLElement; - expect(card.style.borderRadius).toBe("16px"); - // Avatar row + title + 3 body lines = 5 children - expect(card.children.length).toBe(5); - // Avatar row has circle + name - const avatarRow = card.children[0] as HTMLElement; - const avatar = avatarRow.children[0] as HTMLElement; - expect(avatar.style.borderRadius).toBe("50%"); + const scene = el.children[0] as HTMLElement; + expect(scene.className).toBe("perspective-loading__scene"); + const content = el.children[1] as HTMLElement; + expect(content.className).toBe("perspective-loading__content"); + const card = content.firstElementChild as HTMLElement; + expect(card.className).toBe("perspective-loading__card"); + expect(card.children.length).toBe(1); + const ring = card.firstElementChild as HTMLElement; + expect(ring.className).toBe("perspective-loading__ring"); + }); + + it("animates the ring via CSS keyframes (always — essential motion)", () => { + createLoadingIndicator(); + const css = injectedStyles(); + expect(css).toContain("@keyframes perspective-spin"); + expect(css).toMatch( + /\.perspective-loading__ring\s*\{[^}]*animation:[^}]*perspective-spin/ + ); + // arc-only fallback declared before the color-mix track layer + expect(css).toMatch( + /background-image:\s*conic-gradient[^;]*;\s*background-image:\s*\n?\s*conic-gradient[\s\S]*color-mix/ + ); + // no reduced-motion override — a frozen spinner reads as hung + expect(css).not.toContain("@media (prefers-reduced-motion"); }); - it("renders chat message lines below card", () => { - const el = createLoadingIndicator(); - const message = el.children[2] as HTMLElement; - // 2 text lines + icon = 3 children - expect(message.children.length).toBe(3); + it("sizes the ring at 40px", () => { + createLoadingIndicator(); + expect(injectedStyles()).toMatch( + /\.perspective-loading__ring\s*\{[^}]*width:\s*40px/ + ); }); - it("renders input area with pill button", () => { - const el = createLoadingIndicator(); - const inputArea = el.children[3] as HTMLElement; - expect(inputArea.style.borderRadius).toBe("28px"); - const pill = inputArea.firstElementChild as HTMLElement; - expect(pill.style.borderRadius).toBe("9999px"); + it("builds the scene URL from host + researchId (encoded)", () => { + const el = createLoadingIndicator({ + researchId: "pb b/vdh26", + host: "https://s.getperspective.ai", + }); + const scene = el.children[0] as HTMLElement; + expect(scene.dataset.sceneUrl).toBe( + "https://s.getperspective.ai/interview/pb%20b%2Fvdh26/scene-image" + ); + // not applied as a background until the image actually loads + expect(scene.style.backgroundImage).toBe(""); }); - it("does not render footer hint line", () => { + it("skips the scene image without researchId/host", () => { const el = createLoadingIndicator(); - const footer = el.children[4] as HTMLElement; - expect(footer).toBeFalsy(); + const scene = el.children[0] as HTMLElement; + expect(scene.dataset.sceneUrl).toBeUndefined(); + expect(scene.style.backgroundImage).toBe(""); + }); + + it("injects a stylesheet with the container query targeting descendants", () => { + createLoadingIndicator(); + const css = injectedStyles(); + expect(css).toContain("container-type: inline-size"); + expect(css).toContain("@container (min-width: 672px)"); + expect(css).not.toContain("@media (min-width"); + // An element can't be styled by the container it establishes. + expect(css).toMatch( + /@container[^{]*\{\s*\.perspective-loading__content\s*\{/ + ); + expect(css).not.toMatch(/@container[^{]*\{\s*\.perspective-loading\s*\{/); }); - it("shimmer elements have animation applied", () => { - const el = createLoadingIndicator(); - const header = el.children[0] as HTMLElement; - const logo = header.firstElementChild as HTMLElement; - expect(logo.style.animation).toContain("perspective-shimmer"); + it("frosts the card with the panel color and backdrop blur", () => { + createLoadingIndicator(); + const css = injectedStyles(); + expect(css).toMatch( + /\.perspective-loading__card\s*\{[^}]*background:\s*var\(--pl-panel\)/ + ); + expect(css).toContain("backdrop-filter: blur(22px)"); }); - it("uses light theme background by default", () => { + it("defaults to the app's light interview background", () => { const el = createLoadingIndicator(); - expect(el.style.background).toBe("#ffffff"); + expect(el.style.background).toContain("#f5f2f0"); + expect(el.style.getPropertyValue("--pl-panel")).toContain("255, 255, 255"); }); - it("uses dark theme background when specified", () => { + it("defaults to the app's dark interview background", () => { const el = createLoadingIndicator({ theme: "dark" }); - expect(el.style.background).toBe("#02040a"); + expect(el.style.background).toContain("#15171e"); + expect(el.style.getPropertyValue("--pl-panel")).toContain("21, 23, 30"); }); it("uses brand bg color when provided", () => { const el = createLoadingIndicator({ brand: { light: { bg: "#1a3a2a" } }, }); - expect(el.style.background).toBe("#1a3a2a"); + expect(el.style.background).toContain("#1a3a2a"); }); - it("uses a dark shimmer/border palette for a dark brand bg under light theme", () => { + it("picks a dark panel palette for a dark brand bg under light theme", () => { const el = createLoadingIndicator({ theme: "light", brand: { light: { bg: "#0a0a0a" } }, // dark bg despite light theme }); - expect(el.style.background).toBe("#0a0a0a"); - // Dark palette uses white-based translucent shimmer/borders so they stay visible - expect(el.innerHTML).toContain("rgba(255, 255, 255"); - expect(el.innerHTML).not.toContain("rgba(0, 0, 0"); + expect(el.style.background).toContain("#0a0a0a"); + expect(el.style.getPropertyValue("--pl-panel")).toContain("21, 23, 30"); }); - it("uses a light shimmer/border palette for a light brand bg under dark theme", () => { + it("picks a light panel palette for a light brand bg under dark theme", () => { const el = createLoadingIndicator({ theme: "dark", brand: { dark: { bg: "#fafafa" } }, // light bg despite dark theme }); - expect(el.style.background).toBe("#fafafa"); - expect(el.innerHTML).toContain("rgba(0, 0, 0"); - expect(el.innerHTML).not.toContain("rgba(255, 255, 255"); + expect(el.style.background).toContain("#fafafa"); + expect(el.style.getPropertyValue("--pl-panel")).toContain("255, 255, 255"); }); - it("has column layout with padding", () => { - const el = createLoadingIndicator(); - expect(el.style.flexDirection).toBe("column"); - expect(el.style.padding).toBe("1.5rem"); - }); + describe("ring primary color precedence", () => { + it("defaults to the Perspective primary per theme", () => { + const light = createLoadingIndicator({ theme: "light" }); + expect(light.style.getPropertyValue("--pl-primary")).toBe("#7c3aed"); + const dark = createLoadingIndicator({ theme: "dark" }); + expect(dark.style.getPropertyValue("--pl-primary")).toBe("#a78bfa"); + }); + + it("uses the API embed config primary when provided", () => { + const light = createLoadingIndicator({ + theme: "light", + apiConfig: { primaryColor: "#0ea5e9", darkPrimaryColor: "#38bdf8" }, + }); + expect(light.style.getPropertyValue("--pl-primary")).toBe("#0ea5e9"); + const dark = createLoadingIndicator({ + theme: "dark", + apiConfig: { primaryColor: "#0ea5e9", darkPrimaryColor: "#38bdf8" }, + }); + expect(dark.style.getPropertyValue("--pl-primary")).toBe("#38bdf8"); + }); - it("hides logo when hideBranding is true", () => { - const el = createLoadingIndicator({ appearance: { hideBranding: true } }); - // Header should only have progress bar, no logo - const header = el.children[0] as HTMLElement; - expect(header.children.length).toBe(1); + it("local brand primary beats the API config", () => { + const el = createLoadingIndicator({ + theme: "light", + brand: { light: { primary: "#e11d48" } }, + apiConfig: { primaryColor: "#0ea5e9", darkPrimaryColor: "#38bdf8" }, + }); + expect(el.style.getPropertyValue("--pl-primary")).toBe("#e11d48"); + }); + + it("falls back to default when API config omits the dark variant", () => { + const el = createLoadingIndicator({ + theme: "dark", + apiConfig: { primaryColor: "#0ea5e9" }, + }); + expect(el.style.getPropertyValue("--pl-primary")).toBe("#a78bfa"); + }); }); - it("hides progress bar when hideProgress is true", () => { - const el = createLoadingIndicator({ appearance: { hideProgress: true } }); - // Header should only have logo, no progress bar - const header = el.children[0] as HTMLElement; - expect(header.children.length).toBe(1); + it("lets callers override the root border radius (float/popup windows)", () => { + const el = createLoadingIndicator(); + el.style.borderRadius = "16px"; + const css = injectedStyles(); + expect(el.style.borderRadius).toBe("16px"); + expect(css).toMatch(/\.perspective-loading\s*\{[^}]*overflow:\s*hidden/); }); - it("hides entire header when both hideBranding and hideProgress", () => { - const defaultEl = createLoadingIndicator(); - const hiddenEl = createLoadingIndicator({ - appearance: { hideBranding: true, hideProgress: true }, + it("accepts the deprecated appearance option and ignores it", () => { + const el = createLoadingIndicator({ + appearance: { + hideBranding: true, + hideProgress: true, + hideGreeting: true, + }, }); - // One fewer child (no header section) - expect(hiddenEl.children.length).toBe(defaultEl.children.length - 1); + // structure is unchanged — the loading state has no branding/progress/greeting + expect(el.querySelector(".perspective-loading__card")).toBeTruthy(); + expect(el.querySelector(".perspective-loading__ring")).toBeTruthy(); }); - it("hides welcome card when hideGreeting is true", () => { - const el = createLoadingIndicator({ appearance: { hideGreeting: true } }); - // Should have header, message, input, footer — no card - const children = Array.from(el.children); - const hasCard = children.some( - (c) => (c as HTMLElement).style.borderRadius === "16px" + it("prefetchSceneImage fetches once per URL and dedupes after", () => { + const url = prefetchSceneImage( + "prefetch-once", + "https://s.getperspective.ai" + ); + expect(url).toBe( + "https://s.getperspective.ai/interview/prefetch-once/scene-image" ); - expect(hasCard).toBe(false); + // second intent signal for the same research is a no-op + expect( + prefetchSceneImage("prefetch-once", "https://s.getperspective.ai") + ).toBeNull(); + }); + + it("prefetchSceneImage is a no-op without a researchId", () => { + expect(prefetchSceneImage(undefined, "https://x.example")).toBeNull(); + }); + + it("mounting the loading state marks its scene URL as already requested", () => { + createLoadingIndicator({ + researchId: "mounted-first", + host: "https://s.getperspective.ai", + }); + expect( + prefetchSceneImage("mounted-first", "https://s.getperspective.ai") + ).toBeNull(); }); }); diff --git a/packages/sdk/src/loading.ts b/packages/sdk/src/loading.ts index 4985788..3ad33a1 100644 --- a/packages/sdk/src/loading.ts +++ b/packages/sdk/src/loading.ts @@ -1,30 +1,40 @@ /** - * Loading skeleton for embed iframes + * Loading overlay for embed iframes: the research's scene image behind a + * frosted card, with a circular loader centered in the card — the + * interview's stable chrome, not a skeleton that drifts when the app UI + * changes. * - * Shows a skeleton that matches the conversation UI layout (welcome card + - * input area) instead of a generic spinner. This makes the loading feel - * faster because it sets visual expectations. - * - * SSR-safe - returns no-op on server + * Lives in the host page DOM (the iframe is cross-origin), so layout uses + * container queries on the consumer's slot, not viewport media queries. + * SSR-safe: no-op without a DOM. */ -import type { BrandColors, ThemeValue } from "./types"; -import { hasDom } from "./config"; +import type { BrandColors, ThemeConfig, ThemeValue } from "./types"; +import { hasDom, getHost } from "./config"; +import { DEFAULT_THEME } from "./embed-api"; import { resolveTheme, readableTextColor } from "./utils"; -/** Default background + semi-transparent shimmer that works on any bg */ +/** + * The app's own breakpoint for its centered-card layout, so the handoff has + * no shape jump. px, not rem — rem would track the host page's font-size. + */ +const DESKTOP_BREAKPOINT_PX = 672; + +/** + * Matches the app's default surface (`bg-interview-bg`) and translucent card, + * so nothing flashes at handoff. `brand.bg` overrides the background; the + * panel palette follows the background's luminance (see getLoadingColors). + */ const DEFAULT_COLORS = { light: { - bg: "#ffffff", - shimmer: "rgba(0, 0, 0, 0.06)", - shimmerHighlight: "rgba(0, 0, 0, 0.10)", - border: "rgba(0, 0, 0, 0.08)", + bg: "#f5f2f0", + panel: "rgba(255, 255, 255, 0.55)", + panelBorder: "rgba(0, 0, 0, 0.08)", }, dark: { - bg: "#02040a", - shimmer: "rgba(255, 255, 255, 0.08)", - shimmerHighlight: "rgba(255, 255, 255, 0.13)", - border: "rgba(255, 255, 255, 0.08)", + bg: "#15171e", + panel: "rgba(21, 23, 30, 0.55)", + panelBorder: "rgba(255, 255, 255, 0.08)", }, }; @@ -36,7 +46,16 @@ export interface LoadingOptions { light?: BrandColors; dark?: BrandColors; }; - /** Appearance overrides from API config */ + /** Enables the scene image behind the card (requires `host`) */ + researchId?: string; + /** Embed host for the scene URL */ + host?: string; + /** API embed config — loader tint fallback when `brand.primary` is unset */ + apiConfig?: Partial; + /** + * @deprecated Ignored — resolved server-side, and the loading state renders + * none of these. Kept so existing TS callers compile. + */ appearance?: { hideBranding?: boolean; hideProgress?: boolean; @@ -44,46 +63,167 @@ export interface LoadingOptions { }; } -/** Get colors for loading skeleton based on theme and brand */ function getLoadingColors(options?: LoadingOptions): { bg: string; - shimmer: string; - shimmerHighlight: string; - border: string; + panel: string; + panelBorder: string; + primary: string; } { const theme = resolveTheme(options?.theme); const isDark = theme === "dark"; const brandColors = isDark ? options?.brand?.dark : options?.brand?.light; const bg = brandColors?.bg; - // Choose the shimmer/border palette from the actual background luminance when - // a brand bg is set, so a dark brand.bg under a light theme (or vice versa) - // still gets visible shimmer and borders. readableTextColor returns white for - // dark backgrounds; fall back to the theme default for non-hex bg values. + // Tint precedence (same as the float launcher): brand → API config → default. + const api = options?.apiConfig; + const primary = isDark + ? (brandColors?.primary ?? + api?.darkPrimaryColor ?? + DEFAULT_THEME.darkPrimaryColor) + : (brandColors?.primary ?? api?.primaryColor ?? DEFAULT_THEME.primaryColor); + + // Panel palette follows the bg's luminance, so a dark brand.bg under a + // light theme still gets a readable card. Non-hex bg → theme default. const fg = bg ? readableTextColor(bg) : undefined; const useDarkPalette = fg ? fg === "#ffffff" : isDark; const defaults = useDarkPalette ? DEFAULT_COLORS.dark : DEFAULT_COLORS.light; return { bg: bg || defaults.bg, - shimmer: defaults.shimmer, - shimmerHighlight: defaults.shimmerHighlight, - border: defaults.border, + panel: defaults.panel, + panelBorder: defaults.panelBorder, + primary: primary || DEFAULT_THEME.primaryColor, }; } +function buildSceneUrl(researchId: string, host: string): string { + return `${host}/interview/${encodeURIComponent(researchId)}/scene-image`; +} -/** Inject shimmer keyframes once globally (shared across all embed instances) */ -let shimmerInjected = false; -function injectShimmerKeyframes(): void { - if (shimmerInjected || !hasDom()) return; - shimmerInjected = true; +/** + * Dedupes prefetch kick-offs. A mount still creates its own Image() — it + * needs the load event — but a prior prefetch makes it resolve from the + * browser cache, so nothing is fetched twice. + */ +const requestedSceneUrls = new Set(); + +/** + * Warm the scene image on intent signals (float-bubble hover, popup/slider + * triggers) so it's cached when the embed opens. 404 is harmless. Returns + * the URL, or null when skipped (no DOM/researchId, or already requested). + */ +export function prefetchSceneImage( + researchId?: string, + host?: string +): string | null { + if (!hasDom() || !researchId) return null; + const sceneUrl = buildSceneUrl(researchId, getHost(host)); + if (requestedSceneUrls.has(sceneUrl)) return null; + requestedSceneUrls.add(sceneUrl); + new Image().src = sceneUrl; + return sceneUrl; +} + +/** + * Injected once — per-instance colors arrive as CSS custom properties on + * each root, so one stylesheet serves every embed. + */ +let stylesInjected = false; +function injectStyles(): void { + if (stylesInjected || !hasDom()) return; + stylesInjected = true; const styleEl = document.createElement("style"); - styleEl.id = "perspective-shimmer-keyframes"; + styleEl.id = "perspective-loading-styles"; + // The query targets descendants — an element can't be styled by the + // container it establishes. The card inset is a percentage on the + // absolutely-positioned wrapper so it tracks the slot's HEIGHT + // (padding/margin % resolve against width; vh would track the host + // viewport). Rounded float/popup windows are clipped by the root's + // overflow:hidden. styleEl.textContent = ` - @keyframes perspective-shimmer { - 0% { background-position: -400px 0; } - 100% { background-position: 400px 0; } + .perspective-loading { + position: absolute; + inset: 0; + container-type: inline-size; + overflow: hidden; + box-sizing: border-box; + transition: opacity 0.15s ease; + z-index: 1; + } + .perspective-loading__scene { + position: absolute; + inset: 0; + background-position: center; + background-size: cover; + background-repeat: no-repeat; + transform: scale(1.1); + opacity: 0; + transition: opacity 0.3s ease; + } + .perspective-loading__content { + position: absolute; + inset: 0; + display: flex; + align-items: stretch; + justify-content: center; + box-sizing: border-box; + } + .perspective-loading__card { + flex: 1 1 auto; + width: 100%; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + background: var(--pl-panel); + -webkit-backdrop-filter: blur(22px) saturate(1.15); + backdrop-filter: blur(22px) saturate(1.15); + } + /* + * Conic arc + faint track; ::after is the round cap at the leading edge + * (top). Plain conic-gradient first: browsers without color-mix() keep + * it as an arc-only fallback. No reduced-motion override — a frozen + * spinner reads as hung. + */ + .perspective-loading__ring { + width: 40px; + height: 40px; + border-radius: 50%; + position: relative; + background-image: conic-gradient(from 0turn, transparent 12%, var(--pl-primary)); + background-image: + conic-gradient(from 0turn, transparent 12%, var(--pl-primary)), + linear-gradient( + color-mix(in srgb, var(--pl-primary) 16%, transparent), + color-mix(in srgb, var(--pl-primary) 16%, transparent) + ); + -webkit-mask: radial-gradient(closest-side, transparent calc(100% - 4.5px), #000 calc(100% - 4px)); + mask: radial-gradient(closest-side, transparent calc(100% - 4.5px), #000 calc(100% - 4px)); + animation: perspective-spin 0.9s linear infinite; + } + .perspective-loading__ring::after { + content: ""; + position: absolute; + top: 0; + left: 50%; + width: 4.5px; + height: 4.5px; + margin-left: -2.25px; + border-radius: 50%; + background: var(--pl-primary); + } + @keyframes perspective-spin { + to { transform: rotate(1turn); } + } + @container (min-width: ${DESKTOP_BREAKPOINT_PX}px) { + .perspective-loading__content { + inset: clamp(1.5rem, 10%, 5rem) 2rem; + } + .perspective-loading__card { + max-width: ${DESKTOP_BREAKPOINT_PX}px; + border: 1px solid var(--pl-panel-border); + border-radius: 16px; + } } `; document.head.appendChild(styleEl); @@ -95,121 +235,45 @@ export function createLoadingIndicator(options?: LoadingOptions): HTMLElement { return { remove: () => {}, style: {} } as unknown as HTMLElement; } - injectShimmerKeyframes(); + injectStyles(); const colors = getLoadingColors(options); - const appearance = options?.appearance; + // Paints the bg instantly; callers may override borderRadius (float/popup). const container = document.createElement("div"); container.className = "perspective-loading"; - container.style.cssText = ` - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - flex-direction: column; - background: ${colors.bg}; - transition: opacity 0.15s ease; - z-index: 1; - overflow: hidden; - padding: 1.5rem; - box-sizing: border-box; - `; - - const shimmerBg = `linear-gradient(90deg, ${colors.shimmer} 25%, ${colors.shimmerHighlight} 50%, ${colors.shimmer} 75%)`; - const shimmer = (width: string, height: string): HTMLElement => { - const el = document.createElement("div"); - el.style.cssText = `background:${shimmerBg};background-size:800px 100%;animation:perspective-shimmer 1.5s infinite linear;border-radius:8px;height:${height};width:${width};margin-bottom:0.5rem;`; - return el; - }; - - // -- Header: logo placeholder + progress bar -- - if (!appearance?.hideBranding || !appearance?.hideProgress) { - const header = document.createElement("div"); - header.style.cssText = `display:flex;flex-direction:column;align-items:center;gap:0.5rem;margin-bottom:1rem;`; - if (!appearance?.hideBranding) { - const logo = shimmer("7rem", "1.25rem"); - logo.style.margin = "0 auto"; - header.appendChild(logo); - } - if (!appearance?.hideProgress) { - const progressBar = shimmer("100%", "0.25rem"); - progressBar.style.borderRadius = "9999px"; - header.appendChild(progressBar); - } - container.appendChild(header); - } + container.style.background = colors.bg; + container.style.setProperty("--pl-panel", colors.panel); + container.style.setProperty("--pl-panel-border", colors.panelBorder); + container.style.setProperty("--pl-primary", colors.primary); - // -- Welcome card: avatar + name, title, body lines -- - if (!appearance?.hideGreeting) { - const card = document.createElement("div"); - card.style.cssText = `border:1px solid ${colors.border};border-radius:16px;padding:1.25rem;margin-bottom:1.25rem;`; - - // Avatar row: circle + name - const avatarRow = document.createElement("div"); - avatarRow.style.cssText = `display:flex;align-items:center;gap:0.625rem;margin-bottom:0.875rem;`; - const avatar = shimmer("2.25rem", "2.25rem"); - avatar.style.borderRadius = "50%"; - avatar.style.flexShrink = "0"; - avatar.style.marginBottom = "0"; - const name = shimmer("6rem", "0.875rem"); - name.style.marginBottom = "0"; - avatarRow.appendChild(avatar); - avatarRow.appendChild(name); - card.appendChild(avatarRow); - - // Title line - const title = shimmer("60%", "1.125rem"); - title.style.marginBottom = "0.875rem"; - card.appendChild(title); - - // Body lines (3 lines — welcome message paragraph) - card.appendChild(shimmer("95%", "0.75rem")); - card.appendChild(shimmer("90%", "0.75rem")); - const lastBodyLine = shimmer("75%", "0.75rem"); - lastBodyLine.style.marginBottom = "0"; - card.appendChild(lastBodyLine); - - container.appendChild(card); + // Scene applies only after load — a missing scene (404), slow network, or + // blocked request leaves the solid bg, never a broken paint. + const scene = document.createElement("div"); + scene.className = "perspective-loading__scene"; + if (options?.researchId && options?.host) { + const sceneUrl = buildSceneUrl(options.researchId, options.host); + requestedSceneUrls.add(sceneUrl); + scene.dataset.sceneUrl = sceneUrl; + const img = new Image(); + img.onload = () => { + scene.style.backgroundImage = `url("${sceneUrl}")`; + scene.style.opacity = "1"; + }; + img.src = sceneUrl; } + container.appendChild(scene); - // -- Chat message: greeting text + small icon -- - const message = document.createElement("div"); - message.style.cssText = `padding:0 0.25rem;margin-bottom:auto;`; - message.appendChild(shimmer("92%", "0.75rem")); - const msgLine2 = shimmer("50%", "0.75rem"); - msgLine2.style.marginBottom = "0.75rem"; - message.appendChild(msgLine2); - const icon = shimmer("1.5rem", "1.5rem"); - icon.style.marginBottom = "0"; - message.appendChild(icon); - container.appendChild(message); - - // -- Input area: reply field + talk button pill -- - const inputArea = document.createElement("div"); - inputArea.style.cssText = ` - margin-top: 1rem; - border: 1px solid ${colors.border}; - border-radius: 28px; - height: 3rem; - padding: 0.375rem 0.375rem 0.375rem 1rem; - display: flex; - align-items: center; - justify-content: flex-end; - box-sizing: border-box; - `; - const buttonPill = shimmer("4.5rem", "2.25rem"); - buttonPill.style.borderRadius = "9999px"; - buttonPill.style.marginBottom = "0"; - inputArea.appendChild(buttonPill); - container.appendChild(inputArea); - - // -- Footer: centered hint text -- - // const footer = shimmer("14rem", "0.75rem"); - // footer.style.margin = "0.75rem auto 0"; - // container.appendChild(footer); + // Card: centered box on wide slots, full-bleed on compact (@container rule). + const content = document.createElement("div"); + content.className = "perspective-loading__content"; + const card = document.createElement("div"); + card.className = "perspective-loading__card"; + const ring = document.createElement("div"); + ring.className = "perspective-loading__ring"; + card.appendChild(ring); + content.appendChild(card); + container.appendChild(content); return container; } diff --git a/packages/sdk/src/popup.ts b/packages/sdk/src/popup.ts index 58358e5..2920026 100644 --- a/packages/sdk/src/popup.ts +++ b/packages/sdk/src/popup.ts @@ -65,10 +65,12 @@ export function openPopup(config: InternalEmbedConfig): EmbedHandle { closeBtn.style.display = "none"; } - // Create loading indicator with theme and brand colors. const loading = createLoadingIndicator({ theme: config.theme, brand: config.brand, + apiConfig: config._apiConfig, + researchId, + host, }); loading.style.borderRadius = "16px"; diff --git a/packages/sdk/src/slider.ts b/packages/sdk/src/slider.ts index 0c5356f..02edc3b 100644 --- a/packages/sdk/src/slider.ts +++ b/packages/sdk/src/slider.ts @@ -77,10 +77,12 @@ export function openSlider(config: InternalEmbedConfig): EmbedHandle { closeBtn.style.display = "none"; } - // Create loading indicator with theme and brand colors const loading = createLoadingIndicator({ theme: config.theme, brand: config.brand, + apiConfig: config._apiConfig, + researchId, + host, }); // Create iframe (hidden initially). Appearance overrides resolved server-side. diff --git a/packages/sdk/src/widget.ts b/packages/sdk/src/widget.ts index ea685ca..e9cf977 100644 --- a/packages/sdk/src/widget.ts +++ b/packages/sdk/src/widget.ts @@ -192,10 +192,12 @@ export function createWidget( setVar("--perspective-widget-bg", frame.background); } - // Create loading indicator with theme and brand colors const loading = createLoadingIndicator({ theme: config.theme, brand: config.brand, + apiConfig: config._apiConfig, + researchId, + host, }); wrapper.appendChild(loading);