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:

  1. Wait for DOMContentLoaded.
  2. Check activation:
    • If window.parent === window, exit silently (not in an iframe).
    • If ?pathmx-bridge=false is in the query string, exit silently.
  3. Check for debug mode: if ?pathmx-debug=1 is in the query string, enable console logging. All console.log calls in the bridge should be gated behind this flag so the bridge is silent by default.
  4. 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 an id attribute.
  5. Read document.body.dataset.pathmxSourceId to get the current source ID.
  6. Post a ready event to window.parent:
    window.parent.postMessage(
      {
        channel: "pathmx",
        message: { type: "ready", sourceId, blocks },
      },
      "*"
    )
    
  7. 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, "&amp;")
    .replace(/"/g, "&quot;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
}

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:

  1. Check that pathmx-bridge.js exists in the build output root.
  2. Open any built .html file. Confirm:
    • <body> has a data-pathmx-source-id attribute with the correct source ID.
    • <script src="/pathmx-bridge.js"></script> appears before </body>.
  3. 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=1 to 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.source is checked against this.iframe.contentWindow to 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

  1. Start the pathmx dev server: pmx dev in a test repo.
  2. Start the player dev server: bun run dev in packages/player.
  3. Open the player in the browser. The iframe should load the content page.
  4. Open the browser console. You should see the ready event logged with the source ID and block list (add ?pathmx-debug=1 to the content URL if the bridge is silent).
  5. Click the next/prev buttons. The content in the iframe should scroll to the corresponding block.
  6. Test an iframe reload (e.g. navigate manually in the iframe). The bridge should reset and re-fire ready when 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

FileDescription
packages/player/src/lib/utils.tscn helper for shadcn
packages/player/components.jsonshadcn CLI config (Base UI)
packages/core/src/bridge/types.tsShared bridge protocol types
packages/core/src/bridge/index.tsBarrel export
packages/core/src/plugins/runtime/html/inject.tsHTML post-processor for bridge injection
packages/core/src/plugins/runtime/scripts/bridge.tsBridge script generator
packages/player/src/bridge/content-bridge.tsPlayer-side bridge SDK

Edited files

FileChange
packages/player/tsconfig.jsonAdd baseUrl + @/* path aliases
packages/player/package.jsonMove shadcn to devDependencies
packages/player/src/player/player.cssAdd shadcn import + theme variables
packages/core/src/plugins/runtime/runtime-plugin.tsCall injectBridgeRuntime in onBuildSource, emit bridge artifact in finalize
packages/player/src/player/pathmx-player.tsxWire 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.