# postMessage scroll control for `usingandunderstanding.ai` embed mode
postMessage scroll control for usingandunderstanding.ai embed mode
Context
I'm building a slide deck (reveal.js, deployed at https://geoffreychallen.com/mdx/talks/2026-05-12-the-new-computing/, served from http://localhost:3000 in dev) that embeds the site in an <iframe> with ?embed=true&colorScheme=light. The embedded page has more content than fits on the slide, and I want the presenter to be able to scroll the embed remotely (from a slide-side button or keypress) without clicking into the iframe and stealing focus from reveal's slide-nav.
Because the iframe is cross-origin, the parent can't call contentWindow.scrollTo directly. The site has to opt in by listening for a postMessage and scrolling itself.
What to implement
Add a message event listener that runs only when embed mode is active (i.e., the ?embed=true codepath that already adds body.embed-mode). The natural home is the existing EmbedMode client component (the one that currently sets the body class and calls setTheme(colorScheme)).
Message protocol
The parent will send messages of the form:
type ScrollMessage =
| { type: "uai:scroll-by"; delta: number; smooth?: boolean } // scrollBy({ top: delta })
| { type: "uai:scroll-to"; top: number; smooth?: boolean } // scrollTo({ top })
| { type: "uai:scroll-page"; direction: "up" | "down"; smooth?: boolean }; // ~viewport-height jump
All three should call window.scrollBy/window.scrollTo on the iframe's own window with behavior: smooth ? "smooth" : "auto" (default true).
The uai: prefix is just a namespace so we don't collide with other senders.
Security
Hard-allowlist the parent origin. Reject anything else silently. Allowlist these three:
const ALLOWED_PARENT_ORIGINS = new Set([
"https://geoffreychallen.com",
"https://www.geoffreychallen.com",
"http://localhost:3000",
]);
Check event.origin against that set on every message. Don't trust event.source or event.data until origin passes.
Lifecycle
- Attach the listener in
useEffectonly whenembed === "true". - Return a cleanup that removes it.
- Do not attach when embed mode is off — we don't want random pages on the site to be scrollable by arbitrary framers.
Sketch
useEffect(() => {
if (!isEmbedMode) return;
function onMessage(event: MessageEvent) {
if (!ALLOWED_PARENT_ORIGINS.has(event.origin)) return;
const msg = event.data;
if (!msg || typeof msg !== "object") return;
const behavior: ScrollBehavior = msg.smooth === false ? "auto" : "smooth";
switch (msg.type) {
case "uai:scroll-by":
if (typeof msg.delta === "number") window.scrollBy({ top: msg.delta, behavior });
break;
case "uai:scroll-to":
if (typeof msg.top === "number") window.scrollTo({ top: msg.top, behavior });
break;
case "uai:scroll-page": {
const delta = window.innerHeight * 0.9 * (msg.direction === "up" ? -1 : 1);
window.scrollBy({ top: delta, behavior });
break;
}
}
}
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [isEmbedMode]);
How the parent will use it
For reference (you don't need to write this — it lives on my side):
iframe.contentWindow.postMessage(
{ type: "uai:scroll-page", direction: "down" },
"https://usingandunderstanding.ai"
);
Acceptance
- With
?embed=true, posting{type:"uai:scroll-page", direction:"down"}from an allowed origin scrolls the page down ~one viewport. - With
?embed=truebut a non-allowlisted parent origin, posting the same message does nothing. - Without
?embed=true(normal page load), the listener is not attached at all. - No regressions to the existing
setTheme/embed-modebody-class behavior.
Also, please
While you're in there, please drop the gate that requires embed=true for colorScheme to take effect — I want ?colorScheme=light to work on the public homepage too, with or without embed mode. (You already mentioned this is in progress.)
The exploration confirms the picture and surfaces one notable thing: the colorScheme embed-mode gate is already gone in the current embed-mode.tsx (commit 214c9930). So the "also, please" item is a no-op — I'll call that out in the plan.
Writing the plan now.
Now let me typecheck to make sure nothing broke.
Typecheck passes with no errors. The change is in apps/web/components/embed-mode.tsx:
- Module-scope allowlist for
geoffreychallen.com,www.geoffreychallen.com, andhttp://localhost:3000, plus aScrollMessagediscriminated union type. - New
useEffectgated onisEmbed: validatesevent.originagainst the allowlist before touchingevent.data, dispatchesuai:scroll-by/uai:scroll-to/uai:scroll-page, defaults to smooth scrolling, and cleans up on unmount. - Existing body-class and
setThemeeffects untouched. - The
colorScheme-outside-embed-mode change was already shipped in214c9930, so nothing to do there.
Ready for you to verify in the slide deck — let me know if you want me to also commit.
Your task
First, run git status, git diff, and git log --oneline -10 to understand the current state.
Then add and commit all modified files with a descriptive message. Break the changes into multiple commits if appropriate. If there are untracked files, ask if they should be added or added to the appropriate .gitignore file. The user may also want to take over at this point, so provide an abort option as well. Always check changes for unnecessary debugging statements or logging that might have been accidentally included. Prompt the user if you find any, and offer to remove each one by one. Do not use interactive git staging (git add -p) as it does not work.
Clean diff — one file, no debug logs, no untracked files. Committing.
Committed as df9a2c97. Working tree clean, one commit ahead of origin/main (not pushed).
Can we make it possible to embed the learning animations, like the digit explorer? The embed would have only the component itself, no chrome, no additional explanation. We could add embed links to the resources page or the per-resource pedagogical explanation.
Different task from the prior plan — that one was about postMessage scroll. I'll overwrite the plan file with the new task. Let me explore first.
The exploration gives me a clear shape: all 8 animations are pure client components, the /resources/[slug] page already has a slug→component switch, and there's precedent for a minimal layout (the display route). The natural fit is a new /embed/[slug] route with its own minimal layout, sharing the same slug→component registry as the detail page.
Two design decisions where I should hear from you before drafting the plan:
Let me confirm the exact slugs and registry shape before drafting.
I have everything I need. Writing the plan now.
Good catch — I should write the failing tests first. Let me update the plan to put tests in front of the implementation. I'll also add the data-testid attributes the tests will rely on.
Now strip the old verification section since it's been replaced by the test spec + execution-order section.
Updating memory to broaden the TDD preference, then loading task tools and starting the red phase.
Now load task tools and start the red phase.
Let me look at an existing spec file to match conventions, then write the embed spec.
Now I have the conventions down. Writing the embed spec.
Now add the embed project entry to playwright.config.ts.
Need the dev server running first. Let me check status.
Dev server is up. Running the embed spec to verify red.
The first test failed for exactly the expected reason: 404 because the route doesn't exist. The config has maxFailures: 1 so it stopped there. Let me run with that disabled to confirm all the expected reds before implementing.
Red phase confirmed: 11 failures, all for the expected reasons (route doesn't exist, testids don't exist). 21 passing tests are the negative assertions (auth tools 404, snippets absent for auth tools, etc.) — those will guard against regressions during green.
Now implementing. First the registry.
Now the embed route.
Now extend EmbedMode to also activate on /embed/... paths.
Now the snippet component.
Now wire the snippet into the resource detail page.
Now the index page — add "Embed →" links next to "Pedagogy & details →" for the three public tools.
Implementation done. Run the embed spec to verify green.
All 32 tests green. Now typecheck.
TypeScript can't follow the narrowing through isEmbeddableSlug. Fix by checking the lookup result directly.
Typecheck clean. Final pass — re-run the embed spec to confirm nothing regressed after the refactor.
All 32 tests green, typecheck clean.
Summary of changes
Red → green TDD on the embeddable-animations feature:
- Tests first (
testing/e2e/tests/embed.spec.ts, newembedproject inplaywright.config.ts) — 32 tests covering: chrome-free render for the three public tools, 404 for auth-gated and unknown slugs, snippet visibility on detail pages, embed links on the index, clipboard-copy behavior. Verified all 11 acceptance tests failed for the expected reasons before any production code changed. - Registry —
apps/web/lib/embeddable-tools.tsxis the single source of truth for which animations are embeddable (Markov Babbler, Neuron Explorer, Digit Network). - Route —
apps/web/app/embed/[slug]/page.tsxrenders just the component, gated by registry membership; everything else 404s. - Embed-mode trigger —
apps/web/components/embed-mode.tsxnow activates on any/embed/...path viausePathname, so the existingbody.embed-modeCSS hides header/footer and the postMessage scroll listener attaches automatically — no?embed=trueneeded. - Snippet UI —
apps/web/components/embed-snippet.tsxshows the absolute embed URL and an iframe snippet, each with copy-to-clipboard. Renders only for embeddable slugs on/resources/[slug]. - Index links —
Embed →links next to the existingPedagogy & details →for the three public tools on/resources, anchored to#embedon the detail page.
Let me know when you want to commit, or if you'd like to verify the postMessage scroll path on /embed/digit-network first.
Your task
First, run git status, git diff, and git log --oneline -10 to understand the current state.
Then add and commit all modified files with a descriptive message. Break the changes into multiple commits if appropriate. If there are untracked files, ask if they should be added or added to the appropriate .gitignore file. The user may also want to take over at this point, so provide an abort option as well. Always check changes for unnecessary debugging statements or logging that might have been accidentally included. Prompt the user if you find any, and offer to remove each one by one. Do not use interactive git staging (git add -p) as it does not work.
Quick scan of the new files for stray debug logs before committing.
Clean — no debug logs. Committing as one feature commit (test + implementation together).
Committed as 96106c97 on resource_embeds. Working tree clean.
Note: this branch also has the earlier df9a2c97 postMessage commit from the previous session; both commits are local-only (not pushed). Let me know if you want to push or open a PR.
Your task
First, run git status, git diff, and git log --oneline -10 to understand the current state.
Then add and commit all modified files with a descriptive message. Break the changes into multiple commits if appropriate. If there are untracked files, ask if they should be added or added to the appropriate .gitignore file. The user may also want to take over at this point, so provide an abort option as well. Always check changes for unnecessary debugging statements or logging that might have been accidentally included. Prompt the user if you find any, and offer to remove each one by one. Do not use interactive git staging (git add -p) as it does not work.
Pure formatter reflow from Biome — no behavior changes. Committing.
Committed as 310a31fd on resource_embeds. Working tree clean.