diff --git a/lib/folk-rapp.ts b/lib/folk-rapp.ts
index e3b1aff..dbacf11 100644
--- a/lib/folk-rapp.ts
+++ b/lib/folk-rapp.ts
@@ -8,6 +8,11 @@ import { rspaceNavUrl } from "../shared/url-helpers";
* Unlike folk-embed (generic URL iframe), folk-rapp understands the module
* system: it stores moduleId + spaceSlug, derives the iframe URL, shows
* the module's icon/badge in the header, and can switch modules in-place.
+ *
+ * PostMessage protocol:
+ * Parent → iframe: { source: "rspace-parent", type: "context", shapeId, space, moduleId }
+ * iframe → parent: { source: "rspace-canvas", type: "shape-updated", ... } (from CommunitySync)
+ * iframe → parent: { source: "rspace-rapp", type: "navigate", moduleId }
*/
// Module metadata for header display (subset of rstack-app-switcher badges)
@@ -64,6 +69,7 @@ const styles = css`
display: flex;
align-items: center;
gap: 7px;
+ position: relative;
}
.rapp-badge {
@@ -211,6 +217,79 @@ const styles = css`
color: #0f172a;
flex-shrink: 0;
}
+
+ /* Module switcher dropdown */
+ .rapp-switcher {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ margin-top: 4px;
+ min-width: 180px;
+ max-height: 300px;
+ overflow-y: auto;
+ background: #1e293b;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 8px;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
+ padding: 4px;
+ z-index: 100;
+ display: none;
+ }
+
+ .rapp-switcher.open {
+ display: block;
+ }
+
+ .rapp-switcher-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 8px;
+ border-radius: 5px;
+ cursor: pointer;
+ color: #e2e8f0;
+ font-size: 12px;
+ border: none;
+ background: transparent;
+ width: 100%;
+ text-align: left;
+ transition: background 0.12s;
+ }
+
+ .rapp-switcher-item:hover {
+ background: rgba(255, 255, 255, 0.08);
+ }
+
+ .rapp-switcher-item.active {
+ background: rgba(6, 182, 212, 0.15);
+ }
+
+ .rapp-switcher-badge {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ border-radius: 4px;
+ font-size: 0.45rem;
+ font-weight: 900;
+ color: #0f172a;
+ flex-shrink: 0;
+ }
+
+ /* Status indicator for postMessage connection */
+ .rapp-status {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #475569;
+ flex-shrink: 0;
+ transition: background 0.3s;
+ }
+
+ .rapp-status.connected {
+ background: #22c55e;
+ }
`;
declare global {
@@ -238,9 +317,12 @@ export class FolkRApp extends FolkShape {
#spaceSlug: string = "";
#iframe: HTMLIFrameElement | null = null;
#contentEl: HTMLElement | null = null;
+ #messageHandler: ((e: MessageEvent) => void) | null = null;
+ #statusEl: HTMLElement | null = null;
get moduleId() { return this.#moduleId; }
set moduleId(value: string) {
+ if (this.#moduleId === value) return;
this.#moduleId = value;
this.requestUpdate("moduleId");
this.dispatchEvent(new CustomEvent("content-change"));
@@ -249,6 +331,7 @@ export class FolkRApp extends FolkShape {
get spaceSlug() { return this.#spaceSlug; }
set spaceSlug(value: string) {
+ if (this.#spaceSlug === value) return;
this.#spaceSlug = value;
this.requestUpdate("spaceSlug");
this.dispatchEvent(new CustomEvent("content-change"));
@@ -274,10 +357,13 @@ export class FolkRApp extends FolkShape {
${headerBadge}
${headerName}
${headerIcon}
+
+
+
-
+
@@ -290,14 +376,28 @@ export class FolkRApp extends FolkShape {
}
this.#contentEl = wrapper.querySelector(".rapp-content") as HTMLElement;
+ this.#statusEl = wrapper.querySelector(".rapp-status") as HTMLElement;
+ const switchBtn = wrapper.querySelector(".switch-btn") as HTMLButtonElement;
const openTabBtn = wrapper.querySelector(".open-tab-btn") as HTMLButtonElement;
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
+ const switcherEl = wrapper.querySelector(".rapp-switcher") as HTMLElement;
- // Open in tab navigates to the module's page
+ // Module switcher dropdown
+ this.#buildSwitcher(switcherEl);
+ switchBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ switcherEl.classList.toggle("open");
+ });
+
+ // Close switcher when clicking elsewhere
+ const closeSwitcher = () => switcherEl.classList.remove("open");
+ root.addEventListener("click", closeSwitcher);
+
+ // Open in tab — navigate to the module's page via tab bar
openTabBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (this.#moduleId && this.#spaceSlug) {
- window.open(rspaceNavUrl(this.#spaceSlug, this.#moduleId), "_blank");
+ window.location.href = rspaceNavUrl(this.#spaceSlug, this.#moduleId);
}
});
@@ -307,6 +407,10 @@ export class FolkRApp extends FolkShape {
this.dispatchEvent(new CustomEvent("close"));
});
+ // Set up postMessage listener
+ this.#messageHandler = (e: MessageEvent) => this.#handleMessage(e);
+ window.addEventListener("message", this.#messageHandler);
+
// Load content
if (this.#moduleId) {
this.#loadModule();
@@ -317,6 +421,86 @@ export class FolkRApp extends FolkShape {
return root;
}
+ disconnectedCallback() {
+ super.disconnectedCallback?.();
+ if (this.#messageHandler) {
+ window.removeEventListener("message", this.#messageHandler);
+ this.#messageHandler = null;
+ }
+ }
+
+ #buildSwitcher(switcherEl: HTMLElement) {
+ const items = Object.entries(MODULE_META)
+ .map(([id, meta]) => `
+
+ `)
+ .join("");
+
+ switcherEl.innerHTML = items;
+
+ switcherEl.querySelectorAll(".rapp-switcher-item").forEach((btn) => {
+ btn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const modId = (btn as HTMLElement).dataset.module;
+ if (modId && modId !== this.#moduleId) {
+ this.moduleId = modId;
+ this.#buildSwitcher(switcherEl);
+ }
+ switcherEl.classList.remove("open");
+ });
+ });
+ }
+
+ /** Handle postMessage from embedded iframe */
+ #handleMessage(e: MessageEvent) {
+ if (!this.#iframe) return;
+
+ // Only accept messages from our iframe
+ if (e.source !== this.#iframe.contentWindow) return;
+
+ const msg = e.data;
+ if (!msg || typeof msg !== "object") return;
+
+ // CommunitySync shape updates from the embedded module
+ if (msg.source === "rspace-canvas" && msg.type === "shape-updated") {
+ this.dispatchEvent(new CustomEvent("rapp-data", {
+ detail: { moduleId: this.#moduleId, shapeId: msg.shapeId, data: msg.data },
+ bubbles: true,
+ }));
+
+ // Mark as connected
+ if (this.#statusEl) {
+ this.#statusEl.classList.add("connected");
+ this.#statusEl.title = "Connected — receiving data";
+ }
+ }
+
+ // Navigation request from embedded module
+ if (msg.source === "rspace-rapp" && msg.type === "navigate" && msg.moduleId) {
+ this.moduleId = msg.moduleId;
+ }
+ }
+
+ /** Send context to the iframe after it loads */
+ #sendContext() {
+ if (!this.#iframe?.contentWindow) return;
+ try {
+ this.#iframe.contentWindow.postMessage({
+ source: "rspace-parent",
+ type: "context",
+ shapeId: this.id,
+ space: this.#spaceSlug,
+ moduleId: this.#moduleId,
+ embedded: true,
+ }, "*");
+ } catch {
+ // cross-origin or iframe not ready
+ }
+ }
+
#loadModule() {
if (!this.#contentEl || !this.#moduleId) return;
@@ -333,6 +517,12 @@ export class FolkRApp extends FolkShape {
if (icon) icon.textContent = meta.icon;
}
+ // Reset connection status
+ if (this.#statusEl) {
+ this.#statusEl.classList.remove("connected");
+ this.#statusEl.title = "Loading...";
+ }
+
// Show loading state
this.#contentEl.innerHTML = `
@@ -355,6 +545,9 @@ export class FolkRApp extends FolkShape {
// Remove loading indicator
const loading = this.#contentEl?.querySelector(".rapp-loading");
if (loading) loading.remove();
+
+ // Send context to the newly loaded iframe
+ this.#sendContext();
});
iframe.addEventListener("error", () => {