"ChromeEmbed's side panel is an HTML page with a single iframe pointed at cwkPippa. Lesson 5 is the bridge that lets the iframe — which doesn't have chrome.* — still get the context the SW collects, via window.postMessage between extension and iframe."
The Side Panel HTML
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pippa</title>
<style>
html, body { margin: 0; width: 100%; height: 100%; background: #0f0f23; }
iframe { width: 100%; height: 100vh; border: 0; display: block; }
</style>
</head>
<body>
<iframe id="pippa-frame" src="http://localhost:5173/embed/panel"></iframe>
<script src="sidepanel.js"></script>
</body>
</html>
The HTML is almost entirely chrome (full-height iframe, dark background while loading). The src points at cwkPippa's /embed/panel route. Frame-src in the manifest's extension_pages CSP allows the localhost URL to load.
The Identity Boundary
The iframe is a different origin from the extension page (the extension page is on chrome-extension://<id>/, the iframe is on http://localhost:5173/). Consequences:
- The iframe cannot directly read
chrome.*. It's a regular web page from its perspective. - The extension page (sidepanel.html + sidepanel.js) CAN read chrome.* — same origin as the rest of the extension.
- The two communicate via
window.postMessageon the shared DOM window.
This is the trade ChromeEmbed makes: get all of cwkPippa's UI for free by loading it as an iframe, pay the cost of needing a bridge for any chrome.* data.
The Bridge — sidepanel.js
40 lines total. The full surface:
const frame = document.getElementById('pippa-frame');
function postToPanel(message) {
frame?.contentWindow?.postMessage(message, '*');
}
async function requestContext(requestId) {
const response = await chrome.runtime
.sendMessage({ type: 'pippa:request-context', requestId })
.catch(() => null);
if (response?.type === 'pippa:host-context') {
postToPanel({ ...response, requestId: response.requestId || requestId });
} else if (requestId) {
postToPanel({ type: 'pippa:no-context', requestId });
}
}
chrome.runtime.onMessage.addListener((message) => {
if (message?.type === 'pippa:host-context') {
postToPanel(message);
}
});
window.addEventListener('message', (event) => {
if (event.data?.type === 'pippa:request-context') {
requestContext(event.data.requestId);
}
});
frame?.addEventListener('load', () => requestContext());
requestContext();
Four Flows
- Iframe asks the bridge for context — iframe does
window.parent.postMessage({type:'pippa:request-context', requestId}, '*'); bridge's message listener catches it, asks SW, posts result back to iframe. - SW pushes context to the panel — when content script reports new context, SW broadcasts via
chrome.runtime.sendMessage; bridge's chrome.runtime.onMessage listener catches it, posts to iframe. - Iframe boots — on iframe load and on bridge load, the bridge calls requestContext() to seed the iframe with whatever context the SW already has.
- Future bidirectional — if the iframe ever sends 'send this to the SW,' the bridge's message listener can route that to
chrome.runtime.sendMessage. v0.1 doesn't have that path yet.
The Loose Origin
The postMessage calls use '*' as target origin. In a public page that'd be a security risk — any iframe could receive your payload. Inside a panel that you control loading, it's acceptable: the only iframe is the one you spawned. Production-hardening would change '*' to the actual cwkPippa origin (and the iframe would do the same for messages back to its parent).
What the Iframe Side Looks Like
Inside cwkPippa's /embed/panel route, code roughly mirrors the bridge:
// Inside cwkPippa, in the embed/panel React component
useEffect(() => {
const requestId = Math.random().toString(36).slice(2);
function onMessage(event) {
if (event.data?.requestId === requestId && event.data?.type === 'pippa:host-context') {
setContext(event.data.payload);
}
}
window.addEventListener('message', onMessage);
window.parent.postMessage({ type: 'pippa:request-context', requestId }, '*');
return () => window.removeEventListener('message', onMessage);
}, []);
Track 7 Lesson 6's preview-and-confirm Promise pattern would generalize this nicely; ChromeEmbed v0.1 keeps it inline.
Why Iframe Over Native Render
The alternative would be: re-implement cwkPippa's panel chat inside the extension package using a React build that lives in dist/. That works but means every cwkPippa change requires also rebuilding the extension. The iframe path means cwkPippa is the source of truth; the extension is purely a delivery mechanism.
For ChromeEmbed v0.1 the iframe wins by a mile because cwkPippa is already a working SPA with months of UI investment. Building 'extension-shaped cwkPippa' would have doubled the surface area. v2 might invert — once the embed API stabilizes, an offline-capable bundled UI could matter — but that's deliberately deferred.