feat: canvas tab bar + rApps toolbar + iframe shell for all modules

Add the missing tab bar to the canvas page so users can switch between
rApp layers (with full CommunitySync persistence). Add an "rApps"
toolbar group that embeds any of the 18 remaining modules as interactive
iframes directly on the canvas. Switch all module page routes to
renderIframeShell, loading standalone domains inside the unified shell.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-25 18:03:29 -08:00
parent fcf350d40a
commit 0f7d4eb7bb
22 changed files with 343 additions and 142 deletions

View File

@ -10,7 +10,7 @@ import { resolve } from "node:path";
import { mkdir, readFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import {
@ -201,41 +201,17 @@ routes.get("/api/books/:id/pdf", async (c) => {
});
});
// ── Page: Library (book grid) ──
routes.get("/", async (c) => {
// ── Page: Library ──
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "personal";
// Fetch books for the library page
const rows = await sql.unsafe(
`SELECT id, slug, title, author, description, pdf_size_bytes, page_count, tags,
cover_color, contributor_name, featured, view_count, created_at
FROM rbooks.books WHERE status = 'published'
ORDER BY featured DESC, created_at DESC LIMIT 50`
);
const booksJSON = JSON.stringify(rows);
const html = renderShell({
return c.html(renderIframeShell({
title: `${spaceSlug} — Library | rSpace`,
moduleId: "books",
spaceSlug,
body: `
<folk-book-shelf id="shelf"></folk-book-shelf>
`,
modules: getModuleInfoList(),
theme: "dark",
head: `<link rel="stylesheet" href="/modules/books/books.css">`,
scripts: `
<script type="module">
import { FolkBookShelf } from '/modules/books/folk-book-shelf.js';
const shelf = document.getElementById('shelf');
shelf.books = ${booksJSON};
shelf.spaceSlug = '${spaceSlug}';
</script>
`,
});
return c.html(html);
standaloneDomain: "rbooks.online",
}));
});
// ── Page: Book reader ──

View File

@ -9,7 +9,7 @@ import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
@ -375,15 +375,13 @@ routes.get("/api/context/:tool", async (c) => {
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
return c.html(renderIframeShell({
title: `${space} — Calendar | rSpace`,
moduleId: "cal",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/cal/cal.css">`,
body: `<folk-calendar-view space="${space}"></folk-calendar-view>`,
scripts: `<script type="module" src="/modules/cal/folk-calendar-view.js"></script>`,
standaloneDomain: "rcal.online",
}));
});

View File

@ -10,7 +10,7 @@ import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import { depositOrderRevenue } from "./flow";
import type { RSpaceModule } from "../../shared/module";
@ -441,15 +441,13 @@ routes.post("/api/fulfill/resolve", async (c) => {
// ── Page route: shop ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
return c.html(renderIframeShell({
title: `Shop | rSpace`,
moduleId: "cart",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/cart/cart.css">`,
body: `<folk-cart-shop></folk-cart-shop>`,
scripts: `<script type="module" src="/modules/cart/folk-cart-shop.js"></script>`,
standaloneDomain: "rcart.online",
}));
});

View File

@ -9,7 +9,7 @@
*/
import { Hono } from "hono";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import type { RSpaceModule } from "../../shared/module";
import { getModuleInfoList } from "../../shared/module";
import { getDocumentData } from "../../server/community-store";
@ -48,15 +48,13 @@ routes.get("/api/choices", async (c) => {
// GET / — choices page
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
return c.html(renderIframeShell({
title: `${spaceSlug} — Choices | rSpace`,
moduleId: "choices",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/choices/choices.css">`,
body: `<folk-choices-dashboard space="${spaceSlug}"></folk-choices-dashboard>`,
scripts: `<script type="module" src="/modules/choices/folk-choices-dashboard.js"></script>`,
standaloneDomain: "rchoices.online",
}));
});

View File

@ -6,7 +6,7 @@
*/
import { Hono } from "hono";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
@ -120,15 +120,13 @@ routes.post("/api/collect", async (c) => {
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
return c.html(renderIframeShell({
title: `${space} — Data | rSpace`,
moduleId: "data",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/data/data.css">`,
body: `<folk-analytics-view space="${space}"></folk-analytics-view>`,
scripts: `<script type="module" src="/modules/data/folk-analytics-view.js"></script>`,
standaloneDomain: "rdata.online",
}));
});

View File

@ -9,7 +9,7 @@ import { resolve } from "node:path";
import { mkdir, writeFile, unlink } from "node:fs/promises";
import { createHash, randomBytes } from "node:crypto";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
@ -366,15 +366,13 @@ routes.delete("/api/cards/:id", async (c) => {
// ── Page route ──
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
return c.html(renderIframeShell({
title: `${spaceSlug} — Files | rSpace`,
moduleId: "files",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/files/files.css">`,
body: `<folk-file-browser space="${spaceSlug}"></folk-file-browser>`,
scripts: `<script type="module" src="/modules/files/folk-file-browser.js"></script>`,
standaloneDomain: "rfiles.online",
}));
});

View File

@ -7,7 +7,7 @@ import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import { provisionInstance, destroyInstance } from "./lib/provisioner";
import type { RSpaceModule } from "../../shared/module";
@ -157,15 +157,13 @@ routes.get("/api/health", (c) => {
// ── Page route ──
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
return c.html(renderIframeShell({
title: `${spaceSlug} — Forum | rSpace`,
moduleId: "forum",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/forum/forum.css">`,
body: `<folk-forum-dashboard space="${spaceSlug}"></folk-forum-dashboard>`,
scripts: `<script type="module" src="/modules/forum/folk-forum-dashboard.js"></script>`,
standaloneDomain: "rforum.online",
}));
});

View File

@ -8,7 +8,7 @@ import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import type { RSpaceModule } from "../../shared/module";
import { getModuleInfoList } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
@ -193,18 +193,16 @@ const fundsScripts = `
const fundsStyles = `<link rel="stylesheet" href="/modules/funds/funds.css">`;
// Landing page — TBFF info + flow list
// Landing page — iframes the standalone rfunds.online app
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
return c.html(renderIframeShell({
title: `rFunds — TBFF Flow Funding | rSpace`,
moduleId: "funds",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
styles: fundsStyles,
body: `<folk-funds-app space="${spaceSlug}"></folk-funds-app>`,
scripts: fundsScripts,
standaloneDomain: "rfunds.online",
}));
});

View File

@ -9,7 +9,7 @@ import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
@ -530,15 +530,13 @@ runSyncLoop();
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
return c.html(renderIframeShell({
title: `${space} — Inbox | rSpace`,
moduleId: "inbox",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/inbox/inbox.css">`,
body: `<folk-inbox-client space="${space}"></folk-inbox-client>`,
scripts: `<script type="module" src="/modules/inbox/folk-inbox-client.js"></script>`,
standaloneDomain: "rinbox.online",
}));
});

View File

@ -7,7 +7,7 @@
*/
import { Hono } from "hono";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
@ -133,15 +133,13 @@ routes.get("/api/c3nav/:event", async (c) => {
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
return c.html(renderIframeShell({
title: `${space} — Maps | rSpace`,
moduleId: "maps",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/maps/maps.css">`,
body: `<folk-map-viewer space="${space}"></folk-map-viewer>`,
scripts: `<script type="module" src="/modules/maps/folk-map-viewer.js"></script>`,
standaloneDomain: "rmaps.online",
}));
});

View File

@ -7,7 +7,7 @@
*/
import { Hono } from "hono";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
@ -216,15 +216,13 @@ routes.get("/api/workspaces", (c) => {
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
return c.html(renderIframeShell({
title: `${space} — Network | rSpace`,
moduleId: "network",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/network/network.css">`,
body: `<folk-graph-viewer space="${space}"></folk-graph-viewer>`,
scripts: `<script type="module" src="/modules/network/folk-graph-viewer.js"></script>`,
standaloneDomain: "rnetwork.online",
}));
});

View File

@ -9,7 +9,7 @@ import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
@ -361,15 +361,13 @@ routes.delete("/api/notes/:id", async (c) => {
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
return c.html(renderIframeShell({
title: `${space} — Notes | rSpace`,
moduleId: "notes",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/notes/notes.css">`,
body: `<folk-notes-app space="${space}"></folk-notes-app>`,
scripts: `<script type="module" src="/modules/notes/folk-notes-app.js"></script>`,
standaloneDomain: "rnotes.online",
}));
});

View File

@ -7,7 +7,7 @@
*/
import { Hono } from "hono";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
@ -108,15 +108,13 @@ routes.get("/api/assets/:id/original", async (c) => {
// ── Page route ──
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
return c.html(renderIframeShell({
title: `${spaceSlug} — Photos | rSpace`,
moduleId: "photos",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/photos/photos.css">`,
body: `<folk-photo-gallery space="${spaceSlug}"></folk-photo-gallery>`,
scripts: `<script type="module" src="/modules/photos/folk-photo-gallery.js"></script>`,
standaloneDomain: "rphotos.online",
}));
});

View File

@ -9,7 +9,7 @@ import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
@ -348,15 +348,13 @@ routes.delete("/api/providers/:id", async (c) => {
// ── Page route: browse providers ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
return c.html(renderIframeShell({
title: `Providers | rSpace`,
moduleId: "providers",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/providers/providers.css">`,
body: `<folk-provider-directory></folk-provider-directory>`,
scripts: `<script type="module" src="/modules/providers/folk-provider-directory.js"></script>`,
standaloneDomain: "providers.mycofi.earth",
}));
});

View File

@ -13,7 +13,7 @@ import { parseMarkdown } from "./parse-document";
import { compileDocument } from "./typst-compile";
import { getFormat, FORMATS, listFormats } from "./formats";
import type { BookFormat } from "./formats";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
@ -319,32 +319,16 @@ routes.get("/api/artifact/:id/pdf", async (c) => {
});
// ── Page: Editor ──
routes.get("/", async (c) => {
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "personal";
const formatsJSON = JSON.stringify(listFormats());
const html = renderShell({
return c.html(renderIframeShell({
title: `${spaceSlug} — rPubs Editor | rSpace`,
moduleId: "pubs",
spaceSlug,
body: `
<folk-pubs-editor id="editor"></folk-pubs-editor>
`,
modules: getModuleInfoList(),
theme: "dark",
head: `<link rel="stylesheet" href="/modules/pubs/pubs.css">`,
scripts: `
<script type="module">
import { FolkPubsEditor } from '/modules/pubs/folk-pubs-editor.js';
const editor = document.getElementById('editor');
editor.formats = ${formatsJSON};
editor.spaceSlug = '${escapeAttr(spaceSlug)}';
</script>
`,
});
return c.html(html);
standaloneDomain: "rpubs.online",
}));
});
// ── Module export ──

View File

@ -9,7 +9,7 @@ import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
@ -253,15 +253,13 @@ routes.get("/routes", (c) => {
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
return c.html(renderIframeShell({
title: `${space} — Trips | rSpace`,
moduleId: "trips",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/trips/trips.css">`,
body: `<folk-trips-planner space="${space}"></folk-trips-planner>`,
scripts: `<script type="module" src="/modules/trips/folk-trips-planner.js"></script>`,
standaloneDomain: "rtrips.online",
}));
});

View File

@ -6,7 +6,7 @@
*/
import { Hono } from "hono";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
@ -191,15 +191,13 @@ routes.get("/api/health", (c) => c.json({ ok: true }));
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
return c.html(renderIframeShell({
title: `${space} — Tube | rSpace`,
moduleId: "tube",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/tube/tube.css">`,
body: `<folk-video-player space="${space}"></folk-video-player>`,
scripts: `<script type="module" src="/modules/tube/folk-video-player.js"></script>`,
standaloneDomain: "rtube.online",
}));
});

View File

@ -9,7 +9,7 @@ import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
@ -327,15 +327,13 @@ routes.post("/api/proposals/:id/final-vote", async (c) => {
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
return c.html(renderIframeShell({
title: `${space} — Vote | rSpace`,
moduleId: "vote",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/vote/vote.css">`,
body: `<folk-vote-dashboard space="${space}"></folk-vote-dashboard>`,
scripts: `<script type="module" src="/modules/vote/folk-vote-dashboard.js"></script>`,
standaloneDomain: "rvote.online",
}));
});

View File

@ -6,7 +6,7 @@
*/
import { Hono } from "hono";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
@ -94,15 +94,13 @@ function getSafePrefix(chainId: string): string | null {
// ── Page route ──
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
return c.html(renderIframeShell({
title: `${spaceSlug} — Wallet | rSpace`,
moduleId: "wallet",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/wallet/wallet.css">`,
body: `<folk-wallet-viewer space="${spaceSlug}"></folk-wallet-viewer>`,
scripts: `<script type="module" src="/modules/wallet/folk-wallet-viewer.js"></script>`,
standaloneDomain: "rwallet.online",
}));
});

View File

@ -9,7 +9,7 @@ import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { renderShell, renderIframeShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
@ -217,15 +217,13 @@ routes.get("/api/spaces/:slug/activity", async (c) => {
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
return c.html(renderIframeShell({
title: `${space} — Work | rSpace`,
moduleId: "work",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/work/work.css">`,
body: `<folk-work-board space="${space}"></folk-work-board>`,
scripts: `<script type="module" src="/modules/work/folk-work-board.js"></script>`,
standaloneDomain: "rwork.online",
}));
});

View File

@ -411,6 +411,81 @@ const WELCOME_CSS = `
}
`;
/**
* Shell that embeds a standalone app via iframe.
*
* Wraps the independent app's domain in the shared rSpace header/nav
* so users get the latest code from the standalone repo while preserving
* the unified space/identity experience.
*/
export interface IframeShellOptions extends Omit<ShellOptions, "body" | "scripts" | "styles"> {
/** The standalone app domain, e.g. "rvote.online" */
standaloneDomain: string;
/** Extra path to append after the domain root (default: "") */
path?: string;
}
export function renderIframeShell(opts: IframeShellOptions): string {
const { standaloneDomain, path = "", ...shellOpts } = opts;
const iframeSrc = `https://${standaloneDomain}${path}`;
return renderShell({
...shellOpts,
body: `<iframe id="rspace-module-frame"
src="${escapeAttr(iframeSrc)}"
class="rspace-iframe"
allow="camera;microphone;fullscreen;autoplay;clipboard-write;web-share"
loading="lazy"></iframe>`,
styles: `<style>
#app.iframe-layout {
padding-top: 92px;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.rspace-iframe {
flex: 1;
width: 100%;
border: none;
background: #0a0a0a;
}
</style>`,
scripts: `<script type="module">
document.getElementById('app')?.classList.add('iframe-layout');
// Identity bridge: forward EncryptID session to the embedded app
const frame = document.getElementById('rspace-module-frame');
if (frame) {
frame.addEventListener('load', () => {
try {
const raw = localStorage.getItem('encryptid_session');
if (raw) {
frame.contentWindow?.postMessage({
type: 'rspace:identity',
session: JSON.parse(raw),
space: '${escapeAttr(shellOpts.spaceSlug)}',
module: '${escapeAttr(shellOpts.moduleId)}',
}, 'https://${escapeAttr(standaloneDomain)}');
}
} catch(e) {}
});
// Listen for navigation messages from the iframe
window.addEventListener('message', (e) => {
if (e.origin !== 'https://${escapeAttr(standaloneDomain)}') return;
if (e.data?.type === 'rspace:navigate') {
const { space, module } = e.data;
if (space && module && window.__rspaceNavUrl) {
window.location.href = window.__rspaceNavUrl(space, module);
}
}
});
}
</script>`,
});
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

View File

@ -577,6 +577,9 @@
<rstack-identity></rstack-identity>
</div>
</header>
<div class="rstack-tab-row" data-theme="light">
<rstack-tab-bar space="" active="" view-mode="flat"></rstack-tab-bar>
</div>
<div id="community-info">
<h2 id="community-name">Loading...</h2>
<p id="community-slug"></p>
@ -654,6 +657,30 @@
</div>
</div>
<div class="toolbar-group">
<button class="toolbar-group-toggle">📱 rApps</button>
<div class="toolbar-dropdown">
<button id="embed-notes" title="Embed rNotes">📝 rNotes</button>
<button id="embed-photos" title="Embed rPhotos">📸 rPhotos</button>
<button id="embed-books" title="Embed rBooks">📚 rBooks</button>
<button id="embed-pubs" title="Embed rPubs">📖 rPubs</button>
<button id="embed-files" title="Embed rFiles">📁 rFiles</button>
<button id="embed-work" title="Embed rWork">📋 rWork</button>
<button id="embed-forum" title="Embed rForum">💬 rForum</button>
<button id="embed-inbox" title="Embed rInbox">📧 rInbox</button>
<button id="embed-tube" title="Embed rTube">🎬 rTube</button>
<button id="embed-funds" title="Embed rFunds">🌊 rFunds</button>
<button id="embed-wallet" title="Embed rWallet">💰 rWallet</button>
<button id="embed-vote" title="Embed rVote">🗳️ rVote</button>
<button id="embed-cart" title="Embed rCart">🛒 rCart</button>
<button id="embed-data" title="Embed rData">📊 rData</button>
<button id="embed-network" title="Embed rNetwork">🌍 rNetwork</button>
<button id="embed-splat" title="Embed rSplat">🔮 rSplat</button>
<button id="embed-providers" title="Embed rProviders">🏭 rProviders</button>
<button id="embed-swag" title="Embed rSwag">🎨 rSwag</button>
</div>
</div>
<span class="toolbar-sep"></span>
<button id="new-arrow" title="Connect rSpaces">↗️ Connect</button>
@ -724,6 +751,7 @@
import { RStackAppSwitcher } from "@shared/components/rstack-app-switcher";
import { RStackSpaceSwitcher } from "@shared/components/rstack-space-switcher";
import { RStackTabBar } from "@shared/components/rstack-tab-bar";
import { rspaceNavUrl } from "@shared/url-helpers";
// Register shell header components
RStackIdentity.define();
@ -736,6 +764,55 @@
document.querySelector("rstack-app-switcher")?.setModules(data.modules || []);
}).catch(() => {});
// ── Tab bar / Layer system initialization ──
const tabBar = document.querySelector("rstack-tab-bar");
if (tabBar) {
const canvasDefaultLayer = {
id: "layer-canvas",
moduleId: "canvas",
label: "rSpace",
order: 0,
color: "",
visible: true,
createdAt: Date.now(),
};
tabBar.setLayers([canvasDefaultLayer]);
tabBar.setAttribute("active", canvasDefaultLayer.id);
// 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
window.location.href = rspaceNavUrl(
document.querySelector("rstack-space-switcher")?.getAttribute("current") || "demo",
moduleId
);
});
// Adding a new tab: navigate to that module
tabBar.addEventListener("layer-add", (e) => {
const { moduleId } = e.detail;
window.location.href = rspaceNavUrl(
document.querySelector("rstack-space-switcher")?.getAttribute("current") || "demo",
moduleId
);
});
// Closing a tab
tabBar.addEventListener("layer-close", (e) => {
tabBar.removeLayer(e.detail.layerId);
});
// View mode toggle
tabBar.addEventListener("view-toggle", (e) => {
document.dispatchEvent(new CustomEvent("layer-view-mode", { detail: { mode: e.detail.mode } }));
});
// Expose for CommunitySync wiring
window.__rspaceTabBar = tabBar;
}
// Register service worker for offline support
if ("serviceWorker" in navigator && window.location.hostname !== "localhost") {
navigator.serviceWorker.register("/sw.js").then((reg) => {
@ -807,6 +884,11 @@
spaceSwitcher.setAttribute("name", communitySlug);
}
// Update tab bar with resolved space slug
if (tabBar) {
tabBar.setAttribute("space", communitySlug);
}
const canvas = document.getElementById("canvas");
const canvasContent = document.getElementById("canvas-content");
const status = document.getElementById("status");
@ -844,6 +926,94 @@
detail: { sync, communitySlug }
}));
// Wire tab bar to CommunitySync for layer persistence
if (tabBar && sync) {
const canvasDefaultLayer = {
id: "layer-canvas",
moduleId: "canvas",
label: "rSpace",
order: 0,
color: "",
visible: true,
createdAt: Date.now(),
};
// Load persisted layers from Automerge
const layers = sync.getLayers?.() || [];
if (layers.length > 0) {
tabBar.setLayers(layers);
const activeId = sync.doc?.activeLayerId;
if (activeId) tabBar.setAttribute("active", activeId);
if (sync.getFlows) tabBar.setFlows(sync.getFlows());
} else {
// First visit: persist the canvas layer
sync.addLayer?.(canvasDefaultLayer);
sync.setActiveLayer?.(canvasDefaultLayer.id);
}
// Persist layer switch
tabBar.addEventListener("layer-switch", (e) => {
sync.setActiveLayer?.(e.detail.layerId);
});
// Persist new layer
tabBar.addEventListener("layer-add", (e) => {
const { moduleId } = e.detail;
sync.addLayer?.({
id: "layer-" + moduleId,
moduleId,
label: moduleId,
order: (sync.getLayers?.() || []).length,
color: "",
visible: true,
createdAt: Date.now(),
});
});
// Persist layer close
tabBar.addEventListener("layer-close", (e) => {
sync.removeLayer?.(e.detail.layerId);
});
// Persist layer reorder
tabBar.addEventListener("layer-reorder", (e) => {
const { layerId, newIndex } = e.detail;
sync.updateLayer?.(layerId, { order: newIndex });
const allLayers = sync.getLayers?.() || [];
allLayers.forEach((l, i) => {
if (l.order !== i) sync.updateLayer?.(l.id, { order: i });
});
});
// Flow creation from stack view
tabBar.addEventListener("flow-create", (e) => {
sync.addFlow?.(e.detail.flow);
});
// Flow removal from stack view
tabBar.addEventListener("flow-remove", (e) => {
sync.removeFlow?.(e.detail.flowId);
});
// View mode persistence
tabBar.addEventListener("view-toggle", (e) => {
sync.setLayerViewMode?.(e.detail.mode);
});
// Sync remote layer/flow changes back to tab bar
sync.addEventListener("change", () => {
const updatedLayers = sync.getLayers?.() || [];
if (updatedLayers.length > 0) {
tabBar.setLayers(updatedLayers);
if (sync.getFlows) tabBar.setFlows(sync.getFlows());
const activeId = sync.doc?.activeLayerId;
if (activeId) tabBar.setAttribute("active", activeId);
const viewMode = sync.doc?.layerViewMode;
if (viewMode) tabBar.setAttribute("view-mode", viewMode);
}
});
}
// Initialize Presence for real-time cursors
const peerId = generatePeerId();
const storedUsername = localStorage.getItem("rspace-username") || `User ${peerId.slice(0, 4)}`;
@ -1468,6 +1638,38 @@
});
});
// rApp embed buttons — embed any module as an interactive iframe 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" },
];
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 });
});
}
}
// Feed shape — pull live data from another layer/module
document.getElementById("new-feed").addEventListener("click", () => {
// Prompt for source module (simple for now — will get a proper UI)