feat: embed external apps via iframe in rSpace shell

Add ?view=app iframe integration for 4 existing modules (rNetwork→Twenty CRM,
rSocials→Postiz, rForum→Discourse, rFiles→Seafile) and 2 new modules
(rDocs→Docmost, rDesign→Affine). Each module shows its demo view by default
with an "Open Full App" button to switch to the iframe-embedded external app.

Also includes: splat demo data seeding, MI search bar mobile layout fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-28 08:56:08 +00:00
parent e55e56bc06
commit db078d3152
12 changed files with 454 additions and 17 deletions

60
modules/design/mod.ts Normal file
View File

@ -0,0 +1,60 @@
/**
* Design module collaborative design workspace via Affine.
*
* Wraps the Affine instance as an external app embedded in the rSpace shell.
*/
import { Hono } from "hono";
import { renderShell, renderExternalAppShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
const routes = new Hono();
const AFFINE_URL = "https://affine.cosmolocal.world";
routes.get("/api/health", (c) => {
return c.json({ ok: true, module: "rdesign" });
});
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const view = c.req.query("view");
if (view === "demo") {
return c.html(renderShell({
title: `${space} — Design | rSpace`,
moduleId: "rdesign",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<div style="max-width:640px;margin:0 auto;padding:3rem 1rem;text-align:center">
<div style="font-size:3rem;margin-bottom:1rem">🎯</div>
<h2 style="font-size:1.5rem;margin-bottom:0.75rem;background:linear-gradient(135deg,#14b8a6,#22d3ee);-webkit-background-clip:text;-webkit-text-fill-color:transparent">rDesign</h2>
<p style="color:#94a3b8;margin-bottom:2rem;line-height:1.6">Collaborative design workspace powered by Affine. Whiteboard, docs, and kanban all in one tool for your community.</p>
<a href="?" class="rapp-nav__btn--app-toggle" style="display:inline-block;padding:10px 24px;font-size:0.9rem">Open Affine</a>
</div>`,
}));
}
// Default: show the external app directly
return c.html(renderExternalAppShell({
title: `${space} — Affine | rSpace`,
moduleId: "rdesign",
spaceSlug: space,
modules: getModuleInfoList(),
appUrl: AFFINE_URL,
appName: "Affine",
theme: "dark",
}));
});
export const designModule: RSpaceModule = {
id: "rdesign",
name: "rDesign",
icon: "🎯",
description: "Collaborative design workspace with whiteboard and docs",
routes,
standaloneDomain: "rdesign.online",
externalApp: { url: AFFINE_URL, name: "Affine" },
};

60
modules/docs/mod.ts Normal file
View File

@ -0,0 +1,60 @@
/**
* Docs module collaborative documentation via Docmost.
*
* Wraps the Docmost instance as an external app embedded in the rSpace shell.
*/
import { Hono } from "hono";
import { renderShell, renderExternalAppShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
const routes = new Hono();
const DOCMOST_URL = "https://docs.cosmolocal.world";
routes.get("/api/health", (c) => {
return c.json({ ok: true, module: "rdocs" });
});
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const view = c.req.query("view");
if (view === "demo") {
return c.html(renderShell({
title: `${space} — Docs | rSpace`,
moduleId: "rdocs",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<div style="max-width:640px;margin:0 auto;padding:3rem 1rem;text-align:center">
<div style="font-size:3rem;margin-bottom:1rem">📝</div>
<h2 style="font-size:1.5rem;margin-bottom:0.75rem;background:linear-gradient(135deg,#14b8a6,#22d3ee);-webkit-background-clip:text;-webkit-text-fill-color:transparent">rDocs</h2>
<p style="color:#94a3b8;margin-bottom:2rem;line-height:1.6">Collaborative documentation powered by Docmost. Create wikis, knowledge bases, and shared documents for your community.</p>
<a href="?" class="rapp-nav__btn--app-toggle" style="display:inline-block;padding:10px 24px;font-size:0.9rem">Open Docmost</a>
</div>`,
}));
}
// Default: show the external app directly
return c.html(renderExternalAppShell({
title: `${space} — Docmost | rSpace`,
moduleId: "rdocs",
spaceSlug: space,
modules: getModuleInfoList(),
appUrl: DOCMOST_URL,
appName: "Docmost",
theme: "dark",
}));
});
export const docsModule: RSpaceModule = {
id: "rdocs",
name: "rDocs",
icon: "📝",
description: "Collaborative documentation and knowledge base",
routes,
standaloneDomain: "rdocs.online",
externalApp: { url: DOCMOST_URL, name: "Docmost" },
};

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, renderExternalAppShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
@ -367,13 +367,28 @@ routes.delete("/api/cards/:id", async (c) => {
// ── Page route ──
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
const view = c.req.query("view");
if (view === "app") {
return c.html(renderExternalAppShell({
title: `${spaceSlug} — Seafile | rSpace`,
moduleId: "rfiles",
spaceSlug,
modules: getModuleInfoList(),
appUrl: "https://files.rfiles.online",
appName: "Seafile",
theme: "dark",
}));
}
return c.html(renderShell({
title: `${spaceSlug} — Files | rSpace`,
moduleId: "rfiles",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-file-browser space="${spaceSlug}"></folk-file-browser>`,
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
<folk-file-browser space="${spaceSlug}"></folk-file-browser>`,
scripts: `<script type="module" src="/modules/files/folk-file-browser.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/files/files.css">`,
}));
@ -387,6 +402,7 @@ export const filesModule: RSpaceModule = {
routes,
landingPage: renderLanding,
standaloneDomain: "rfiles.online",
externalApp: { url: "https://files.rfiles.online", name: "Seafile" },
feeds: [
{
id: "file-activity",

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, renderExternalAppShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import { provisionInstance, destroyInstance } from "./lib/provisioner";
import type { RSpaceModule } from "../../shared/module";
@ -158,13 +158,28 @@ routes.get("/api/health", (c) => {
// ── Page route ──
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
const view = c.req.query("view");
if (view === "app") {
return c.html(renderExternalAppShell({
title: `${spaceSlug} — Discourse | rSpace`,
moduleId: "rforum",
spaceSlug,
modules: getModuleInfoList(),
appUrl: "https://commons.rforum.online",
appName: "Discourse",
theme: "dark",
}));
}
return c.html(renderShell({
title: `${spaceSlug} — Forum | rSpace`,
moduleId: "rforum",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-forum-dashboard space="${spaceSlug}"></folk-forum-dashboard>`,
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
<folk-forum-dashboard space="${spaceSlug}"></folk-forum-dashboard>`,
scripts: `<script type="module" src="/modules/forum/folk-forum-dashboard.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/forum/forum.css">`,
}));
@ -178,6 +193,7 @@ export const forumModule: RSpaceModule = {
routes,
landingPage: renderLanding,
standaloneDomain: "rforum.online",
externalApp: { url: "https://commons.rforum.online", name: "Discourse" },
feeds: [
{
id: "threads",

View File

@ -7,7 +7,7 @@
*/
import { Hono } from "hono";
import { renderShell } from "../../server/shell";
import { renderShell, renderExternalAppShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { renderLanding } from "./landing";
@ -217,13 +217,28 @@ routes.get("/api/workspaces", (c) => {
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const view = c.req.query("view");
if (view === "app") {
return c.html(renderExternalAppShell({
title: `${space} — Twenty CRM | rSpace`,
moduleId: "rnetwork",
spaceSlug: space,
modules: getModuleInfoList(),
appUrl: "https://demo.rnetwork.online",
appName: "Twenty CRM",
theme: "dark",
}));
}
return c.html(renderShell({
title: `${space} — Network | rSpace`,
moduleId: "rnetwork",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-graph-viewer space="${space}"></folk-graph-viewer>`,
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
<folk-graph-viewer space="${space}"></folk-graph-viewer>`,
scripts: `<script type="module" src="/modules/network/folk-graph-viewer.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/network/network.css">`,
}));
@ -237,6 +252,7 @@ export const networkModule: RSpaceModule = {
routes,
landingPage: renderLanding,
standaloneDomain: "rnetwork.online",
externalApp: { url: "https://demo.rnetwork.online", name: "Twenty CRM" },
feeds: [
{
id: "trust-graph",

View File

@ -6,7 +6,7 @@
*/
import { Hono } from "hono";
import { renderShell } from "../../server/shell";
import { renderShell, renderExternalAppShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { renderLanding } from "./landing";
@ -153,7 +153,10 @@ function renderDemoFeedHTML(): string {
return `
<div class="rsocials-app rsocials-demo">
<div class="rsocials-header">
<h2>Social Feed <span class="rsocials-demo-badge">DEMO</span></h2>
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem">
<h2>Social Feed <span class="rsocials-demo-badge">DEMO</span></h2>
<a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a>
</div>
<p class="rsocials-subtitle">A preview of your community's social timeline</p>
</div>
<div class="rsocials-feed">
@ -166,6 +169,20 @@ function renderDemoFeedHTML(): string {
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const view = c.req.query("view");
if (view === "app") {
return c.html(renderExternalAppShell({
title: `${space} — Postiz | rSpace`,
moduleId: "rsocials",
spaceSlug: space,
modules: getModuleInfoList(),
appUrl: "https://social.jeffemmett.com",
appName: "Postiz",
theme: "dark",
}));
}
const isDemo = space === "demo";
const body = isDemo
@ -310,6 +327,7 @@ export const socialsModule: RSpaceModule = {
routes,
standaloneDomain: "rsocials.online",
landingPage: renderLanding,
externalApp: { url: "https://social.jeffemmett.com", name: "Postiz" },
feeds: [
{
id: "social-feed",

View File

@ -54,10 +54,22 @@ export class FolkSplatViewer extends HTMLElement {
if (this._mode === "viewer") {
this.renderViewer();
} else {
if (this._spaceSlug === "demo") this.loadDemoData();
this.renderGallery();
}
}
private loadDemoData() {
this._splats = [
{ id: "s1", slug: "matterhorn-scan", title: "Matterhorn Summit", description: "Photogrammetry capture of the Matterhorn peak from drone footage, 42 source images.", file_format: "splat", file_size_bytes: 18_874_368, view_count: 284, contributor_name: "Alpine Explorer Team", processing_status: "ready", created_at: "2026-02-10" },
{ id: "s2", slug: "community-garden", title: "Community Garden Plot", description: "3D scan of the shared garden space — beds, paths, tool shed.", file_format: "ply", file_size_bytes: 24_117_248, view_count: 156, contributor_name: "Garden Collective", processing_status: "ready", created_at: "2026-02-15" },
{ id: "s3", slug: "print-shop-interior", title: "Print Shop Interior", description: "Interior scan of Druckwerkstatt Berlin — letterpress, risograph, binding station.", file_format: "spz", file_size_bytes: 31_457_280, view_count: 93, contributor_name: "Druckwerkstatt", processing_status: "ready", created_at: "2026-02-18" },
{ id: "s4", slug: "chamonix-trailhead", title: "Chamonix Trailhead", description: "360° capture of the Lac Blanc trailhead parking area and signage.", file_format: "splat", file_size_bytes: 12_582_912, view_count: 67, processing_status: "ready", created_at: "2026-02-20" },
{ id: "s5", slug: "zermatt-bridge", title: "Zermatt Suspension Bridge", description: "Charles Kuonen bridge scan from 18 photos. Processing complete.", file_format: "ply", file_size_bytes: 0, view_count: 0, source_file_count: 18, processing_status: "processing", created_at: "2026-02-25" },
{ id: "s6", slug: "mycorrhiza-sculpture", title: "Mycorrhiza Sculpture", description: "Uploaded for 3D reconstruction from video. Queued.", file_format: "splat", file_size_bytes: 0, view_count: 0, source_file_count: 1, processing_status: "pending", created_at: "2026-02-27" },
];
}
disconnectedCallback() {
if (this._viewer) {
try { this._viewer.dispose(); } catch {}

View File

@ -63,6 +63,8 @@ import { dataModule } from "../modules/data/mod";
import { splatModule } from "../modules/splat/mod";
import { photosModule } from "../modules/photos/mod";
import { socialsModule } from "../modules/rsocials/mod";
import { docsModule } from "../modules/docs/mod";
import { designModule } from "../modules/design/mod";
import { spaces } from "./spaces";
import { renderShell, renderModuleLanding } from "./shell";
import { renderMainLanding, renderSpaceDashboard } from "./landing";
@ -94,6 +96,8 @@ registerModule(dataModule);
registerModule(splatModule);
registerModule(photosModule);
registerModule(socialsModule);
registerModule(docsModule);
registerModule(designModule);
// ── Config ──
const PORT = Number(process.env.PORT) || 3000;

View File

@ -54,7 +54,7 @@ export function renderShell(opts: ShellOptions): string {
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
<title>${escapeHtml(title)}</title>
<link rel="stylesheet" href="/shell.css?v=3">
<link rel="stylesheet" href="/shell.css?v=4">
<style>
/* When loaded inside an iframe (e.g. standalone domain redirecting back),
hide the shell chrome the parent rSpace page already provides it. */
@ -94,7 +94,7 @@ export function renderShell(opts: ShellOptions): string {
${renderWelcomeOverlay()}
<script type="module">
import '/shell.js';
import '/shell.js?v=4';
// Provide module list to app switcher
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
@ -312,6 +312,126 @@ export function renderShell(opts: ShellOptions): string {
</html>`;
}
// ── External app iframe shell ──
export interface ExternalAppShellOptions {
/** Page <title> */
title: string;
/** Current module ID */
moduleId: string;
/** Current space slug */
spaceSlug: string;
/** Space display name */
spaceName?: string;
/** List of available modules */
modules: ModuleInfo[];
/** External app URL to embed */
appUrl: string;
/** External app display name */
appName: string;
/** Theme */
theme?: "dark" | "light";
}
export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
const {
title,
moduleId,
spaceSlug,
spaceName,
modules,
appUrl,
appName,
theme = "dark",
} = opts;
const moduleListJSON = JSON.stringify(modules);
const demoUrl = `?view=demo`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
<title>${escapeHtml(title)}</title>
<link rel="stylesheet" href="/shell.css?v=4">
<style>
html.rspace-embedded .rstack-header { display: none !important; }
html.rspace-embedded .rstack-tab-row { display: none !important; }
html.rspace-embedded .rspace-iframe-wrap { top: 0 !important; height: 100vh !important; }
</style>
<script>if (window.self !== window.parent) document.documentElement.classList.add('rspace-embedded');</script>
</head>
<body data-theme="${theme}">
<header class="rstack-header" data-theme="${theme}">
<div class="rstack-header__left">
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
<rstack-app-switcher current="${escapeAttr(moduleId)}"></rstack-app-switcher>
<rstack-space-switcher current="${escapeAttr(spaceSlug)}" name="${escapeAttr(spaceName || spaceSlug)}"></rstack-space-switcher>
</div>
<div class="rstack-header__center">
<rstack-mi></rstack-mi>
</div>
<div class="rstack-header__right">
<a href="${demoUrl}" class="rapp-nav__btn rapp-nav__btn--secondary" style="font-size:0.78rem;padding:5px 12px;">Back to Demo</a>
<rstack-identity></rstack-identity>
</div>
</header>
<div class="rstack-tab-row" data-theme="${theme}">
<rstack-tab-bar space="${escapeAttr(spaceSlug)}" active="" view-mode="flat"></rstack-tab-bar>
</div>
<div class="rspace-iframe-wrap">
<div class="rspace-iframe-loading" id="iframe-loading">
<div class="rspace-iframe-spinner"></div>
<span>Loading ${escapeHtml(appName)}</span>
</div>
<iframe
class="rspace-iframe"
src="${escapeAttr(appUrl)}"
title="${escapeAttr(appName)}"
allow="clipboard-read; clipboard-write; fullscreen"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals"
onload="document.getElementById('iframe-loading').style.display='none'"
></iframe>
<a class="rspace-iframe-newtab" href="${escapeAttr(appUrl)}" target="_blank" rel="noopener">Open in new tab </a>
</div>
<script type="module">
import '/shell.js?v=4';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
const tabBar = document.querySelector('rstack-tab-bar');
const spaceSlug = '${escapeAttr(spaceSlug)}';
const currentModuleId = '${escapeAttr(moduleId)}';
const TABS_KEY = 'rspace_tabs_' + spaceSlug;
const moduleList = ${moduleListJSON};
if (tabBar) {
tabBar.setModules(moduleList);
function getModuleLabel(id) {
const m = moduleList.find(mod => mod.id === id);
return m ? m.name : id;
}
function makeLayer(id, order) {
return { id: 'layer-' + id, moduleId: id, label: getModuleLabel(id), order, color: '', visible: true, createdAt: Date.now() };
}
let layers;
try { const saved = localStorage.getItem(TABS_KEY); layers = saved ? JSON.parse(saved) : []; if (!Array.isArray(layers)) layers = []; } catch(e) { layers = []; }
if (!layers.find(l => l.moduleId === currentModuleId)) layers.push(makeLayer(currentModuleId, layers.length));
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
tabBar.setLayers(layers);
tabBar.setAttribute('active', 'layer-' + currentModuleId);
function saveTabs() { localStorage.setItem(TABS_KEY, JSON.stringify(layers)); }
tabBar.addEventListener('layer-switch', (e) => { saveTabs(); window.location.href = window.__rspaceNavUrl(spaceSlug, e.detail.moduleId); });
tabBar.addEventListener('layer-add', (e) => { const { moduleId } = e.detail; if (!layers.find(l => l.moduleId === moduleId)) layers.push(makeLayer(moduleId, layers.length)); saveTabs(); window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); });
tabBar.addEventListener('layer-close', (e) => { const { layerId } = e.detail; tabBar.removeLayer(layerId); layers = layers.filter(l => l.id !== layerId); saveTabs(); if (layerId === 'layer-' + currentModuleId && layers.length > 0) window.location.href = window.__rspaceNavUrl(spaceSlug, layers[0].moduleId); });
}
</script>
</body>
</html>`;
}
// ── Welcome overlay (quarter-screen popup for first-time visitors on demo) ──
function renderWelcomeOverlay(): string {
@ -461,7 +581,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>${mod.icon}</text></svg>">
<title>${escapeHtml(mod.name)} rSpace</title>
<link rel="stylesheet" href="/shell.css?v=3">
<link rel="stylesheet" href="/shell.css?v=4">
${cssBlock}
<script defer src="https://rdata.online/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script>
</head>
@ -481,7 +601,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
</header>
${bodyContent}
<script type="module">
import '/shell.js';
import '/shell.js?v=4';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
try {
var raw = localStorage.getItem('encryptid_session');

View File

@ -417,7 +417,7 @@ const STYLES = `
:host-context([data-theme="light"]) .mi-tool-chip:hover { background: rgba(0,0,0,0.1); }
@media (max-width: 640px) {
.mi { max-width: 200px; }
.mi-panel { min-width: 300px; left: -60px; }
.mi { max-width: none; width: 100%; }
.mi-panel { min-width: 0; width: 100%; left: 0; right: 0; }
}
`;

View File

@ -35,6 +35,11 @@ export interface RSpaceModule {
hidden?: boolean;
/** Optional: render rich landing page body HTML */
landingPage?: () => string;
/** Optional: external app to embed via iframe when ?view=app */
externalApp?: {
url: string;
name: string;
};
}
/** Registry of all loaded modules */
@ -63,6 +68,10 @@ export interface ModuleInfo {
acceptsFeeds?: FlowKind[];
hidden?: boolean;
hasLandingPage?: boolean;
externalApp?: {
url: string;
name: string;
};
}
export function getModuleInfoList(): ModuleInfo[] {
@ -77,5 +86,6 @@ export function getModuleInfoList(): ModuleInfo[] {
...(m.feeds ? { feeds: m.feeds } : {}),
...(m.acceptsFeeds ? { acceptsFeeds: m.acceptsFeeds } : {}),
...(m.landingPage ? { hasLandingPage: true } : {}),
...(m.externalApp ? { externalApp: m.externalApp } : {}),
}));
}

View File

@ -229,6 +229,88 @@ body[data-theme="light"] {
padding: 2px 8px;
}
/* ── Iframe embed for external apps ── */
.rspace-iframe-wrap {
position: fixed;
top: 92px; /* header 56px + tab row 36px */
left: 0;
right: 0;
bottom: 0;
z-index: 1;
}
.rspace-iframe {
width: 100%;
height: 100%;
border: none;
background: #0f172a;
}
.rspace-iframe-loading {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: #0f172a;
color: #94a3b8;
font-size: 0.9rem;
z-index: 2;
}
.rspace-iframe-spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(255,255,255,0.1);
border-top-color: #14b8a6;
border-radius: 50%;
animation: rspace-spin 0.8s linear infinite;
}
@keyframes rspace-spin {
to { transform: rotate(360deg); }
}
.rspace-iframe-newtab {
position: absolute;
bottom: 12px;
right: 16px;
font-size: 0.72rem;
color: #64748b;
text-decoration: none;
padding: 4px 10px;
border-radius: 4px;
background: rgba(15, 23, 42, 0.85);
border: 1px solid rgba(255,255,255,0.08);
z-index: 3;
transition: color 0.15s, border-color 0.15s;
}
.rspace-iframe-newtab:hover {
color: #e2e8f0;
border-color: rgba(255,255,255,0.2);
}
/* "Open Full App" button used in module demo views */
.rapp-nav__btn--app-toggle {
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
font-size: 0.78rem;
padding: 5px 14px;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
border: none;
cursor: pointer;
transition: opacity 0.15s;
}
.rapp-nav__btn--app-toggle:hover {
opacity: 0.88;
}
/* ── Mobile adjustments ── */
@media (max-width: 640px) {
@ -243,17 +325,33 @@ body[data-theme="light"] {
gap: 0;
}
.rstack-header__left {
flex: 1;
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
gap: 2px;
}
.rstack-header__center {
order: 3;
flex-basis: 100%;
padding: 4px 0 2px;
justify-content: stretch;
display: flex;
}
.rstack-header__right {
flex-shrink: 0;
flex: 0 0 auto;
gap: 6px;
margin-left: auto;
}
.rstack-header__demo-btn {
padding: 4px 10px;
font-size: 0.72rem;
}
.rstack-header__brand {
font-size: 1rem;
gap: 6px;
}
.rstack-header__logo {
width: 24px;
height: 24px;
}
.rstack-tab-row {
position: sticky;
@ -264,6 +362,13 @@ body[data-theme="light"] {
#app.canvas-layout {
padding-top: 0;
}
.rspace-iframe-wrap {
position: relative;
top: 0;
width: 100%;
height: calc(100vh - 80px);
min-height: 400px;
}
.rapp-nav {
flex-wrap: wrap;
gap: 6px;