From 987318ecca77a1cc86dbe78c0f817a105d918caa Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 15 Jul 2026 14:28:10 +0200 Subject: [PATCH 1/2] feat(embed): bootable embedded mode for the webapp inside PostHog Code EXPERIMENT: adds an `embed` vite entry that boots the full app inside an iframe served by PostHog Code's local auth-injecting proxy. Tracking is disabled (no JS_POSTHOG_API_KEY -> posthog-js opts out, exporter pattern), and a postMessage bridge syncs navigation (kea-router push / locationChanged) and theme both ways. themeLogic gains an embedForcedTheme override so the host window's light/dark choice wins over user/system preference. Co-Authored-By: Claude Fable 5 --- frontend/src/embed/bridge.ts | 87 ++++++++++++++++++++++++++++ frontend/src/embed/index.tsx | 33 +++++++++++ frontend/src/lib/logic/themeLogic.ts | 22 ++++++- frontend/vite.config.mts | 1 + 4 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 frontend/src/embed/bridge.ts create mode 100644 frontend/src/embed/index.tsx diff --git a/frontend/src/embed/bridge.ts b/frontend/src/embed/bridge.ts new file mode 100644 index 000000000000..6ba5934fd4ec --- /dev/null +++ b/frontend/src/embed/bridge.ts @@ -0,0 +1,87 @@ +/** + * postMessage bridge between the embedded PostHog app and its host window. + * + * Protocol (all messages are plain objects with a `source` discriminator): + * host -> iframe { source: 'posthog-embed-host', type: 'navigate', url } + * host -> iframe { source: 'posthog-embed-host', type: 'setTheme', theme: 'light' | 'dark' | null } + * iframe -> host { source: 'posthog-embed', type: 'ready', url } + * iframe -> host { source: 'posthog-embed', type: 'routeChanged', url } + * + * Navigation goes through kea-router, which auto-prefixes `/project/` — + * the host should send bare paths like `/notebooks`. + */ +import { getContext } from 'kea' +import { router } from 'kea-router' + +import { themeLogic } from 'lib/logic/themeLogic' + +const FROM_EMBED = 'posthog-embed' +const FROM_HOST = 'posthog-embed-host' + +function postToHost(message: Record): void { + window.parent.postMessage({ source: FROM_EMBED, ...message }, '*') +} + +function currentUrl(): string { + const { pathname, search, hash } = window.location + return `${pathname}${search}${hash}` +} + +function keaIsReady(): boolean { + try { + return !!getContext().store + } catch { + return false + } +} + +/** Called by the embed entry once the main app bundle has been imported. */ +export function initEmbedBridge(): void { + if (window.parent === window) { + return // not framed: nothing to bridge + } + // The main entry lazy-loads the App chunk and only then runs initKea, so + // poll until the kea context exists before touching logics or the router. + const timer = window.setInterval(() => { + if (keaIsReady()) { + window.clearInterval(timer) + start() + } + }, 50) +} + +function start(): void { + // Keep themeLogic mounted for the app's lifetime so the forced theme + // doesn't unmount away with whatever scene mounted it first. + themeLogic.mount() + + if (window.__POSTHOG_EMBED_THEME__) { + themeLogic.actions.setEmbedForcedTheme(window.__POSTHOG_EMBED_THEME__) + } + + window.addEventListener('message', (event: MessageEvent) => { + const data: unknown = event.data + if (!data || typeof data !== 'object' || (data as Record).source !== FROM_HOST) { + return + } + const message = data as { type?: string; url?: unknown; theme?: unknown } + if (message.type === 'navigate' && typeof message.url === 'string') { + router.actions.push(message.url) + } else if (message.type === 'setTheme') { + themeLogic.actions.setEmbedForcedTheme( + message.theme === 'dark' ? 'dark' : message.theme === 'light' ? 'light' : null + ) + } + }) + + let lastUrl = currentUrl() + getContext().store.subscribe(() => { + const url = currentUrl() + if (url !== lastUrl) { + lastUrl = url + postToHost({ type: 'routeChanged', url }) + } + }) + + postToHost({ type: 'ready', url: lastUrl }) +} diff --git a/frontend/src/embed/index.tsx b/frontend/src/embed/index.tsx new file mode 100644 index 000000000000..56dca0f5e7ed --- /dev/null +++ b/frontend/src/embed/index.tsx @@ -0,0 +1,33 @@ +/** + * PostHog Code embedded-app entry. + * + * Boots the full PostHog app inside an iframe controlled by a desktop host + * (PostHog Code). The host serves this entry through a local auth-injecting + * proxy, so the app sees a same-origin `/api` and never handles credentials. + * + * Tracking stays off: we clear JS_POSTHOG_API_KEY before loadPostHogJS runs, + * which makes posthog-js init with a fake token and opt out (same pattern as + * the exporter and render-query entries). + */ + +declare global { + interface Window { + __POSTHOG_EMBED__?: boolean + __POSTHOG_EMBED_THEME__?: 'light' | 'dark' + } +} + +window.JS_POSTHOG_API_KEY = undefined +window.__POSTHOG_EMBED__ = true + +// The host can seed the initial theme via query param (survives until the +// bridge is up and can receive live setTheme messages) or a pre-set global. +const themeParam = new URLSearchParams(window.location.search).get('__posthog_embed_theme') +if (themeParam === 'light' || themeParam === 'dark') { + window.__POSTHOG_EMBED_THEME__ = themeParam +} + +// Dynamic imports only: static imports would hoist above the global setup. +void import('../index').then(() => import('./bridge')).then(({ initEmbedBridge }) => initEmbedBridge()) + +export {} diff --git a/frontend/src/lib/logic/themeLogic.ts b/frontend/src/lib/logic/themeLogic.ts index 52d697061c08..f7815f480121 100644 --- a/frontend/src/lib/logic/themeLogic.ts +++ b/frontend/src/lib/logic/themeLogic.ts @@ -19,6 +19,8 @@ export const themeLogic = kea([ actions({ syncDarkModePreference: (darkModePreference: boolean) => ({ darkModePreference }), setTheme: (theme: string | null) => ({ theme }), + // Embedded mode (PostHog Code iframe): the host window's theme wins + setEmbedForcedTheme: (theme: 'light' | 'dark' | null) => ({ theme }), saveCustomCss: true, setPersistedCustomCss: (css: string | null) => ({ css }), setPreviewingCustomCss: (css: string | null) => ({ css }), @@ -38,6 +40,12 @@ export const themeLogic = kea([ setTheme: (_, { theme }) => theme, }, ], + embedForcedTheme: [ + (typeof window !== 'undefined' && window.__POSTHOG_EMBED_THEME__) || (null as 'light' | 'dark' | null), + { + setEmbedForcedTheme: (_, { theme }) => theme, + }, + ], persistedCustomCss: [ null as string | null, { persist: true }, @@ -74,8 +82,18 @@ export const themeLogic = kea([ (persistedCustomCss, previewingCustomCss): string | null => previewingCustomCss || persistedCustomCss, ], isDarkModeOn: [ - (s) => [s.themeMode, s.darkModeSystemPreference, sceneLogic.selectors.sceneConfig, s.theme], - (themeMode, darkModeSystemPreference, sceneConfig, theme) => { + (s) => [ + s.themeMode, + s.darkModeSystemPreference, + sceneLogic.selectors.sceneConfig, + s.theme, + s.embedForcedTheme, + ], + (themeMode, darkModeSystemPreference, sceneConfig, theme, embedForcedTheme) => { + // Embedded in PostHog Code: the host explicitly controls light/dark + if (embedForcedTheme) { + return embedForcedTheme === 'dark' + } if ( typeof window !== 'undefined' && window.document && diff --git a/frontend/vite.config.mts b/frontend/vite.config.mts index 6f46c4bb9d8f..4a3cf23359cd 100644 --- a/frontend/vite.config.mts +++ b/frontend/vite.config.mts @@ -83,6 +83,7 @@ export default defineConfig(({ mode }) => { rollupOptions: { input: { index: resolve(__dirname, 'src/index.tsx'), + embed: resolve(__dirname, 'src/embed/index.tsx'), exporter: resolve(__dirname, 'src/exporter/index.tsx'), render_query: resolve(__dirname, 'src/render-query/index.tsx'), toolbar: resolve(__dirname, 'src/toolbar/index.tsx'), From 46ef23639d036dc40999f132147561ac02ce8135 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 15 Jul 2026 16:55:50 +0200 Subject: [PATCH 2/2] feat(embed): hide nav chrome in embed mode, bridge new-tab intents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - navigationLogic gains an embedMode reducer (seeded from window.__POSTHOG_EMBED__, like themeLogic's embedForcedTheme) that forces navigation mode 'none': no PanelLayout sidebar, no top bar, no side panel — scene content takes the full iframe. mode='none' is the existing tested path used by onboarding's hidden-navbar variants. - embed bridge intercepts window.open and clicks on same-origin URLs and forwards them to the host as `openTab` messages, so "open in new tab" affordances become host browser tabs instead of being dropped. External URLs keep native behavior; in-app kea-router pushes still navigate in place. Co-Authored-By: Claude Fable 5 --- frontend/src/embed/bridge.ts | 55 +++++++++++++++++++ .../navigation-3000/navigationLogic.tsx | 16 +++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/frontend/src/embed/bridge.ts b/frontend/src/embed/bridge.ts index 6ba5934fd4ec..2ba9f209bf8a 100644 --- a/frontend/src/embed/bridge.ts +++ b/frontend/src/embed/bridge.ts @@ -6,9 +6,16 @@ * host -> iframe { source: 'posthog-embed-host', type: 'setTheme', theme: 'light' | 'dark' | null } * iframe -> host { source: 'posthog-embed', type: 'ready', url } * iframe -> host { source: 'posthog-embed', type: 'routeChanged', url } + * iframe -> host { source: 'posthog-embed', type: 'openTab', url } * * Navigation goes through kea-router, which auto-prefixes `/project/` — * the host should send bare paths like `/notebooks`. + * + * New-tab intents (window.open / target="_blank" on same-origin URLs) are + * forwarded to the host as `openTab` so it can open another embedded tab + * instead of the browser context dropping them. External URLs keep the + * native path (Electron's window-open handler opens the system browser). + * In-app kea-router pushes are untouched and navigate in place. */ import { getContext } from 'kea' import { router } from 'kea-router' @@ -50,11 +57,59 @@ export function initEmbedBridge(): void { }, 50) } +function sendOpenTab(resolved: URL): void { + postToHost({ type: 'openTab', url: `${resolved.pathname}${resolved.search}${resolved.hash}` }) +} + +function interceptNewTabs(): void { + // window.open with a same-origin URL becomes a host tab; everything else + // (external docs links, OAuth popups) keeps the native path. + const originalOpen = window.open.bind(window) + window.open = (url?: string | URL, target?: string, features?: string): Window | null => { + if (url) { + const resolved = new URL(String(url), window.location.href) + if (resolved.origin === window.location.origin) { + sendOpenTab(resolved) + return null + } + } + return originalOpen(url, target, features) + } + + // Plain anchors (the webapp's "open in new tab" + // affordances) — capture phase so we run before any in-app handlers. + document.addEventListener( + 'click', + (event) => { + if (event.defaultPrevented || event.button !== 0) { + return + } + const anchor = (event.target as HTMLElement | null)?.closest?.('a[target="_blank"]') + if (!anchor) { + return + } + const href = (anchor as HTMLAnchorElement).href + if (!href) { + return + } + const resolved = new URL(href, window.location.href) + if (resolved.origin === window.location.origin) { + event.preventDefault() + event.stopPropagation() + sendOpenTab(resolved) + } + }, + true + ) +} + function start(): void { // Keep themeLogic mounted for the app's lifetime so the forced theme // doesn't unmount away with whatever scene mounted it first. themeLogic.mount() + interceptNewTabs() + if (window.__POSTHOG_EMBED_THEME__) { themeLogic.actions.setEmbedForcedTheme(window.__POSTHOG_EMBED_THEME__) } diff --git a/frontend/src/layout/navigation-3000/navigationLogic.tsx b/frontend/src/layout/navigation-3000/navigationLogic.tsx index c068e7b30cc3..043c1fb6307d 100644 --- a/frontend/src/layout/navigation-3000/navigationLogic.tsx +++ b/frontend/src/layout/navigation-3000/navigationLogic.tsx @@ -111,6 +111,8 @@ export const navigation3000Logic = kea([ toggleListItemAccordion: (key: string) => ({ key }), setZenMode: (zenMode: boolean, trigger: ZenModeTrigger) => ({ zenMode, trigger }), toggleZenMode: (trigger: ZenModeTrigger) => ({ trigger }), + // Embedded mode (PostHog Code iframe): the host provides all chrome + setEmbedMode: (embed: boolean) => ({ embed }), }), reducers({ isSidebarShown: [ @@ -244,6 +246,12 @@ export const navigation3000Logic = kea([ setZenMode: (_, { zenMode }) => zenMode, }, ], + embedMode: [ + typeof window !== 'undefined' && !!window.__POSTHOG_EMBED__, + { + setEmbedMode: (_, { embed }) => embed, + }, + ], }), listeners(({ actions, values }) => ({ setZenMode: ({ zenMode, trigger }) => { @@ -369,14 +377,20 @@ export const navigation3000Logic = kea([ s.zenMode, s.activeSceneId, featureFlagLogic.selectors.featureFlags, + s.embedMode, ], ( sceneConfig, isCurrentOrganizationUnavailable, zenMode, activeSceneId, - featureFlags + featureFlags, + embedMode ): Navigation3000Mode => { + if (embedMode) { + // Embedded in PostHog Code: no chrome, the host wraps the scene + return 'none' + } if (zenMode) { return 'zen' }