feat: unified module system — Phase 0 shell + Phase 1 canvas module

Implement the rSpace module architecture that enables all r-suite apps
to run as modules within a single-origin platform at rspace.online,
while each module can still deploy standalone at its own domain.

Phase 0 — Shell + Module System:
- RSpaceModule interface (shared/module.ts) with routes, metadata, hooks
- Shell HTML renderer (server/shell.ts) for wrapping module content
- Three header web components: rstack-app-switcher, rstack-space-switcher,
  rstack-identity (refactored from rspace-header.ts into Shadow DOM)
- Space registry API (server/spaces.ts) — /api/spaces CRUD
- Hono-based server (server/index.ts) replacing raw Bun.serve fetch handler
  while preserving all WebSocket, API, and subdomain backward compat
- Shared PostgreSQL with per-module schema isolation (rbooks, rcart, etc.)
- Vite multi-entry build: shell.js + shell.css built alongside existing entries
- Module info API: GET /api/modules returns registered module metadata

Phase 1 — Canvas Module:
- modules/canvas/mod.ts exports canvasModule as first RSpaceModule
- Canvas routes mounted at /:space/canvas with shell wrapper
- Fallback serves existing canvas.html for backward compatibility
- /:space redirects to /:space/canvas

URL structure: rspace.online/{space}/{module} (e.g. /demo/canvas)
All existing subdomain routing (*.rspace.online) preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-20 21:54:15 +00:00
parent 7210888aed
commit eed7b2f151
15 changed files with 1766 additions and 552 deletions

View File

@ -24,6 +24,8 @@ WORKDIR /app
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
COPY --from=build /app/server ./server COPY --from=build /app/server ./server
COPY --from=build /app/lib ./lib COPY --from=build /app/lib ./lib
COPY --from=build /app/shared ./shared
COPY --from=build /app/modules ./modules
COPY --from=build /app/package.json . COPY --from=build /app/package.json .
COPY --from=build /encryptid-sdk /encryptid-sdk COPY --from=build /encryptid-sdk /encryptid-sdk

16
db/init.sql Normal file
View File

@ -0,0 +1,16 @@
-- rSpace shared PostgreSQL — per-module schema isolation
-- Each module owns its schema. Modules that don't need a DB skip this.
-- Module schemas (created on init, populated by module migrations)
CREATE SCHEMA IF NOT EXISTS rbooks;
CREATE SCHEMA IF NOT EXISTS rcart;
CREATE SCHEMA IF NOT EXISTS providers;
CREATE SCHEMA IF NOT EXISTS rfiles;
CREATE SCHEMA IF NOT EXISTS rforum;
-- Grant usage to the rspace user
GRANT ALL ON SCHEMA rbooks TO rspace;
GRANT ALL ON SCHEMA rcart TO rspace;
GRANT ALL ON SCHEMA providers TO rspace;
GRANT ALL ON SCHEMA rfiles TO rspace;
GRANT ALL ON SCHEMA rforum TO rspace;

View File

@ -13,21 +13,51 @@ services:
- STORAGE_DIR=/data/communities - STORAGE_DIR=/data/communities
- PORT=3000 - PORT=3000
- INTERNAL_API_KEY=${INTERNAL_API_KEY} - INTERNAL_API_KEY=${INTERNAL_API_KEY}
- DATABASE_URL=postgres://rspace:${POSTGRES_PASSWORD:-rspace}@rspace-db:5432/rspace
depends_on:
rspace-db:
condition: service_healthy
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
# Only handle subdomains (rspace-prod handles main domain) # Main domain — serves landing + path-based routing
- "traefik.http.routers.rspace-canvas.rule=HostRegexp(`{subdomain:[a-z0-9-]+}.rspace.online`) && !Host(`rspace.online`) && !Host(`www.rspace.online`)" - "traefik.http.routers.rspace-main.rule=Host(`rspace.online`)"
- "traefik.http.routers.rspace-main.entrypoints=web"
- "traefik.http.routers.rspace-main.priority=110"
# Subdomains — backward compat for *.rspace.online canvas
- "traefik.http.routers.rspace-canvas.rule=HostRegexp(`{subdomain:[a-z0-9-]+}.rspace.online`) && !Host(`rspace.online`) && !Host(`www.rspace.online`) && !Host(`auth.rspace.online`)"
- "traefik.http.routers.rspace-canvas.entrypoints=web" - "traefik.http.routers.rspace-canvas.entrypoints=web"
- "traefik.http.routers.rspace-canvas.priority=100" - "traefik.http.routers.rspace-canvas.priority=100"
# Service configuration # Service configuration
- "traefik.http.services.rspace-canvas.loadbalancer.server.port=3000" - "traefik.http.services.rspace-online.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik-public" - "traefik.docker.network=traefik-public"
networks: networks:
- traefik-public - traefik-public
- rspace-internal
rspace-db:
image: postgres:16-alpine
container_name: rspace-db
restart: unless-stopped
volumes:
- rspace-pgdata:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
environment:
- POSTGRES_DB=rspace
- POSTGRES_USER=rspace
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-rspace}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rspace"]
interval: 5s
timeout: 3s
retries: 5
networks:
- rspace-internal
volumes: volumes:
rspace-data: rspace-data:
rspace-pgdata:
networks: networks:
traefik-public: traefik-public:
external: true external: true
rspace-internal:

58
modules/canvas/mod.ts Normal file
View File

@ -0,0 +1,58 @@
/**
* Canvas module the collaborative infinite canvas.
*
* This is the original rSpace canvas restructured as an rSpace module.
* Routes are relative to the mount point (/:space/canvas in unified mode,
* / in standalone mode).
*/
import { Hono } from "hono";
import { resolve } from "node:path";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
const DIST_DIR = resolve(import.meta.dir, "../../dist");
const routes = new Hono();
// GET / — serve the canvas page wrapped in shell
routes.get("/", async (c) => {
const spaceSlug = c.req.param("space") || c.req.query("space") || "demo";
// Read the canvas page template from dist
const canvasFile = Bun.file(resolve(DIST_DIR, "canvas-module.html"));
let canvasBody = "";
if (await canvasFile.exists()) {
canvasBody = await canvasFile.text();
} else {
// Fallback: serve full canvas.html directly if module template not built yet
const fallbackFile = Bun.file(resolve(DIST_DIR, "canvas.html"));
if (await fallbackFile.exists()) {
return new Response(fallbackFile, {
headers: { "Content-Type": "text/html" },
});
}
canvasBody = `<div style="padding:2rem;text-align:center;color:#64748b;">Canvas loading...</div>`;
}
const html = renderShell({
title: `${spaceSlug} — Canvas | rSpace`,
moduleId: "canvas",
spaceSlug,
body: canvasBody,
modules: getModuleInfoList(),
theme: "light",
scripts: `<script type="module" src="/canvas-module.js"></script>`,
});
return c.html(html);
});
export const canvasModule: RSpaceModule = {
id: "canvas",
name: "Canvas",
icon: "🎨",
description: "Collaborative infinite canvas",
routes,
};

File diff suppressed because it is too large Load Diff

135
server/shell.ts Normal file
View File

@ -0,0 +1,135 @@
/**
* Shell HTML renderer.
*
* Wraps module content in the shared rSpace layout: header with app/space
* switchers + identity, <main> with module content, shell script + styles.
*
* In standalone mode, modules call renderStandaloneShell() which omits the
* app/space switchers and only includes identity.
*/
import type { ModuleInfo } from "../shared/module";
export interface ShellOptions {
/** Page <title> */
title: string;
/** Current module ID (highlighted in app switcher) */
moduleId: string;
/** Current space slug */
spaceSlug: string;
/** Space display name */
spaceName?: string;
/** Module HTML content to inject into <main> */
body: string;
/** Additional <script type="module"> tags for module-specific JS */
scripts?: string;
/** Additional <link>/<style> tags for module-specific CSS */
styles?: string;
/** List of available modules (for app switcher) */
modules: ModuleInfo[];
/** Theme for the header: 'dark' or 'light' */
theme?: "dark" | "light";
/** Extra <head> content (meta tags, preloads, etc.) */
head?: string;
}
export function renderShell(opts: ShellOptions): string {
const {
title,
moduleId,
spaceSlug,
spaceName,
body,
scripts = "",
styles = "",
modules,
theme = "light",
head = "",
} = opts;
const moduleListJSON = JSON.stringify(modules);
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">
${styles}
${head}
</head>
<body data-theme="${theme}">
<header class="rstack-header" data-theme="${theme}">
<div class="rstack-header__left">
<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__right">
<rstack-identity></rstack-identity>
</div>
</header>
<main id="app">
${body}
</main>
<script type="module">
import '/shell.js';
// Provide module list to app switcher
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
</script>
${scripts}
</body>
</html>`;
}
/** Minimal shell for standalone module deployments (no app/space switcher) */
export function renderStandaloneShell(opts: {
title: string;
body: string;
scripts?: string;
styles?: string;
theme?: "dark" | "light";
head?: string;
}): string {
const { title, body, scripts = "", styles = "", theme = "light", head = "" } = opts;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(title)}</title>
<link rel="stylesheet" href="/shell.css">
${styles}
${head}
</head>
<body data-theme="${theme}">
<header class="rstack-header rstack-header--standalone" data-theme="${theme}">
<div class="rstack-header__left">
<a href="/" class="rstack-header__brand">
<span class="rstack-header__brand-gradient">rSpace</span>
</a>
</div>
<div class="rstack-header__right">
<rstack-identity></rstack-identity>
</div>
</header>
<main id="app">
${body}
</main>
<script type="module">
import '/shell.js';
</script>
${scripts}
</body>
</html>`;
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function escapeAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

136
server/spaces.ts Normal file
View File

@ -0,0 +1,136 @@
/**
* Space registry CRUD for rSpace spaces.
*
* Spaces are stored as Automerge CRDT documents (extending the existing
* community-store pattern). This module provides Hono routes for listing,
* creating, and managing spaces.
*/
import { Hono } from "hono";
import {
communityExists,
createCommunity,
loadCommunity,
getDocumentData,
listCommunities,
} from "./community-store";
import type { SpaceVisibility } from "./community-store";
import {
verifyEncryptIDToken,
extractToken,
} from "@encryptid/sdk/server";
import type { EncryptIDClaims } from "@encryptid/sdk/server";
import { getAllModules } from "../shared/module";
const spaces = new Hono();
// ── List all spaces (public + user's own) ──
spaces.get("/", async (c) => {
const slugs = await listCommunities();
const spacesList = [];
for (const slug of slugs) {
await loadCommunity(slug);
const data = getDocumentData(slug);
if (data?.meta) {
const vis = data.meta.visibility || "public_read";
// Only include public/public_read spaces in the public listing
if (vis === "public" || vis === "public_read") {
spacesList.push({
slug: data.meta.slug,
name: data.meta.name,
visibility: vis,
createdAt: data.meta.createdAt,
});
}
}
}
return c.json({ spaces: spacesList });
});
// ── Create a new space (requires auth) ──
spaces.post("/", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) {
return c.json({ error: "Authentication required" }, 401);
}
let claims: EncryptIDClaims;
try {
claims = await verifyEncryptIDToken(token);
} catch {
return c.json({ error: "Invalid or expired token" }, 401);
}
const body = await c.req.json<{
name?: string;
slug?: string;
visibility?: SpaceVisibility;
}>();
const { name, slug, visibility = "public_read" } = body;
if (!name || !slug) {
return c.json({ error: "Name and slug are required" }, 400);
}
if (!/^[a-z0-9-]+$/.test(slug)) {
return c.json({ error: "Slug must contain only lowercase letters, numbers, and hyphens" }, 400);
}
const validVisibilities: SpaceVisibility[] = ["public", "public_read", "authenticated", "members_only"];
if (!validVisibilities.includes(visibility)) {
return c.json({ error: `Invalid visibility. Must be one of: ${validVisibilities.join(", ")}` }, 400);
}
if (await communityExists(slug)) {
return c.json({ error: "Space already exists" }, 409);
}
await createCommunity(name, slug, claims.sub, visibility);
// Notify all modules about the new space
for (const mod of getAllModules()) {
if (mod.onSpaceCreate) {
try {
await mod.onSpaceCreate(slug);
} catch (e) {
console.error(`[Spaces] Module ${mod.id} onSpaceCreate failed:`, e);
}
}
}
return c.json({
slug,
name,
visibility,
ownerDID: claims.sub,
url: `/${slug}/canvas`,
}, 201);
});
// ── Get space info ──
spaces.get("/:slug", async (c) => {
const slug = c.req.param("slug");
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data) {
return c.json({ error: "Space not found" }, 404);
}
return c.json({
slug: data.meta.slug,
name: data.meta.name,
visibility: data.meta.visibility,
createdAt: data.meta.createdAt,
ownerDID: data.meta.ownerDID,
memberCount: Object.keys(data.members || {}).length,
});
});
export { spaces };

View File

@ -0,0 +1,154 @@
/**
* <rstack-app-switcher> Dropdown to switch between rSpace modules.
*
* Attributes:
* current the active module ID (highlighted)
*
* Methods:
* setModules(list) provide the list of available modules
*/
export interface AppSwitcherModule {
id: string;
name: string;
icon: string;
description: string;
}
export class RStackAppSwitcher extends HTMLElement {
#shadow: ShadowRoot;
#modules: AppSwitcherModule[] = [];
constructor() {
super();
this.#shadow = this.attachShadow({ mode: "open" });
}
static get observedAttributes() {
return ["current"];
}
get current(): string {
return this.getAttribute("current") || "";
}
connectedCallback() {
this.#render();
}
attributeChangedCallback() {
this.#render();
}
setModules(modules: AppSwitcherModule[]) {
this.#modules = modules;
this.#render();
}
#render() {
const current = this.current;
const currentMod = this.#modules.find((m) => m.id === current);
const label = currentMod ? `${currentMod.icon} ${currentMod.name}` : "🌌 rSpace";
this.#shadow.innerHTML = `
<style>${STYLES}</style>
<div class="switcher">
<button class="trigger" id="trigger">${label} <span class="caret"></span></button>
<div class="menu" id="menu">
${this.#modules
.map(
(m) => `
<a class="item ${m.id === current ? "active" : ""}"
href="/${this.#getSpaceSlug()}/${m.id}"
data-id="${m.id}">
<span class="item-icon">${m.icon}</span>
<div class="item-text">
<span class="item-name">${m.name}</span>
<span class="item-desc">${m.description}</span>
</div>
</a>
`
)
.join("")}
</div>
</div>
`;
const trigger = this.#shadow.getElementById("trigger")!;
const menu = this.#shadow.getElementById("menu")!;
trigger.addEventListener("click", (e) => {
e.stopPropagation();
menu.classList.toggle("open");
});
document.addEventListener("click", () => menu.classList.remove("open"));
}
#getSpaceSlug(): string {
// Read from the space switcher or URL
const spaceSwitcher = document.querySelector("rstack-space-switcher");
if (spaceSwitcher) return spaceSwitcher.getAttribute("current") || "personal";
// Fallback: parse from URL (/:space/:module)
const parts = window.location.pathname.split("/").filter(Boolean);
return parts[0] || "personal";
}
static define(tag = "rstack-app-switcher") {
if (!customElements.get(tag)) customElements.define(tag, RStackAppSwitcher);
}
}
const STYLES = `
:host { display: contents; }
.switcher { position: relative; }
.trigger {
display: flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: 8px; border: none;
font-size: 0.9rem; font-weight: 600; cursor: pointer;
transition: background 0.15s;
background: rgba(255,255,255,0.08); color: inherit;
}
:host-context([data-theme="light"]) .trigger {
background: rgba(0,0,0,0.05); color: #0f172a;
}
:host-context([data-theme="dark"]) .trigger {
background: rgba(255,255,255,0.08); color: #e2e8f0;
}
.trigger:hover { background: rgba(255,255,255,0.12); }
:host-context([data-theme="light"]) .trigger:hover { background: rgba(0,0,0,0.08); }
.caret { font-size: 0.7em; opacity: 0.6; }
.menu {
position: absolute; top: 100%; left: 0; margin-top: 6px;
min-width: 260px; border-radius: 12px; overflow: hidden;
box-shadow: 0 8px 30px rgba(0,0,0,0.25); display: none; z-index: 200;
}
.menu.open { display: block; }
:host-context([data-theme="light"]) .menu {
background: white; border: 1px solid rgba(0,0,0,0.1);
}
:host-context([data-theme="dark"]) .menu {
background: #1e293b; border: 1px solid rgba(255,255,255,0.1);
}
.item {
display: flex; align-items: center; gap: 12px;
padding: 10px 14px; text-decoration: none;
transition: background 0.12s; cursor: pointer;
}
:host-context([data-theme="light"]) .item { color: #374151; }
:host-context([data-theme="light"]) .item:hover { background: #f1f5f9; }
:host-context([data-theme="light"]) .item.active { background: #e0f2fe; }
:host-context([data-theme="dark"]) .item { color: #e2e8f0; }
:host-context([data-theme="dark"]) .item:hover { background: rgba(255,255,255,0.05); }
:host-context([data-theme="dark"]) .item.active { background: rgba(6,182,212,0.1); }
.item-icon { font-size: 1.3rem; width: 28px; text-align: center; flex-shrink: 0; }
.item-text { display: flex; flex-direction: column; min-width: 0; }
.item-name { font-size: 0.875rem; font-weight: 600; }
.item-desc { font-size: 0.75rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
`;

View File

@ -0,0 +1,513 @@
/**
* <rstack-identity> Custom element for EncryptID sign-in/sign-out.
*
* Renders either a "Sign In" button or the user avatar + dropdown.
* Contains the full WebAuthn auth modal (sign-in + register).
* Refactored from lib/rspace-header.ts into a standalone web component.
*/
const SESSION_KEY = "encryptid_session";
const ENCRYPTID_URL = "https://auth.rspace.online";
interface SessionState {
accessToken: string;
claims: {
sub: string;
exp: number;
username?: string;
did?: string;
eid: {
authLevel: number;
capabilities: { encrypt: boolean; sign: boolean; wallet: boolean };
};
};
}
// ── Session helpers (exported for use by other code) ──
export function getSession(): SessionState | null {
try {
const stored = localStorage.getItem(SESSION_KEY);
if (!stored) return null;
const session = JSON.parse(stored) as SessionState;
if (Math.floor(Date.now() / 1000) >= session.claims.exp) {
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem("rspace-username");
return null;
}
return session;
} catch {
return null;
}
}
export function clearSession(): void {
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem("rspace-username");
}
export function isAuthenticated(): boolean {
return getSession() !== null;
}
export function getAccessToken(): string | null {
return getSession()?.accessToken ?? null;
}
export function getUserDID(): string | null {
return getSession()?.claims.did ?? getSession()?.claims.sub ?? null;
}
export function getUsername(): string | null {
return getSession()?.claims.username ?? null;
}
// ── Helpers ──
function base64urlToBuffer(b64url: string): ArrayBuffer {
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
const bin = atob(b64 + pad);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes.buffer;
}
function bufferToBase64url(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let bin = "";
for (let i = 0; i < bytes.byteLength; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
function parseJWT(token: string): Record<string, unknown> {
const parts = token.split(".");
if (parts.length < 2) return {};
try {
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
return JSON.parse(atob(b64 + pad));
} catch {
return {};
}
}
function storeSession(token: string, username: string, did: string): void {
const payload = parseJWT(token) as Record<string, any>;
const session: SessionState = {
accessToken: token,
claims: {
sub: payload.sub || "",
exp: payload.exp || 0,
username,
did,
eid: payload.eid || { authLevel: 3, capabilities: { encrypt: true, sign: true, wallet: false } },
},
};
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
if (username) localStorage.setItem("rspace-username", username);
}
// ── The custom element ──
export class RStackIdentity extends HTMLElement {
#shadow: ShadowRoot;
constructor() {
super();
this.#shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.#render();
}
#render() {
const session = getSession();
const theme = this.closest("[data-theme]")?.getAttribute("data-theme") || "light";
if (session) {
const username = session.claims.username || "";
const did = session.claims.did || session.claims.sub;
const displayName = username || (did.length > 24 ? did.slice(0, 16) + "..." + did.slice(-6) : did);
const initial = username ? username[0].toUpperCase() : did.slice(8, 10).toUpperCase();
this.#shadow.innerHTML = `
<style>${STYLES}</style>
<div class="user ${theme}" id="user-toggle">
<div class="avatar">${initial}</div>
<span class="name">${displayName}</span>
<div class="dropdown" id="dropdown">
<button class="dropdown-item" data-action="profile">👤 Profile</button>
<button class="dropdown-item" data-action="recovery">🛡 Recovery</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item dropdown-item--danger" data-action="signout">🚪 Sign Out</button>
</div>
</div>
`;
const toggle = this.#shadow.getElementById("user-toggle")!;
const dropdown = this.#shadow.getElementById("dropdown")!;
toggle.addEventListener("click", (e) => {
e.stopPropagation();
dropdown.classList.toggle("open");
});
document.addEventListener("click", () => dropdown.classList.remove("open"));
this.#shadow.querySelectorAll("[data-action]").forEach((el) => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const action = (el as HTMLElement).dataset.action;
dropdown.classList.remove("open");
if (action === "signout") {
clearSession();
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
} else if (action === "profile") {
window.open(ENCRYPTID_URL, "_blank");
} else if (action === "recovery") {
window.open(`${ENCRYPTID_URL}/recover`, "_blank");
}
});
});
} else {
this.#shadow.innerHTML = `
<style>${STYLES}</style>
<button class="signin-btn ${theme}" id="signin-btn">🔑 Sign In</button>
`;
this.#shadow.getElementById("signin-btn")!.addEventListener("click", () => {
this.showAuthModal();
});
}
}
/** Public method: show the auth modal programmatically */
showAuthModal(callbacks?: { onSuccess?: () => void; onCancel?: () => void }): void {
if (document.querySelector(".rstack-auth-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "rstack-auth-overlay";
let mode: "signin" | "register" = "signin";
const render = () => {
overlay.innerHTML = mode === "signin" ? signinHTML() : registerHTML();
attachListeners();
};
const signinHTML = () => `
<style>${MODAL_STYLES}</style>
<div class="auth-modal">
<h2>Sign in with EncryptID</h2>
<p>Use your passkey to sign in. No passwords needed.</p>
<div class="actions">
<button class="btn btn--secondary" data-action="cancel">Cancel</button>
<button class="btn btn--primary" data-action="signin">🔑 Sign In with Passkey</button>
</div>
<div class="error" id="auth-error"></div>
<div class="toggle">Don't have an account? <a data-action="switch-register">Create one</a></div>
</div>
`;
const registerHTML = () => `
<style>${MODAL_STYLES}</style>
<div class="auth-modal">
<h2>Create your EncryptID</h2>
<p>Set up a secure, passwordless identity.</p>
<input class="input" id="auth-username" type="text" placeholder="Choose a username" autocomplete="username webauthn" maxlength="32" />
<div class="actions">
<button class="btn btn--secondary" data-action="cancel">Cancel</button>
<button class="btn btn--primary" data-action="register">🔐 Create Passkey</button>
</div>
<div class="error" id="auth-error"></div>
<div class="toggle">Already have an account? <a data-action="switch-signin">Sign in</a></div>
</div>
`;
const close = () => {
overlay.remove();
};
const handleSignIn = async () => {
const errEl = overlay.querySelector("#auth-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="signin"]') as HTMLButtonElement;
errEl.textContent = "";
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Authenticating...';
try {
const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!startRes.ok) throw new Error("Failed to start authentication");
const { options: serverOptions } = await startRes.json();
const credential = (await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rpId: serverOptions.rpId || "rspace.online",
userVerification: "required",
timeout: 60000,
},
})) as PublicKeyCredential;
if (!credential) throw new Error("Authentication failed");
const completeRes = await fetch(`${ENCRYPTID_URL}/api/auth/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
challenge: serverOptions.challenge,
credential: { credentialId: bufferToBase64url(credential.rawId) },
}),
});
const data = await completeRes.json();
if (!completeRes.ok || !data.success) throw new Error(data.error || "Authentication failed");
storeSession(data.token, data.username || "", data.did || "");
close();
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
callbacks?.onSuccess?.();
} catch (err: any) {
btn.disabled = false;
btn.innerHTML = "🔑 Sign In with Passkey";
errEl.textContent = err.name === "NotAllowedError" ? "Authentication was cancelled." : err.message || "Authentication failed.";
}
};
const handleRegister = async () => {
const usernameInput = overlay.querySelector("#auth-username") as HTMLInputElement;
const errEl = overlay.querySelector("#auth-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="register"]') as HTMLButtonElement;
const username = usernameInput.value.trim();
if (!username) {
errEl.textContent = "Please enter a username.";
usernameInput.focus();
return;
}
errEl.textContent = "";
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Creating passkey...';
try {
const startRes = await fetch(`${ENCRYPTID_URL}/api/register/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, displayName: username }),
});
if (!startRes.ok) throw new Error("Failed to start registration");
const { options: serverOptions, userId } = await startRes.json();
const credential = (await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rp: { id: serverOptions.rp?.id || "rspace.online", name: serverOptions.rp?.name || "EncryptID" },
user: { id: new Uint8Array(base64urlToBuffer(serverOptions.user.id)), name: username, displayName: username },
pubKeyCredParams: serverOptions.pubKeyCredParams || [
{ alg: -7, type: "public-key" as const },
{ alg: -257, type: "public-key" as const },
],
authenticatorSelection: { residentKey: "required", requireResidentKey: true, userVerification: "required" },
attestation: "none",
timeout: 60000,
},
})) as PublicKeyCredential;
if (!credential) throw new Error("Failed to create credential");
const response = credential.response as AuthenticatorAttestationResponse;
const publicKey = response.getPublicKey?.();
const completeRes = await fetch(`${ENCRYPTID_URL}/api/register/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
challenge: serverOptions.challenge,
credential: {
credentialId: bufferToBase64url(credential.rawId),
publicKey: publicKey ? bufferToBase64url(publicKey) : "",
transports: response.getTransports?.() || [],
},
userId,
username,
}),
});
const data = await completeRes.json();
if (!completeRes.ok || !data.success) throw new Error(data.error || "Registration failed");
storeSession(data.token, username, data.did || "");
close();
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
callbacks?.onSuccess?.();
} catch (err: any) {
btn.disabled = false;
btn.innerHTML = "🔐 Create Passkey";
errEl.textContent = err.name === "NotAllowedError" ? "Passkey creation was cancelled." : err.message || "Registration failed.";
}
};
const attachListeners = () => {
overlay.querySelector('[data-action="cancel"]')?.addEventListener("click", () => {
close();
callbacks?.onCancel?.();
});
overlay.querySelector('[data-action="signin"]')?.addEventListener("click", handleSignIn);
overlay.querySelector('[data-action="register"]')?.addEventListener("click", handleRegister);
overlay.querySelector('[data-action="switch-register"]')?.addEventListener("click", () => {
mode = "register";
render();
setTimeout(() => (overlay.querySelector("#auth-username") as HTMLInputElement)?.focus(), 50);
});
overlay.querySelector('[data-action="switch-signin"]')?.addEventListener("click", () => {
mode = "signin";
render();
});
overlay.querySelector("#auth-username")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") handleRegister();
});
overlay.addEventListener("click", (e) => {
if (e.target === overlay) {
close();
callbacks?.onCancel?.();
}
});
};
document.body.appendChild(overlay);
render();
}
static define(tag = "rstack-identity") {
if (!customElements.get(tag)) customElements.define(tag, RStackIdentity);
}
}
// ── Require auth helper (for use by module code) ──
export function requireAuth(onAuthenticated: () => void): boolean {
if (isAuthenticated()) return true;
const el = document.querySelector("rstack-identity") as RStackIdentity | null;
if (el) {
el.showAuthModal({ onSuccess: onAuthenticated });
}
return false;
}
// ── Styles ──
const STYLES = `
:host { display: contents; }
.signin-btn {
display: flex; align-items: center; gap: 8px;
padding: 8px 20px; border-radius: 8px; border: none;
font-size: 0.875rem; font-weight: 600; cursor: pointer;
transition: all 0.2s; text-decoration: none;
}
.signin-btn.light {
background: linear-gradient(135deg, #06b6d4, #0891b2); color: white;
}
.signin-btn.dark {
background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white;
}
.signin-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); }
.user {
display: flex; align-items: center; gap: 10px;
position: relative; cursor: pointer;
}
.avatar {
width: 34px; height: 34px; border-radius: 50%;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 0.8rem; color: white;
}
.name {
font-size: 0.8rem; max-width: 140px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.user.light .name { color: #64748b; }
.user.dark .name { color: #94a3b8; }
.dropdown {
position: absolute; top: 100%; right: 0; margin-top: 8px;
min-width: 200px; border-radius: 10px; overflow: hidden;
box-shadow: 0 8px 30px rgba(0,0,0,0.2); display: none; z-index: 100;
}
.dropdown.open { display: block; }
.user.light .dropdown { background: white; border: 1px solid rgba(0,0,0,0.1); }
.user.dark .dropdown { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); }
.dropdown-item {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; font-size: 0.875rem; cursor: pointer;
transition: background 0.15s; border: none; background: none;
width: 100%; text-align: left;
}
.user.light .dropdown-item { color: #374151; }
.user.light .dropdown-item:hover { background: #f1f5f9; }
.user.dark .dropdown-item { color: #e2e8f0; }
.user.dark .dropdown-item:hover { background: rgba(255,255,255,0.05); }
.dropdown-item--danger { color: #ef4444 !important; }
.dropdown-divider { height: 1px; margin: 4px 0; }
.user.light .dropdown-divider { background: rgba(0,0,0,0.08); }
.user.dark .dropdown-divider { background: rgba(255,255,255,0.08); }
`;
const MODAL_STYLES = `
.rstack-auth-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px); display: flex; align-items: center;
justify-content: center; z-index: 10000; animation: fadeIn 0.2s;
}
.auth-modal {
background: #1e293b; border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px; padding: 2rem; max-width: 420px; width: 90%;
text-align: center; color: white; box-shadow: 0 20px 60px rgba(0,0,0,0.4);
animation: slideUp 0.3s;
}
.auth-modal h2 {
font-size: 1.5rem; margin-bottom: 0.5rem;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.auth-modal p { color: #94a3b8; font-size: 0.95rem; line-height: 1.6; margin-bottom: 1.5rem; }
.input {
width: 100%; padding: 12px 16px; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05);
color: white; font-size: 1rem; margin-bottom: 1rem; outline: none;
transition: border-color 0.2s; box-sizing: border-box;
}
.input:focus { border-color: #06b6d4; }
.input::placeholder { color: #64748b; }
.actions { display: flex; gap: 12px; margin-top: 0.5rem; }
.btn {
flex: 1; padding: 12px 20px; border-radius: 8px; border: none;
font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: all 0.2s;
}
.btn--primary { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; }
.btn--primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); }
.btn--primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn--secondary { background: rgba(255,255,255,0.08); color: #94a3b8; border: 1px solid rgba(255,255,255,0.1); }
.btn--secondary:hover { background: rgba(255,255,255,0.12); color: white; }
.error { color: #ef4444; font-size: 0.85rem; margin-top: 0.5rem; min-height: 1.2em; }
.toggle { margin-top: 1rem; font-size: 0.85rem; color: #64748b; }
.toggle a { color: #06b6d4; cursor: pointer; text-decoration: none; }
.toggle a:hover { text-decoration: underline; }
.spinner {
display: inline-block; width: 18px; height: 18px;
border: 2px solid transparent; border-top-color: currentColor;
border-radius: 50%; animation: spin 0.7s linear infinite;
vertical-align: middle; margin-right: 6px;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@keyframes spin { to { transform: rotate(360deg); } }
`;

View File

@ -0,0 +1,193 @@
/**
* <rstack-space-switcher> Dropdown to switch between user's spaces.
*
* Attributes:
* current the active space slug
* name the display name of the active space
*
* Fetches the user's spaces from /api/spaces on click.
*/
import { isAuthenticated } from "./rstack-identity";
interface SpaceInfo {
slug: string;
name: string;
icon?: string;
}
export class RStackSpaceSwitcher extends HTMLElement {
#shadow: ShadowRoot;
#spaces: SpaceInfo[] = [];
#loaded = false;
constructor() {
super();
this.#shadow = this.attachShadow({ mode: "open" });
}
static get observedAttributes() {
return ["current", "name"];
}
get current(): string {
return this.getAttribute("current") || "";
}
get spaceName(): string {
return this.getAttribute("name") || this.current;
}
connectedCallback() {
this.#render();
}
attributeChangedCallback() {
this.#render();
}
async #loadSpaces() {
if (this.#loaded) return;
try {
const res = await fetch("/api/spaces");
if (res.ok) {
const data = await res.json();
this.#spaces = data.spaces || [];
}
} catch {
// Offline or API not available — just show current
}
this.#loaded = true;
}
#render() {
const current = this.current;
const name = this.spaceName;
this.#shadow.innerHTML = `
<style>${STYLES}</style>
<div class="switcher">
<button class="trigger" id="trigger">
<span class="slash">/</span>
<span class="space-name">${name || "Select space"}</span>
<span class="caret"></span>
</button>
<div class="menu" id="menu">
<div class="menu-loading">Loading spaces...</div>
</div>
</div>
`;
const trigger = this.#shadow.getElementById("trigger")!;
const menu = this.#shadow.getElementById("menu")!;
trigger.addEventListener("click", async (e) => {
e.stopPropagation();
const isOpen = menu.classList.toggle("open");
if (isOpen && !this.#loaded) {
await this.#loadSpaces();
this.#renderMenu(menu, current);
}
});
document.addEventListener("click", () => menu.classList.remove("open"));
}
#renderMenu(menu: HTMLElement, current: string) {
if (this.#spaces.length === 0) {
menu.innerHTML = `
<div class="menu-empty">
${isAuthenticated() ? "No spaces yet" : "Sign in to see your spaces"}
</div>
<a class="item item--create" href="/new">+ Create new space</a>
`;
return;
}
const moduleId = this.#getCurrentModule();
menu.innerHTML = `
${this.#spaces
.map(
(s) => `
<a class="item ${s.slug === current ? "active" : ""}"
href="/${s.slug}/${moduleId}">
<span class="item-icon">${s.icon || "🌐"}</span>
<span class="item-name">${s.name}</span>
</a>
`
)
.join("")}
<div class="divider"></div>
<a class="item item--create" href="/new">+ Create new space</a>
`;
}
#getCurrentModule(): string {
const parts = window.location.pathname.split("/").filter(Boolean);
return parts[1] || "canvas";
}
static define(tag = "rstack-space-switcher") {
if (!customElements.get(tag)) customElements.define(tag, RStackSpaceSwitcher);
}
}
const STYLES = `
:host { display: contents; }
.switcher { position: relative; }
.trigger {
display: flex; align-items: center; gap: 4px;
padding: 6px 12px; border-radius: 8px; border: none;
font-size: 0.875rem; font-weight: 500; cursor: pointer;
transition: background 0.15s; background: transparent; color: inherit;
}
:host-context([data-theme="light"]) .trigger { color: #374151; }
:host-context([data-theme="dark"]) .trigger { color: #94a3b8; }
.trigger:hover { background: rgba(0,0,0,0.05); }
:host-context([data-theme="dark"]) .trigger:hover { background: rgba(255,255,255,0.05); }
.slash { opacity: 0.4; font-weight: 300; margin-right: 2px; }
.space-name { max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.caret { font-size: 0.7em; opacity: 0.5; }
.menu {
position: absolute; top: 100%; left: 0; margin-top: 6px;
min-width: 220px; max-height: 320px; overflow-y: auto;
border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.25);
display: none; z-index: 200;
}
.menu.open { display: block; }
:host-context([data-theme="light"]) .menu { background: white; border: 1px solid rgba(0,0,0,0.1); }
:host-context([data-theme="dark"]) .menu { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); }
.item {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; text-decoration: none; cursor: pointer;
transition: background 0.12s;
}
:host-context([data-theme="light"]) .item { color: #374151; }
:host-context([data-theme="light"]) .item:hover { background: #f1f5f9; }
:host-context([data-theme="light"]) .item.active { background: #e0f2fe; }
:host-context([data-theme="dark"]) .item { color: #e2e8f0; }
:host-context([data-theme="dark"]) .item:hover { background: rgba(255,255,255,0.05); }
:host-context([data-theme="dark"]) .item.active { background: rgba(6,182,212,0.1); }
.item-icon { font-size: 1.1rem; flex-shrink: 0; }
.item-name { font-size: 0.875rem; font-weight: 500; }
.item--create {
font-size: 0.85rem; font-weight: 600; color: #06b6d4 !important;
}
.item--create:hover { background: rgba(6,182,212,0.08) !important; }
.divider { height: 1px; margin: 4px 0; }
:host-context([data-theme="light"]) .divider { background: rgba(0,0,0,0.08); }
:host-context([data-theme="dark"]) .divider { background: rgba(255,255,255,0.08); }
.menu-loading, .menu-empty {
padding: 16px; text-align: center; font-size: 0.85rem; color: #94a3b8;
}
`;

60
shared/module.ts Normal file
View File

@ -0,0 +1,60 @@
import { Hono } from "hono";
/**
* The contract every rSpace module must implement.
*
* A module is a self-contained feature area (books, pubs, cart, canvas, etc.)
* that exposes Hono routes and metadata. The shell mounts these routes under
* `/:space/{moduleId}` in unified mode. In standalone mode, the module's own
* `standalone.ts` mounts them at the root with a minimal shell.
*/
export interface RSpaceModule {
/** Short identifier used in URLs: 'books', 'pubs', 'cart', 'canvas', etc. */
id: string;
/** Human-readable name: 'rBooks', 'rPubs', 'rCart', etc. */
name: string;
/** Emoji or SVG string for the app switcher */
icon: string;
/** One-line description */
description: string;
/** Mountable Hono sub-app. Routes are relative to the mount point. */
routes: Hono;
/** Optional: standalone domain for this module (e.g. 'rbooks.online') */
standaloneDomain?: string;
/** Called when a new space is created (e.g. to initialize module-specific data) */
onSpaceCreate?: (spaceSlug: string) => Promise<void>;
/** Called when a space is deleted (e.g. to clean up module-specific data) */
onSpaceDelete?: (spaceSlug: string) => Promise<void>;
}
/** Registry of all loaded modules */
const modules = new Map<string, RSpaceModule>();
export function registerModule(mod: RSpaceModule): void {
modules.set(mod.id, mod);
}
export function getModule(id: string): RSpaceModule | undefined {
return modules.get(id);
}
export function getAllModules(): RSpaceModule[] {
return Array.from(modules.values());
}
/** Metadata exposed to the client for the app switcher */
export interface ModuleInfo {
id: string;
name: string;
icon: string;
description: string;
}
export function getModuleInfoList(): ModuleInfo[] {
return getAllModules().map((m) => ({
id: m.id,
name: m.name,
icon: m.icon,
description: m.description,
}));
}

View File

@ -18,9 +18,11 @@
"paths": { "paths": {
"@lib": ["lib"], "@lib": ["lib"],
"@lib/*": ["lib/*"], "@lib/*": ["lib/*"],
"@encryptid/*": ["src/encryptid/*"] "@encryptid/*": ["src/encryptid/*"],
"@shared": ["shared"],
"@shared/*": ["shared/*"]
} }
}, },
"include": ["**/*.ts", "vite.config.ts"], "include": ["**/*.ts", "vite.config.ts"],
"exclude": ["node_modules/**/*", "dist/**/*", "src/encryptid/**/*", "website/sw.ts"] "exclude": ["node_modules/**/*", "dist/**/*", "src/encryptid/**/*"]
} }

View File

@ -32,6 +32,32 @@ export default defineConfig({
}, },
}, },
}); });
// Build shell.ts as a standalone JS bundle
await build({
configFile: false,
root: resolve(__dirname, "website"),
resolve: {
alias: {
"@lib": resolve(__dirname, "./lib"),
"@shared": resolve(__dirname, "./shared"),
},
},
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist"),
lib: {
entry: resolve(__dirname, "website/shell.ts"),
formats: ["es"],
fileName: () => "shell.js",
},
rollupOptions: {
output: {
entryFileNames: "shell.js",
},
},
},
});
}, },
}, },
}, },
@ -40,6 +66,7 @@ export default defineConfig({
alias: { alias: {
"@lib": resolve(__dirname, "./lib"), "@lib": resolve(__dirname, "./lib"),
"@encryptid": resolve(__dirname, "./src/encryptid"), "@encryptid": resolve(__dirname, "./src/encryptid"),
"@shared": resolve(__dirname, "./shared"),
}, },
}, },
build: { build: {
@ -55,6 +82,8 @@ export default defineConfig({
}, },
outDir: "../dist", outDir: "../dist",
emptyOutDir: true, emptyOutDir: true,
// Copy shell.css to dist
cssCodeSplit: false,
}, },
server: { server: {
port: 5173, port: 5173,

92
website/public/shell.css Normal file
View File

@ -0,0 +1,92 @@
/* ── rStack Shell Layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
min-height: 100vh;
}
/* ── Header bar ── */
.rstack-header {
position: fixed;
top: 0; left: 0; right: 0;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
z-index: 9999;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.rstack-header[data-theme="light"] {
background: rgba(255, 255, 255, 0.9);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
color: #0f172a;
}
.rstack-header[data-theme="dark"] {
background: rgba(15, 23, 42, 0.85);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
color: #e2e8f0;
}
.rstack-header__left {
display: flex;
align-items: center;
gap: 4px;
}
.rstack-header__right {
display: flex;
align-items: center;
gap: 12px;
}
.rstack-header__brand {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
font-size: 1.25rem;
font-weight: 700;
color: inherit;
}
.rstack-header__brand-gradient {
background: linear-gradient(135deg, #14b8a6, #22d3ee);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
/* ── Main content area ── */
#app {
padding-top: 56px; /* Below fixed header */
min-height: 100vh;
}
/* When canvas module is active, make it fill the viewport */
#app.canvas-layout {
padding-top: 56px;
height: 100vh;
overflow: hidden;
}
/* ── Standalone mode (no app/space switcher) ── */
.rstack-header--standalone .rstack-header__left {
gap: 0;
}
/* ── Mobile adjustments ── */
@media (max-width: 640px) {
.rstack-header {
padding: 0 12px;
}
}

17
website/shell.ts Normal file
View File

@ -0,0 +1,17 @@
/**
* Shell entry point loaded on every page.
*
* Registers the three header web components:
* <rstack-app-switcher>
* <rstack-space-switcher>
* <rstack-identity>
*/
import { RStackIdentity } from "../shared/components/rstack-identity";
import { RStackAppSwitcher } from "../shared/components/rstack-app-switcher";
import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher";
// Register all header components
RStackIdentity.define();
RStackAppSwitcher.define();
RStackSpaceSwitcher.define();