From 50f0e110368aaa85f88acad000838632af17fae9 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Feb 2026 19:45:22 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20folk-rapp=20shape=20=E2=80=94=20embed?= =?UTF-8?q?=20live=20rApp=20modules=20on=20the=20canvas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POC for cross-app embedding (TASK-46). New folk-rapp shape type that embeds any rApp module as a live iframe inside a canvas shape. Features: - Module picker dropdown when no module selected - Colored header with module badge/icon - Open-in-tab action button - Syncs moduleId + spaceSlug via Automerge CRDT - Toolbar rApps section now creates folk-rapp (not generic folk-embed) - Fixed stale "canvas" moduleId refs → "rspace" in canvas.html Co-Authored-By: Claude Opus 4.6 --- lib/community-sync.ts | 7 + lib/folk-rapp.ts | 416 ++++++++++++++++++++++++++++++++++++++++++ lib/index.ts | 3 + website/canvas.html | 69 ++++--- 4 files changed, 465 insertions(+), 30 deletions(-) create mode 100644 lib/folk-rapp.ts diff --git a/lib/community-sync.ts b/lib/community-sync.ts index ed3fc05..aaad860 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -769,6 +769,13 @@ export class CommunitySync extends EventTarget { if (data.scores !== undefined) spider.scores = data.scores; } + // Update rApp embed properties + if (data.type === "folk-rapp") { + const rapp = shape as any; + if (data.moduleId !== undefined && rapp.moduleId !== data.moduleId) rapp.moduleId = data.moduleId; + if (data.spaceSlug !== undefined && rapp.spaceSlug !== data.spaceSlug) rapp.spaceSlug = data.spaceSlug; + } + // Update feed shape properties if (data.type === "folk-feed") { const feed = shape as any; diff --git a/lib/folk-rapp.ts b/lib/folk-rapp.ts new file mode 100644 index 0000000..e3b1aff --- /dev/null +++ b/lib/folk-rapp.ts @@ -0,0 +1,416 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; +import { rspaceNavUrl } from "../shared/url-helpers"; + +/** + * — Embeds a live rApp module as a shape on the canvas. + * + * 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. + */ + +// Module metadata for header display (subset of rstack-app-switcher badges) +const MODULE_META: Record = { + rnotes: { badge: "rN", color: "#fcd34d", name: "rNotes", icon: "📝" }, + rphotos: { badge: "rPh", color: "#f9a8d4", name: "rPhotos", icon: "📸" }, + rbooks: { badge: "rB", color: "#fda4af", name: "rBooks", icon: "📚" }, + rpubs: { badge: "rP", color: "#fda4af", name: "rPubs", icon: "📖" }, + rfiles: { badge: "rFi", color: "#67e8f9", name: "rFiles", icon: "📁" }, + rwork: { badge: "rWo", color: "#cbd5e1", name: "rWork", icon: "📋" }, + rforum: { badge: "rFo", color: "#fcd34d", name: "rForum", icon: "💬" }, + rinbox: { badge: "rI", color: "#a5b4fc", name: "rInbox", icon: "📧" }, + rtube: { badge: "rTu", color: "#f9a8d4", name: "rTube", icon: "🎬" }, + rfunds: { badge: "rF", color: "#bef264", name: "rFunds", icon: "🌊" }, + rwallet: { badge: "rW", color: "#fde047", name: "rWallet", icon: "💰" }, + rvote: { badge: "rV", color: "#c4b5fd", name: "rVote", icon: "🗳️" }, + rcart: { badge: "rCt", color: "#fdba74", name: "rCart", icon: "🛒" }, + rdata: { badge: "rD", color: "#d8b4fe", name: "rData", icon: "📊" }, + rnetwork: { badge: "rNe", color: "#93c5fd", name: "rNetwork", icon: "🌍" }, + rsplat: { badge: "r3", color: "#d8b4fe", name: "rSplat", icon: "🔮" }, + rproviders: { badge: "rPr", color: "#fdba74", name: "rProviders", icon: "🏭" }, + rswag: { badge: "rSw", color: "#fda4af", name: "rSwag", icon: "🎨" }, + rchoices: { badge: "rCo", color: "#f0abfc", name: "rChoices", icon: "🤔" }, + rcal: { badge: "rC", color: "#7dd3fc", name: "rCal", icon: "📅" }, + rtrips: { badge: "rT", color: "#6ee7b7", name: "rTrips", icon: "✈️" }, + rmaps: { badge: "rM", color: "#86efac", name: "rMaps", icon: "🗺️" }, +}; + +const styles = css` + :host { + background: #1e293b; + border-radius: 10px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); + min-width: 320px; + min-height: 240px; + overflow: hidden; + } + + .rapp-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + background: var(--rapp-color, #334155); + color: #0f172a; + font-size: 12px; + font-weight: 700; + cursor: move; + user-select: none; + border-radius: 10px 10px 0 0; + } + + .rapp-header-left { + display: flex; + align-items: center; + gap: 7px; + } + + .rapp-badge { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 5px; + background: rgba(0, 0, 0, 0.15); + font-size: 0.55rem; + font-weight: 900; + line-height: 1; + flex-shrink: 0; + } + + .rapp-name { + font-size: 12px; + font-weight: 700; + } + + .rapp-icon { + font-size: 13px; + } + + .rapp-actions { + display: flex; + gap: 2px; + } + + .rapp-actions button { + background: transparent; + border: none; + color: #0f172a; + cursor: pointer; + padding: 2px 5px; + border-radius: 4px; + font-size: 12px; + opacity: 0.7; + transition: opacity 0.15s, background 0.15s; + } + + .rapp-actions button:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.1); + } + + .rapp-content { + width: 100%; + height: calc(100% - 34px); + position: relative; + background: #0f172a; + } + + .rapp-iframe { + width: 100%; + height: 100%; + border: none; + border-radius: 0 0 10px 10px; + } + + .rapp-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 12px; + color: #64748b; + font-size: 13px; + } + + .rapp-loading .spinner { + width: 24px; + height: 24px; + border: 2px solid rgba(100, 116, 139, 0.3); + border-top-color: #64748b; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + .rapp-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 8px; + color: #ef4444; + font-size: 13px; + padding: 16px; + text-align: center; + } + + /* Module picker (shown when no moduleId set) */ + .rapp-picker { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; + height: 100%; + overflow-y: auto; + } + + .rapp-picker-title { + font-size: 12px; + font-weight: 600; + color: #94a3b8; + margin-bottom: 4px; + } + + .rapp-picker-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 6px; + cursor: pointer; + transition: background 0.12s; + color: #e2e8f0; + font-size: 13px; + border: none; + background: transparent; + text-align: left; + width: 100%; + } + + .rapp-picker-item:hover { + background: rgba(255, 255, 255, 0.08); + } + + .rapp-picker-badge { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 5px; + font-size: 0.5rem; + font-weight: 900; + color: #0f172a; + flex-shrink: 0; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-rapp": FolkRApp; + } +} + +export class FolkRApp extends FolkShape { + static override tagName = "folk-rapp"; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + const childRules = Array.from(styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #moduleId: string = ""; + #spaceSlug: string = ""; + #iframe: HTMLIFrameElement | null = null; + #contentEl: HTMLElement | null = null; + + get moduleId() { return this.#moduleId; } + set moduleId(value: string) { + this.#moduleId = value; + this.requestUpdate("moduleId"); + this.dispatchEvent(new CustomEvent("content-change")); + this.#loadModule(); + } + + get spaceSlug() { return this.#spaceSlug; } + set spaceSlug(value: string) { + this.#spaceSlug = value; + this.requestUpdate("spaceSlug"); + this.dispatchEvent(new CustomEvent("content-change")); + this.#loadModule(); + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + this.#moduleId = this.getAttribute("module-id") || ""; + this.#spaceSlug = this.getAttribute("space-slug") || ""; + + const meta = MODULE_META[this.#moduleId]; + const headerColor = meta?.color || "#475569"; + const headerName = meta?.name || this.#moduleId || "rApp"; + const headerBadge = meta?.badge || "r?"; + const headerIcon = meta?.icon || "📱"; + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+
+ ${headerBadge} + ${headerName} + ${headerIcon} +
+
+ + +
+
+
+ `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) { + containerDiv.replaceWith(wrapper); + } + + this.#contentEl = wrapper.querySelector(".rapp-content") as HTMLElement; + const openTabBtn = wrapper.querySelector(".open-tab-btn") as HTMLButtonElement; + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + + // Open in tab navigates to the module's page + openTabBtn.addEventListener("click", (e) => { + e.stopPropagation(); + if (this.#moduleId && this.#spaceSlug) { + window.open(rspaceNavUrl(this.#spaceSlug, this.#moduleId), "_blank"); + } + }); + + // Close button + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Load content + if (this.#moduleId) { + this.#loadModule(); + } else { + this.#showPicker(); + } + + return root; + } + + #loadModule() { + if (!this.#contentEl || !this.#moduleId) return; + + // Update header + const meta = MODULE_META[this.#moduleId]; + const header = this.shadowRoot?.querySelector(".rapp-header") as HTMLElement; + if (header && meta) { + header.style.setProperty("--rapp-color", meta.color); + const badge = header.querySelector(".rapp-badge"); + const name = header.querySelector(".rapp-name"); + const icon = header.querySelector(".rapp-icon"); + if (badge) badge.textContent = meta.badge; + if (name) name.textContent = meta.name; + if (icon) icon.textContent = meta.icon; + } + + // Show loading state + this.#contentEl.innerHTML = ` +
+
+ Loading ${meta?.name || this.#moduleId}... +
+ `; + + // Create iframe + const space = this.#spaceSlug || "demo"; + const iframeUrl = `/${space}/${this.#moduleId}`; + + const iframe = document.createElement("iframe"); + iframe.className = "rapp-iframe"; + iframe.src = iframeUrl; + iframe.loading = "lazy"; + iframe.allow = "clipboard-write"; + + iframe.addEventListener("load", () => { + // Remove loading indicator + const loading = this.#contentEl?.querySelector(".rapp-loading"); + if (loading) loading.remove(); + }); + + iframe.addEventListener("error", () => { + if (this.#contentEl) { + this.#contentEl.innerHTML = ` +
+ Failed to load ${meta?.name || this.#moduleId} + +
+ `; + this.#contentEl.querySelector("button")?.addEventListener("click", () => this.#loadModule()); + } + }); + + this.#contentEl.appendChild(iframe); + this.#iframe = iframe; + } + + #showPicker() { + if (!this.#contentEl) return; + + const items = Object.entries(MODULE_META) + .map(([id, meta]) => ` + + `) + .join(""); + + this.#contentEl.innerHTML = ` +
+ Choose an rApp to embed + ${items} +
+ `; + + this.#contentEl.querySelectorAll(".rapp-picker-item").forEach((btn) => { + btn.addEventListener("click", () => { + const modId = (btn as HTMLElement).dataset.module; + if (modId) { + this.moduleId = modId; + } + }); + }); + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-rapp", + moduleId: this.#moduleId, + spaceSlug: this.#spaceSlug, + }; + } +} diff --git a/lib/index.ts b/lib/index.ts index 9b7d39e..9519134 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -66,6 +66,9 @@ export * from "./folk-choice-spider"; // Nested Space Shape export * from "./folk-canvas"; +// rApp Embed Shape (cross-app embedding) +export * from "./folk-rapp"; + // Feed Shape (inter-layer data flow) export * from "./folk-feed"; diff --git a/website/canvas.html b/website/canvas.html index a439d52..013ac69 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -402,7 +402,8 @@ folk-choice-vote, folk-choice-rank, folk-choice-spider, - folk-social-post { + folk-social-post, + folk-rapp { position: absolute; } @@ -413,7 +414,7 @@ folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-token-mint, folk-token-ledger, folk-choice-vote, folk-choice-rank, folk-choice-spider, - folk-social-post) { + folk-social-post, folk-rapp) { cursor: crosshair; } @@ -424,7 +425,7 @@ folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-token-mint, folk-token-ledger, folk-choice-vote, folk-choice-rank, folk-choice-spider, - folk-social-post):hover { + folk-social-post, folk-rapp):hover { outline: 2px dashed #3b82f6; outline-offset: 4px; } @@ -741,6 +742,7 @@ FolkChoiceSpider, FolkSocialPost, FolkCanvas, + FolkRApp, FolkFeed, CommunitySync, PresenceManager, @@ -769,7 +771,7 @@ if (tabBar) { const canvasDefaultLayer = { id: "layer-canvas", - moduleId: "canvas", + moduleId: "rspace", label: "rSpace", order: 0, color: "", @@ -783,7 +785,7 @@ // Tab switching: navigate to the selected module's page tabBar.addEventListener("layer-switch", (e) => { const { moduleId } = e.detail; - if (moduleId === "canvas") return; // already on canvas + if (moduleId === "rspace") return; // already on canvas window.location.href = rspaceNavUrl( document.querySelector("rstack-space-switcher")?.getAttribute("current") || "demo", moduleId @@ -853,6 +855,7 @@ FolkChoiceSpider.define(); FolkSocialPost.define(); FolkCanvas.define(); + FolkRApp.define(); FolkFeed.define(); // Get community info from URL @@ -863,7 +866,7 @@ const urlParams = new URLSearchParams(window.location.search); const pathSegments = window.location.pathname.split("/").filter(Boolean); - const ignorePaths = ["canvas", "settings", "api"]; + const ignorePaths = ["rspace", "canvas", "settings", "api"]; const cleanSegments = pathSegments.filter(s => !ignorePaths.includes(s)); let communitySlug = urlParams.get("space"); @@ -906,6 +909,7 @@ "folk-token-mint", "folk-token-ledger", "folk-choice-vote", "folk-choice-rank", "folk-choice-spider", "folk-social-post", + "folk-rapp", "folk-feed" ].join(", "); @@ -930,7 +934,7 @@ if (tabBar && sync) { const canvasDefaultLayer = { id: "layer-canvas", - moduleId: "canvas", + moduleId: "rspace", label: "rSpace", order: 0, color: "", @@ -1307,6 +1311,11 @@ if (data.collapsed != null) shape.collapsed = data.collapsed; if (data.label) shape.label = data.label; break; + case "folk-rapp": + shape = document.createElement("folk-rapp"); + if (data.moduleId) shape.moduleId = data.moduleId; + if (data.spaceSlug) shape.spaceSlug = data.spaceSlug; + break; case "folk-feed": shape = document.createElement("folk-feed"); if (data.sourceLayer) shape.sourceLayer = data.sourceLayer; @@ -1387,6 +1396,7 @@ "folk-choice-spider": { width: 440, height: 540 }, "folk-social-post": { width: 300, height: 380 }, "folk-canvas": { width: 600, height: 400 }, + "folk-rapp": { width: 500, height: 400 }, "folk-feed": { width: 280, height: 360 }, }; @@ -1638,34 +1648,33 @@ }); }); - // rApp embed buttons — embed any module as an interactive iframe on the canvas + // rApp embed buttons — embed any module as a folk-rapp shape on the canvas const rAppModules = [ - { btnId: "embed-notes", moduleId: "notes", icon: "📝", name: "rNotes" }, - { btnId: "embed-photos", moduleId: "photos", icon: "📸", name: "rPhotos" }, - { btnId: "embed-books", moduleId: "books", icon: "📚", name: "rBooks" }, - { btnId: "embed-pubs", moduleId: "pubs", icon: "📖", name: "rPubs" }, - { btnId: "embed-files", moduleId: "files", icon: "📁", name: "rFiles" }, - { btnId: "embed-work", moduleId: "work", icon: "📋", name: "rWork" }, - { btnId: "embed-forum", moduleId: "forum", icon: "💬", name: "rForum" }, - { btnId: "embed-inbox", moduleId: "inbox", icon: "📧", name: "rInbox" }, - { btnId: "embed-tube", moduleId: "tube", icon: "🎬", name: "rTube" }, - { btnId: "embed-funds", moduleId: "funds", icon: "🌊", name: "rFunds" }, - { btnId: "embed-wallet", moduleId: "wallet", icon: "💰", name: "rWallet" }, - { btnId: "embed-vote", moduleId: "vote", icon: "🗳️", name: "rVote" }, - { btnId: "embed-cart", moduleId: "cart", icon: "🛒", name: "rCart" }, - { btnId: "embed-data", moduleId: "data", icon: "📊", name: "rData" }, - { btnId: "embed-network", moduleId: "network", icon: "🌍", name: "rNetwork" }, - { btnId: "embed-splat", moduleId: "splat", icon: "🔮", name: "rSplat" }, - { btnId: "embed-providers", moduleId: "providers", icon: "🏭", name: "rProviders" }, - { btnId: "embed-swag", moduleId: "swag", icon: "🎨", name: "rSwag" }, + { btnId: "embed-notes", moduleId: "rnotes" }, + { btnId: "embed-photos", moduleId: "rphotos" }, + { btnId: "embed-books", moduleId: "rbooks" }, + { btnId: "embed-pubs", moduleId: "rpubs" }, + { btnId: "embed-files", moduleId: "rfiles" }, + { btnId: "embed-work", moduleId: "rwork" }, + { btnId: "embed-forum", moduleId: "rforum" }, + { btnId: "embed-inbox", moduleId: "rinbox" }, + { btnId: "embed-tube", moduleId: "rtube" }, + { btnId: "embed-funds", moduleId: "rfunds" }, + { btnId: "embed-wallet", moduleId: "rwallet" }, + { btnId: "embed-vote", moduleId: "rvote" }, + { btnId: "embed-cart", moduleId: "rcart" }, + { btnId: "embed-data", moduleId: "rdata" }, + { btnId: "embed-network", moduleId: "rnetwork" }, + { btnId: "embed-splat", moduleId: "rsplat" }, + { btnId: "embed-providers", moduleId: "rproviders" }, + { btnId: "embed-swag", moduleId: "rswag" }, ]; for (const app of rAppModules) { const btn = document.getElementById(app.btnId); if (btn) { btn.addEventListener("click", () => { - const moduleUrl = rspaceNavUrl(communitySlug, app.moduleId); - newShape("folk-embed", { url: moduleUrl }); + newShape("folk-rapp", { moduleId: app.moduleId, spaceSlug: communitySlug }); }); } } @@ -1699,7 +1708,7 @@ // Auto-register a LayerFlow in Automerge if layers exist if (shape && sync.getLayers) { const layers = sync.getLayers(); - const currentLayer = layers.find(l => l.moduleId === "canvas") || layers[0]; + const currentLayer = layers.find(l => l.moduleId === "rspace") || layers[0]; const sourceLayer = layers.find(l => l.moduleId === sourceModule); if (currentLayer && sourceLayer) { @@ -1800,7 +1809,7 @@ "folk-token-mint": "🪙", "folk-token-ledger": "📒", "folk-choice-vote": "☑", "folk-choice-rank": "📊", "folk-choice-spider": "🕸", "folk-social-post": "📱", - "folk-feed": "🔄", "folk-arrow": "↗️", + "folk-rapp": "📱", "folk-feed": "🔄", "folk-arrow": "↗️", }; function getShapeLabel(data) {