& {
showCloseButton?: boolean;
}) {
+ const viewport = useEmbedViewport();
+ const embeddedStyle =
+ viewport != null && viewport.visibleHeight > 0
+ ? {
+ top: embeddedDialogCenterY(viewport),
+ maxHeight: Math.min(viewport.visibleHeight * 0.9, 720),
+ }
+ : undefined;
+
// Not using DialogPortal because it's breaking useRef's for some reason
return (
@@ -66,6 +112,7 @@ function DialogContent({
'bg-def-100 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg [&>*]:min-w-0',
className,
)}
+ style={{ ...embeddedStyle, ...style }}
{...props}
>
{children}
diff --git a/apps/start/src/hooks/measure-embed-content-height.test.ts b/apps/start/src/hooks/measure-embed-content-height.test.ts
new file mode 100644
index 000000000..80a924c67
--- /dev/null
+++ b/apps/start/src/hooks/measure-embed-content-height.test.ts
@@ -0,0 +1,15 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { measureEmbedContentHeight } from './use-embed-viewport';
+
+describe('measureEmbedContentHeight', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('returns 0 without reading HTMLElement when document is undefined', () => {
+ vi.stubGlobal('document', undefined);
+ vi.stubGlobal('HTMLElement', undefined);
+
+ expect(measureEmbedContentHeight()).toBe(0);
+ });
+});
diff --git a/apps/start/src/hooks/use-embed-viewport.ts b/apps/start/src/hooks/use-embed-viewport.ts
new file mode 100644
index 000000000..892c75a2e
--- /dev/null
+++ b/apps/start/src/hooks/use-embed-viewport.ts
@@ -0,0 +1,209 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import {
+ type EmbedViewport,
+ isInIframe,
+ viewportFromIframeRect,
+} from '@/utils/embed-viewport';
+
+const SOURCE = 'openpanel-embed';
+
+type ViewportMessage = {
+ source: typeof SOURCE;
+ type: 'viewport';
+ visibleTop: number;
+ visibleHeight: number;
+};
+
+type ResizeAckMessage = {
+ source: typeof SOURCE;
+ type: 'resize-ack';
+ height: number;
+};
+
+function isViewportMessage(data: unknown): data is ViewportMessage {
+ if (!data || typeof data !== 'object') {
+ return false;
+ }
+ const msg = data as Record;
+ return (
+ msg.source === SOURCE &&
+ msg.type === 'viewport' &&
+ typeof msg.visibleTop === 'number' &&
+ typeof msg.visibleHeight === 'number'
+ );
+}
+
+function isResizeAckMessage(data: unknown): data is ResizeAckMessage {
+ if (!data || typeof data !== 'object') {
+ return false;
+ }
+ const msg = data as Record;
+ return (
+ msg.source === SOURCE &&
+ msg.type === 'resize-ack' &&
+ typeof msg.height === 'number'
+ );
+}
+
+/**
+ * Height of the share content itself — not documentElement.scrollHeight, which
+ * floors at the iframe viewport and can never shrink after the host expands.
+ */
+export function measureEmbedContentHeight(
+ root: Element | null | undefined = typeof document === 'undefined'
+ ? null
+ : document.querySelector('[data-openpanel-embed-root]'),
+): number {
+ // Guard before `instanceof HTMLElement` — that global is missing without a DOM.
+ if (typeof document === 'undefined') {
+ return 0;
+ }
+ if (root instanceof HTMLElement) {
+ return Math.ceil(root.getBoundingClientRect().height);
+ }
+ const body = document.body;
+ if (!body) {
+ return 0;
+ }
+ let maxBottom = 0;
+ for (const child of Array.from(body.children)) {
+ if (!(child instanceof HTMLElement)) {
+ continue;
+ }
+ const rect = child.getBoundingClientRect();
+ maxBottom = Math.max(maxBottom, rect.bottom);
+ }
+ return Math.ceil(Math.max(0, maxBottom - body.getBoundingClientRect().top));
+}
+
+/**
+ * While framed by openpanel-embed.js, keep the visible slice in sync so
+ * overlays can pin to what the user actually sees (not mid-document).
+ */
+export function useEmbedViewport(): EmbedViewport | null {
+ const [viewport, setViewport] = useState(null);
+
+ useEffect(() => {
+ if (!isInIframe()) {
+ return;
+ }
+
+ const onMessage = (event: MessageEvent) => {
+ if (!isViewportMessage(event.data)) {
+ return;
+ }
+ setViewport({
+ visibleTop: event.data.visibleTop,
+ visibleHeight: event.data.visibleHeight,
+ });
+ };
+
+ window.addEventListener('message', onMessage);
+ window.parent.postMessage({ source: SOURCE, type: 'request-viewport' }, '*');
+
+ const interval = window.setInterval(() => {
+ window.parent.postMessage(
+ { source: SOURCE, type: 'request-viewport' },
+ '*',
+ );
+ }, 250);
+
+ return () => {
+ window.removeEventListener('message', onMessage);
+ window.clearInterval(interval);
+ };
+ }, []);
+
+ return viewport;
+}
+
+/** Report content height to the parent embed host until acknowledged. */
+export function useReportEmbedHeight(enabled = true) {
+ useEffect(() => {
+ if (!enabled || !isInIframe()) {
+ return;
+ }
+
+ let acked = false;
+ let lastHeight = -1;
+ let retries = 0;
+ let retryTimer: number | undefined;
+
+ const publish = () => {
+ const height = measureEmbedContentHeight();
+ lastHeight = height;
+ window.parent.postMessage(
+ { source: SOURCE, type: 'resize', height },
+ '*',
+ );
+ };
+
+ const scheduleRetry = () => {
+ if (acked || retries >= 20) {
+ return;
+ }
+ retries += 1;
+ retryTimer = window.setTimeout(() => {
+ publish();
+ scheduleRetry();
+ }, 200);
+ };
+
+ const onMessage = (event: MessageEvent) => {
+ if (!isResizeAckMessage(event.data)) {
+ return;
+ }
+ if (event.data.height === lastHeight) {
+ acked = true;
+ if (retryTimer !== undefined) {
+ window.clearTimeout(retryTimer);
+ }
+ }
+ };
+
+ const onHostPing = (event: MessageEvent) => {
+ const data = event.data;
+ if (
+ data &&
+ typeof data === 'object' &&
+ (data as { source?: string; type?: string }).source === SOURCE &&
+ (data as { type?: string }).type === 'request-resize'
+ ) {
+ publish();
+ }
+ };
+
+ window.addEventListener('message', onMessage);
+ window.addEventListener('message', onHostPing);
+ publish();
+ scheduleRetry();
+
+ const root = document.querySelector('[data-openpanel-embed-root]');
+ const ro = new ResizeObserver(() => {
+ acked = false;
+ retries = 0;
+ publish();
+ scheduleRetry();
+ });
+ if (root) {
+ ro.observe(root);
+ } else if (document.body) {
+ ro.observe(document.body);
+ }
+ window.addEventListener('load', publish);
+
+ return () => {
+ window.removeEventListener('message', onMessage);
+ window.removeEventListener('message', onHostPing);
+ window.removeEventListener('load', publish);
+ if (retryTimer !== undefined) {
+ window.clearTimeout(retryTimer);
+ }
+ ro.disconnect();
+ };
+ }, [enabled]);
+}
+
+export { isInIframe, viewportFromIframeRect };
diff --git a/apps/start/src/modals/share-overview-modal.tsx b/apps/start/src/modals/share-overview-modal.tsx
index ced7a9660..24c81995d 100644
--- a/apps/start/src/modals/share-overview-modal.tsx
+++ b/apps/start/src/modals/share-overview-modal.tsx
@@ -27,6 +27,7 @@ export default function ShareOverviewModal() {
const { projectId, organizationId } = useAppParams();
const navigate = useNavigate();
const [copied, setCopied] = useState(false);
+ const [copiedEmbed, setCopiedEmbed] = useState(false);
const trpc = useTRPC();
const queryClient = useQueryClient();
@@ -43,7 +44,10 @@ export default function ShareOverviewModal() {
const shareUrl = existingShare?.id
? `${window.location.origin}/share/overview/${existingShare.id}`
: '';
-
+ const embedScriptUrl = `${window.location.origin}/openpanel-embed.js`;
+ const embedCode = shareUrl
+ ? `\n`
+ : '';
const { register, handleSubmit, watch } = useForm({
resolver: zodResolver(validator),
defaultValues: {
@@ -90,6 +94,20 @@ export default function ShareOverviewModal() {
toast('Link copied to clipboard');
};
+ const handleCopyEmbed = async () => {
+ try {
+ if (!navigator.clipboard) {
+ throw new Error('Clipboard API unavailable');
+ }
+ await navigator.clipboard.writeText(embedCode);
+ setCopiedEmbed(true);
+ setTimeout(() => setCopiedEmbed(false), 2000);
+ toast('Embed code copied to clipboard');
+ } catch {
+ toast('Could not copy embed code');
+ }
+ };
+
const handleMakePrivate = () => {
mutation.mutate({
public: false,
@@ -152,6 +170,32 @@ export default function ShareOverviewModal() {
+
+
+ Embed (auto-resizes to content height):
+
+
+
+
+
+
+
+
)}
diff --git a/apps/start/src/routeTree.gen.ts b/apps/start/src/routeTree.gen.ts
index 81b0a9d63..d4d8c4db4 100644
--- a/apps/start/src/routeTree.gen.ts
+++ b/apps/start/src/routeTree.gen.ts
@@ -21,6 +21,7 @@ import { Route as WidgetTestRouteImport } from './routes/widget/test'
import { Route as WidgetRealtimeRouteImport } from './routes/widget/realtime'
import { Route as WidgetCounterRouteImport } from './routes/widget/counter'
import { Route as WidgetBadgeRouteImport } from './routes/widget/badge'
+import { Route as IframeTestRouteImport } from './routes/iframe-test'
import { Route as ApiHealthcheckRouteImport } from './routes/api/healthcheck'
import { Route as ApiConfigRouteImport } from './routes/api/config'
import { Route as PublicOnboardingRouteImport } from './routes/_public.onboarding'
@@ -184,6 +185,11 @@ const ApiHealthcheckRoute = ApiHealthcheckRouteImport.update({
path: '/api/healthcheck',
getParentRoute: () => rootRouteImport,
} as any)
+const IframeTestRoute = IframeTestRouteImport.update({
+ id: '/iframe-test',
+ path: '/iframe-test',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ApiConfigRoute = ApiConfigRouteImport.update({
id: '/api/config',
path: '/api/config',
@@ -711,6 +717,7 @@ export interface FileRoutesByFullPath {
'/widget/badge': typeof WidgetBadgeRoute
'/widget/counter': typeof WidgetCounterRoute
'/widget/realtime': typeof WidgetRealtimeRoute
+ '/iframe-test': typeof IframeTestRoute
'/widget/test': typeof WidgetTestRoute
'/$organizationId/$projectId': typeof AppOrganizationIdProjectIdRouteWithChildren
'/$organizationId/billing': typeof AppOrganizationIdBillingRoute
@@ -798,6 +805,7 @@ export interface FileRoutesByTo {
'/widget/badge': typeof WidgetBadgeRoute
'/widget/counter': typeof WidgetCounterRoute
'/widget/realtime': typeof WidgetRealtimeRoute
+ '/iframe-test': typeof IframeTestRoute
'/widget/test': typeof WidgetTestRoute
'/$organizationId/billing': typeof AppOrganizationIdBillingRoute
'/$organizationId/settings': typeof AppOrganizationIdSettingsRoute
@@ -880,6 +888,7 @@ export interface FileRoutesById {
'/widget/badge': typeof WidgetBadgeRoute
'/widget/counter': typeof WidgetCounterRoute
'/widget/realtime': typeof WidgetRealtimeRoute
+ '/iframe-test': typeof IframeTestRoute
'/widget/test': typeof WidgetTestRoute
'/_app/$organizationId/$projectId': typeof AppOrganizationIdProjectIdRouteWithChildren
'/_app/$organizationId/billing': typeof AppOrganizationIdBillingRoute
@@ -980,6 +989,7 @@ export interface FileRouteTypes {
| '/widget/badge'
| '/widget/counter'
| '/widget/realtime'
+ | '/iframe-test'
| '/widget/test'
| '/$organizationId/$projectId'
| '/$organizationId/billing'
@@ -1067,6 +1077,7 @@ export interface FileRouteTypes {
| '/widget/badge'
| '/widget/counter'
| '/widget/realtime'
+ | '/iframe-test'
| '/widget/test'
| '/$organizationId/billing'
| '/$organizationId/settings'
@@ -1148,6 +1159,7 @@ export interface FileRouteTypes {
| '/widget/badge'
| '/widget/counter'
| '/widget/realtime'
+ | '/iframe-test'
| '/widget/test'
| '/_app/$organizationId/$projectId'
| '/_app/$organizationId/billing'
@@ -1245,6 +1257,7 @@ export interface RootRouteChildren {
WidgetBadgeRoute: typeof WidgetBadgeRoute
WidgetCounterRoute: typeof WidgetCounterRoute
WidgetRealtimeRoute: typeof WidgetRealtimeRoute
+ IframeTestRoute: typeof IframeTestRoute
WidgetTestRoute: typeof WidgetTestRoute
ShareDashboardShareIdRoute: typeof ShareDashboardShareIdRoute
ShareOverviewShareIdRoute: typeof ShareOverviewShareIdRoute
@@ -1295,6 +1308,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/iframe-test': {
+ id: '/iframe-test'
+ path: '/iframe-test'
+ fullPath: '/iframe-test'
+ preLoaderRoute: typeof IframeTestRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/widget/test': {
id: '/widget/test'
path: '/widget/test'
@@ -2499,6 +2519,7 @@ const rootRouteChildren: RootRouteChildren = {
WidgetBadgeRoute: WidgetBadgeRoute,
WidgetCounterRoute: WidgetCounterRoute,
WidgetRealtimeRoute: WidgetRealtimeRoute,
+ IframeTestRoute: IframeTestRoute,
WidgetTestRoute: WidgetTestRoute,
ShareDashboardShareIdRoute: ShareDashboardShareIdRoute,
ShareOverviewShareIdRoute: ShareOverviewShareIdRoute,
diff --git a/apps/start/src/routes/iframe-test.tsx b/apps/start/src/routes/iframe-test.tsx
new file mode 100644
index 000000000..c31a71324
--- /dev/null
+++ b/apps/start/src/routes/iframe-test.tsx
@@ -0,0 +1,35 @@
+import { createFileRoute } from '@tanstack/react-router';
+import { useEffect } from 'react';
+
+export const Route = createFileRoute('/iframe-test')({
+ component: IframeTestLayout,
+});
+
+function IframeTestLayout() {
+ useEffect(() => {
+ const script = document.createElement('script');
+ script.src = '/openpanel-embed.js';
+ script.async = true;
+ document.body.appendChild(script);
+ return () => {
+ script.remove();
+ };
+ }, []);
+
+ return (
+
+
+
+ Local harness for share embeds. Point the iframe at a public share URL
+ and confirm the frame grows with content and modals stay in view.
+
+
+
+
+ );
+}
diff --git a/apps/start/src/routes/share.dashboard.$shareId.tsx b/apps/start/src/routes/share.dashboard.$shareId.tsx
index 139932fbd..717eb4c26 100644
--- a/apps/start/src/routes/share.dashboard.$shareId.tsx
+++ b/apps/start/src/routes/share.dashboard.$shareId.tsx
@@ -1,3 +1,4 @@
+import { useReportEmbedHeight } from '@/hooks/use-embed-viewport';
import { ShareEnterPassword } from '@/components/auth/share-enter-password';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state';
@@ -82,6 +83,7 @@ function RouteComponent() {
const { header } = useSearch({ from: '/share/dashboard/$shareId' });
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
+ useReportEmbedHeight();
const shareQuery = useSuspenseQuery(
trpc.share.dashboard.queryOptions({
@@ -116,7 +118,7 @@ function RouteComponent() {
const layouts = useReportLayouts(reports);
return (
-
+
{isHeaderVisible && (
diff --git a/apps/start/src/routes/share.overview.$shareId.tsx b/apps/start/src/routes/share.overview.$shareId.tsx
index 0d8fccf18..706d4119a 100644
--- a/apps/start/src/routes/share.overview.$shareId.tsx
+++ b/apps/start/src/routes/share.overview.$shareId.tsx
@@ -1,3 +1,4 @@
+import { useReportEmbedHeight } from '@/hooks/use-embed-viewport';
import { ShareEnterPassword } from '@/components/auth/share-enter-password';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state';
@@ -75,6 +76,7 @@ function RouteComponent() {
shareId,
}),
);
+ useReportEmbedHeight();
if (shareQuery.isLoading) {
return
Loading...
;
@@ -98,7 +100,7 @@ function RouteComponent() {
header !== '0' && header !== 0 && header !== 'false' && header !== false;
return (
-
+
{isHeaderVisible && (
diff --git a/apps/start/src/utils/embed-viewport.test.ts b/apps/start/src/utils/embed-viewport.test.ts
new file mode 100644
index 000000000..7ced91173
--- /dev/null
+++ b/apps/start/src/utils/embed-viewport.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest';
+import {
+ embeddedDialogCenterY,
+ viewportFromIframeRect,
+} from './embed-viewport';
+
+describe('viewportFromIframeRect', () => {
+ it('when the iframe top is above the parent viewport, visibleTop tracks scroll into the iframe', () => {
+ // Parent scrolled so iframe top is 400px above the viewport; parent is 800 tall.
+ expect(
+ viewportFromIframeRect({ top: -400, bottom: 1600, height: 2000 }, 800),
+ ).toEqual({ visibleTop: 400, visibleHeight: 800 });
+ });
+
+ it('when the iframe sits fully in view, visibleTop is 0', () => {
+ expect(
+ viewportFromIframeRect({ top: 100, bottom: 900, height: 800 }, 1000),
+ ).toEqual({ visibleTop: 0, visibleHeight: 800 });
+ });
+
+ it('returns zero height when the iframe is fully above the parent viewport', () => {
+ expect(
+ viewportFromIframeRect({ top: -2000, bottom: -200, height: 1800 }, 800),
+ ).toEqual({ visibleTop: 1800, visibleHeight: 0 });
+ });
+
+ it('returns zero height when the iframe is fully below the parent viewport', () => {
+ expect(
+ viewportFromIframeRect({ top: 1200, bottom: 2200, height: 1000 }, 800),
+ ).toEqual({ visibleTop: 0, visibleHeight: 0 });
+ });
+});
+
+describe('embeddedDialogCenterY', () => {
+ it('centers the dialog in the visible slice', () => {
+ expect(
+ embeddedDialogCenterY({ visibleTop: 400, visibleHeight: 800 }),
+ ).toBe(800);
+ });
+});
diff --git a/apps/start/src/utils/embed-viewport.ts b/apps/start/src/utils/embed-viewport.ts
new file mode 100644
index 000000000..1ad4cfe5f
--- /dev/null
+++ b/apps/start/src/utils/embed-viewport.ts
@@ -0,0 +1,45 @@
+/**
+ * Pure helpers for OpenPanel share embeds (no DOM).
+ * Parent page scrolls a tall iframe; dialogs must center in the *visible* slice.
+ */
+
+export type EmbedViewport = {
+ /** Distance from the top of the iframe document to the top of the visible slice */
+ visibleTop: number;
+ /** Height of the visible slice inside the iframe (px) */
+ visibleHeight: number;
+};
+
+export function isInIframe(): boolean {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+ try {
+ return window.self !== window.top;
+ } catch {
+ // Cross-origin parent access can throw; being framed still means embedded.
+ return true;
+ }
+}
+
+/** Center Y (iframe document coords) for a dialog in the visible slice. */
+export function embeddedDialogCenterY(viewport: EmbedViewport): number {
+ return viewport.visibleTop + viewport.visibleHeight / 2;
+}
+
+/**
+ * Map the iframe element's getBoundingClientRect() + parent viewport
+ * into the visible slice inside the iframe document.
+ */
+export function viewportFromIframeRect(
+ rect: { top: number; bottom: number; height: number },
+ parentInnerHeight: number,
+): EmbedViewport {
+ const visibleTop = Math.min(rect.height, Math.max(0, -rect.top));
+ const visibleBottom = Math.min(rect.height, parentInnerHeight - rect.top);
+ const visibleHeight = Math.max(0, visibleBottom - visibleTop);
+ return {
+ visibleTop,
+ visibleHeight,
+ };
+}