Player MVP Implementation Guide
A step-by-step guide for building the pathmx player bridge -- a postMessage-based communication layer between the player (parent window) and iframe content pages.
This guide covers Phase 0 (shadcn/ui setup) and Phase 1 (MVP bridge). Future phases are outlined briefly at the end.
Architecture
The player renders an iframe containing static pathmx HTML pages. A small bridge script injected into every page establishes a two-way postMessage channel. The player sends commands (scroll to block); the bridge executes them against the DOM and reports events back (ready, block list).
┌─────────────────────────────────┐
│ Player (parent window) │
│ ┌───────────────────────────┐ │
│ │ PathMXPlayer (React) │ │
│ │ ├── ContentBridge SDK │ │
│ │ └── PlayerControls │ │
│ └───────────────────────────┘ │
│ │ postMessage │
│ ┌────────▼──────────────────┐ │
│ │ iframe │ │
│ │ ┌─────────────────────┐ │ │
│ │ │ pathmx-bridge.js │ │ │
│ │ │ (injected at build) │ │ │
│ │ └─────────────────────┘ │ │
│ │ ┌─────────────────────┐ │ │
│ │ │ Static HTML content │ │ │
│ │ │ <section> blocks │ │ │
│ │ └─────────────────────┘ │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘
The bridge script is injected via HTML post-processing in RuntimePlugin.onBuildSource(), not in the layout template. This means it works with any layout, including custom per-source overrides.
Phase 0: shadcn/ui Setup
Set up shadcn/ui with Base UI in packages/player so we have a component library ready for the player UI.
Most dependencies are already installed (cva, clsx, tailwind-merge, lucide-react, tw-animate-css). This phase fills in the missing config.
Step 0.1 — Add path aliases to tsconfig.json
Edit packages/player/tsconfig.json. Add baseUrl and paths inside compilerOptions:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
// ... rest of existing options
}
}
This lets shadcn component imports like @/components/ui/button resolve to src/components/ui/button.
Step 0.2 — Create the cn helper
Create packages/player/src/lib/utils.ts:
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Step 0.3 — Add shadcn CSS import and theme variables
Edit packages/player/src/player/player.css. Add the shadcn Tailwind import and full CSS variable theme. Keep the existing @plugin and player-specific rules.
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:where(.dark, .dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
#pathmx-player,
#pathmx-player-window {
pointer-events: none;
& * {
pointer-events: auto;
}
}
Step 0.4 — Create components.json
Create packages/player/components.json. Use "style": "base-nova" so the CLI pulls Base UI variants (not Radix):
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/player/player.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
Step 0.5 — Move shadcn to devDependencies
In packages/player/package.json, move "shadcn" from dependencies to devDependencies. It's a CLI tool, not a runtime dep.
Step 0.6 — Verify the setup
From packages/player/, run:
bunx shadcn@latest add button
This should create src/components/ui/button.tsx using Base UI primitives. If the file appears and imports resolve, the setup is correct.
Phase 1: MVP Bridge
Build a working postMessage bridge between the player and iframe content. Prove it works with one action: scroll to a specific block.
Step 1.1 — Define the bridge protocol types
Create packages/core/src/bridge/types.ts. Start with the minimal set needed for the MVP:
/**
* All messages between player and bridge are wrapped in this envelope.
* The `channel` field prevents collisions with other postMessage traffic.
*/
export type BridgeEnvelope = {
channel: "pathmx"
message: PlayerCommand | ContentEvent
}
// --- Player -> Content (commands) ---
export type ScrollToBlockCommand = {
type: "scrollToBlock"
blockId: string
behavior?: ScrollBehavior
}
export type PlayerCommand = ScrollToBlockCommand
// --- Content -> Player (events) ---
export type ReadyEvent = {
type: "ready"
sourceId: string
blocks: string[]
}
export type ContentEvent = ReadyEvent
Export these from packages/core/src/bridge/index.ts and re-export from the core package's public API so both the bridge script and the player can import them.
Step 1.2 — Write the bridge script
Create packages/core/src/plugins/runtime/scripts/bridge.ts. This file generates the bridge script source code that will be emitted as a build artifact. The generated script must be plain JS with zero imports (it runs standalone in the browser).
Write it as a function that returns the script as a string:
export function createBridgeScript(): string {
return `(function() { ... })();`
}
The generated script should:
- Wait for
DOMContentLoaded. - Check activation:
- If
window.parent === window, exit silently (not in an iframe). - If
?pathmx-bridge=falseis in the query string, exit silently.
- If
- Check for debug mode: if
?pathmx-debug=1is in the query string, enable console logging. Allconsole.logcalls in the bridge should be gated behind this flag so the bridge is silent by default. - Scan the DOM for all
<section data-pathmx-type="block">elements. Collect their IDs into an array. Filter out any falsy/empty IDs (.filter(Boolean)) to handle sections without anidattribute. - Read
document.body.dataset.pathmxSourceIdto get the current source ID. - Post a
readyevent towindow.parent:window.parent.postMessage( { channel: "pathmx", message: { type: "ready", sourceId, blocks }, }, "*" ) - Listen for incoming commands:
window.addEventListener("message", (event) => { if (event.data?.channel !== "pathmx") return const msg = event.data.message if (msg.type === "scrollToBlock") { const el = document.getElementById(msg.blockId) if (el) el.scrollIntoView({ behavior: msg.behavior || "smooth" }) } })
Keep it under 2KB. No framework dependencies. Do not allocate observers or listeners until after the activation check passes.
Step 1.3 — Create the HTML post-processor
Create packages/core/src/plugins/runtime/html/inject.ts.
This function takes the final HTML string (after layout resolution) and injects the bridge runtime. It works with any layout template, including custom ones.
function escapeHtmlAttr(value: string): string {
return value
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/</g, "<")
.replace(/>/g, ">")
}
type InjectOptions = {
sourceId: string
}
export function injectBridgeRuntime(
html: string,
options: InjectOptions
): string {
const { sourceId } = options
const safeSourceId = escapeHtmlAttr(sourceId)
// Inject data-pathmx-source-id onto <body>
html = html.replace(
/<body([^>]*)>/i,
`<body$1 data-pathmx-source-id="${safeSourceId}">`
)
// Inject bridge script before </body>
const bridgeTag = `<script src="/pathmx-bridge.js"></script>`
if (html.includes("</body>")) {
html = html.replace("</body>", `${bridgeTag}\n</body>`)
} else {
html += `\n${bridgeTag}`
}
return html
}
Note the escapeHtmlAttr call on sourceId. If a source ID ever contains ", <, or > characters, raw interpolation would produce broken HTML. This prevents that.
The regex for <body> handles attributes that may already exist on the tag. If </body> isn't found (malformed HTML), the script is appended at the end as a fallback.
Step 1.4 — Wire injection into RuntimePlugin
Edit packages/core/src/plugins/runtime/runtime-plugin.ts.
In onBuildSource, add the post-processing step:
import { injectBridgeRuntime } from "./html/inject"
// Change this:
const htmlEntry = await layoutSource(source, context.build)
await this.artifacts.create(context, ".html", htmlEntry)
// To this:
const htmlEntry = await layoutSource(source, context.build)
const htmlWithBridge = injectBridgeRuntime(htmlEntry, { sourceId: source.id })
await this.artifacts.create(context, ".html", htmlWithBridge)
In finalize, emit the bridge script as a root artifact:
import { createBridgeScript } from "./scripts/bridge"
// Add a new private method:
private async createBridgeScript(context: BuildContext) {
const cache = await context.build.getCache()
const bridgeScript = createBridgeScript()
await cache.addRootArtifact("pathmx-bridge.js", bridgeScript)
}
// Call it in finalize():
override async finalize(context: BuildContext) {
await this.createRuntimePackageJson(context)
await this.createRuntimeBunfig(context)
await this.createRuntimeScripts(context)
await this.createRuntimeStyles(context)
await this.createBridgeScript(context)
await context.build.emit()
}
Step 1.5 — Verify build output
Run a build and inspect the output:
- Check that
pathmx-bridge.jsexists in the build output root. - Open any built
.htmlfile. Confirm:<body>has adata-pathmx-source-idattribute with the correct source ID.<script src="/pathmx-bridge.js"></script>appears before</body>.
- Serve the build and open a page in the browser. Check the console -- the bridge should be silent (not in an iframe). No errors. Add
?pathmx-debug=1to the URL to confirm the debug logging path works.
Step 1.6 — Introduce a player config
Before building the player-side bridge, extract the hardcoded http://localhost:8000 into a config object. This will be used throughout the player.
In packages/player/src/player/pathmx-player.tsx (or a dedicated config file), define:
type PlayerConfig = {
contentBaseUrl: string
}
const defaultConfig: PlayerConfig = {
contentBaseUrl: "http://localhost:8000",
}
Pass this through to ContentView and anywhere else that builds content URLs. This avoids scattering a hardcoded URL across multiple files.
Step 1.7 — Implement the player-side ContentBridge
Create packages/player/src/bridge/content-bridge.ts.
This class wraps postMessage communication with the iframe:
import type {
BridgeEnvelope,
PlayerCommand,
ContentEvent,
ReadyEvent,
} from "@pathmx/core"
type EventCallback<T> = (event: T) => void
export class ContentBridge {
private iframe: HTMLIFrameElement
private listeners: Map<string, Set<EventCallback<any>>> = new Map()
private messageHandler: (event: MessageEvent) => void
ready = false
currentSourceId: string | null = null
blocks: string[] = []
constructor(iframe: HTMLIFrameElement) {
this.iframe = iframe
this.messageHandler = this.handleMessage.bind(this)
window.addEventListener("message", this.messageHandler)
}
private handleMessage(event: MessageEvent) {
const data = event.data as BridgeEnvelope
if (data?.channel !== "pathmx") return
if (event.source !== this.iframe.contentWindow) return
const msg = data.message as ContentEvent
// Update local state BEFORE emitting to listeners,
// so listeners see current values when their callbacks fire.
if (msg.type === "ready") {
this.ready = true
this.currentSourceId = msg.sourceId
this.blocks = msg.blocks
}
this.emit(msg.type, msg)
}
private send(command: PlayerCommand) {
const envelope: BridgeEnvelope = { channel: "pathmx", message: command }
this.iframe.contentWindow?.postMessage(envelope, "*")
}
scrollToBlock(blockId: string, behavior: ScrollBehavior = "smooth") {
this.send({ type: "scrollToBlock", blockId, behavior })
}
on<T extends ContentEvent>(
type: T["type"],
callback: EventCallback<T>
): () => void {
if (!this.listeners.has(type)) this.listeners.set(type, new Set())
const set = this.listeners.get(type)!
set.add(callback)
return () => set.delete(callback)
}
private emit(type: string, event: ContentEvent) {
this.listeners.get(type)?.forEach((cb) => cb(event))
}
reset() {
this.ready = false
this.currentSourceId = null
this.blocks = []
}
destroy() {
window.removeEventListener("message", this.messageHandler)
this.listeners.clear()
}
}
Key details:
- State (
ready,blocks,currentSourceId) is updated before emitting to listeners. This way listener callbacks see current values, not stale ones. event.sourceis checked againstthis.iframe.contentWindowto ignore messages from other iframes or windows.- A
reset()method clears state, used when the iframe reloads.
Step 1.8 — Wire the bridge into the player UI
Edit packages/player/src/player/pathmx-player.tsx.
Connect ContentView to a ContentBridge instance. Handle iframe reloads by listening to iframe.onload and resetting the bridge.
import { useRef, useState, useEffect, useCallback } from "react"
import { ContentBridge } from "../bridge/content-bridge"
export function ContentView({
url,
onBridgeReady,
}: {
url: string
onBridgeReady?: (bridge: ContentBridge) => void
}) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const bridgeRef = useRef<ContentBridge | null>(null)
useEffect(() => {
const iframe = iframeRef.current
if (!iframe) return
const bridge = new ContentBridge(iframe)
bridgeRef.current = bridge
const unsubReady = bridge.on("ready", () => {
onBridgeReady?.(bridge)
})
// Handle iframe reloads (user clicked a non-intercepted link,
// dev server hot-reload, etc.) by resetting bridge state.
// The bridge script in the new page will fire a fresh `ready` event.
const handleLoad = () => bridge.reset()
iframe.addEventListener("load", handleLoad)
return () => {
iframe.removeEventListener("load", handleLoad)
unsubReady()
bridge.destroy()
bridgeRef.current = null
}
}, [])
return (
<iframe
ref={iframeRef}
src={url}
className="absolute inset-0 w-full h-full"
/>
)
}
In PathMXPlayer, track the bridge and block index. Use the contentBaseUrl config:
export function PathMXPlayer({
manifest,
contentBaseUrl,
}: {
manifest: ManifestData
contentBaseUrl: string
}) {
const [bridge, setBridge] = useState<ContentBridge | null>(null)
const [blockIndex, setBlockIndex] = useState(0)
const route = contentBaseUrl + "/" + manifest.rootSourceId
const handleNext = useCallback(() => {
if (!bridge || bridge.blocks.length === 0) return
const nextIndex = Math.min(blockIndex + 1, bridge.blocks.length - 1)
setBlockIndex(nextIndex)
bridge.scrollToBlock(bridge.blocks[nextIndex]!)
}, [bridge, blockIndex])
const handlePrev = useCallback(() => {
if (!bridge || bridge.blocks.length === 0) return
const prevIndex = Math.max(blockIndex - 1, 0)
setBlockIndex(prevIndex)
bridge.scrollToBlock(bridge.blocks[prevIndex]!)
}, [bridge, blockIndex])
return (
<div id="pathmx-player-window" className="relative h-screen" tabIndex={0}>
<ContentView url={route} onBridgeReady={setBridge} />
<PlayerControls
onNext={handleNext}
onPrev={handlePrev}
blockIndex={blockIndex}
totalBlocks={bridge?.blocks.length ?? 0}
/>
</div>
)
}
Update PlayerControls to accept these props and render prev/next buttons. Use shadcn Button and lucide icons (ChevronLeft, ChevronRight) if Phase 0 is done.
Step 1.9 — Test the MVP
- Start the pathmx dev server:
pmx devin a test repo. - Start the player dev server:
bun run devinpackages/player. - Open the player in the browser. The iframe should load the content page.
- Open the browser console. You should see the
readyevent logged with the source ID and block list (add?pathmx-debug=1to the content URL if the bridge is silent). - Click the next/prev buttons. The content in the iframe should scroll to the corresponding block.
- Test an iframe reload (e.g. navigate manually in the iframe). The bridge should reset and re-fire
readywhen the new page loads.
If this works, the core bridge pattern is proven. Everything else builds on top of this channel.
Files Summary
New files
| File | Description |
|---|---|
packages/player/src/lib/utils.ts | cn helper for shadcn |
packages/player/components.json | shadcn CLI config (Base UI) |
packages/core/src/bridge/types.ts | Shared bridge protocol types |
packages/core/src/bridge/index.ts | Barrel export |
packages/core/src/plugins/runtime/html/inject.ts | HTML post-processor for bridge injection |
packages/core/src/plugins/runtime/scripts/bridge.ts | Bridge script generator |
packages/player/src/bridge/content-bridge.ts | Player-side bridge SDK |
Edited files
| File | Change |
|---|---|
packages/player/tsconfig.json | Add baseUrl + @/* path aliases |
packages/player/package.json | Move shadcn to devDependencies |
packages/player/src/player/player.css | Add shadcn import + theme variables |
packages/core/src/plugins/runtime/runtime-plugin.ts | Call injectBridgeRuntime in onBuildSource, emit bridge artifact in finalize |
packages/player/src/player/pathmx-player.tsx | Wire ContentBridge, add controls, use contentBaseUrl config |
Future Work
These phases build on the working bridge from Phase 1. They are not designed in detail here -- revisit when ready to implement.
Manifest cleanup + bootstrapping. Remove rootDir and sourceDir from ManifestData (they're filesystem paths that shouldn't be in client output). Move them to a build-internal type. Then inline the client-safe manifest into every page via injectBridgeRuntime so the bridge has full source graph knowledge on init. When inlining, use a safe JSON serializer that escapes </ to <\/ to prevent </script> injection. The bridge would forward the manifest to the player in the ready event, eliminating the manifest fetch.
Link interception + SPA navigation. The bridge intercepts clicks on internal links (identifiable by the data-pathmx-target attribute already added during build). Instead of navigating, it sends a linkClick event to the player. The player fetches the target source's .blocks.html fragment and sends a navigate command back. The bridge swaps <main> innerHTML, re-scans blocks, and re-fires ready. A prefetch manager caches .blocks.html content for instant transitions. When intercepting clicks, skip modified clicks (ctrl/meta/shift/alt key held, middle-click, target="_blank", download attribute) so those behave normally.
Focus/play mode + theming. A presentation mode where the player navigates block-by-block with keyboard/button controls, dimming non-active blocks via CSS class toggles. A setTheme command lets the player change CSS custom properties on the content page's <html> element (colors, light/dark mode). TOC extraction from headings enables a sidebar outline. Scroll position reporting enables progress indicators.