feat: auto-route users to personal/demo space + landing overlay

- Anon users visiting any rApp (standalone or unified) land on demo space
- Logged-in users auto-redirect to personal space (auto-provisioned)
- POST /api/spaces/auto-provision creates personal space on first visit
- Standalone domains support /<space> path prefix (rpubs.online/jeff)
- rspace.online/ redirects to /demo/canvas (app-first experience)
- Quarter-screen welcome overlay on demo space for first-time visitors
- Full landing page moved to /about
- Auth flow triggers auto-space-resolution on sign-in/register
- Demo space seeded with shapes for all 22 rApps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-25 15:47:47 -08:00
parent 38a5d84f72
commit e7ce57ce0b
5 changed files with 573 additions and 8 deletions

View File

@ -423,6 +423,48 @@ app.get("/api/modules", (c) => {
return c.json({ modules: getModuleInfoList() });
});
// ── Auto-provision personal space ──
app.post("/api/spaces/auto-provision", 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 username = claims.username?.toLowerCase();
if (!username || !/^[a-z0-9][a-z0-9-]*$/.test(username)) {
return c.json({ error: "Username not suitable for space slug" }, 400);
}
if (await communityExists(username)) {
return c.json({ status: "exists", slug: username });
}
await createCommunity(
`${claims.username}'s Space`,
username,
claims.sub,
"authenticated",
);
for (const mod of getAllModules()) {
if (mod.onSpaceCreate) {
try {
await mod.onSpaceCreate(username);
} catch (e) {
console.error(`[AutoProvision] Module ${mod.id} onSpaceCreate:`, e);
}
}
}
console.log(`[AutoProvision] Created personal space: ${username}`);
return c.json({ status: "created", slug: username }, 201);
});
// ── Mount module routes under /:space/:moduleId ──
for (const mod of getAllModules()) {
app.route(`/:space/${mod.id}`, mod.routes);
@ -430,8 +472,11 @@ for (const mod of getAllModules()) {
// ── Page routes ──
// Landing page: rspace.online/
app.get("/", async (c) => {
// Landing page: rspace.online/ → redirect to demo canvas (overlay shows there)
app.get("/", (c) => c.redirect("/demo/canvas", 302));
// About/info page (full landing content)
app.get("/about", async (c) => {
const file = Bun.file(resolve(DIST_DIR, "index.html"));
if (await file.exists()) {
return new Response(file, { headers: { "Content-Type": "text/html" } });
@ -633,9 +678,27 @@ const server = Bun.serve<WSData>({
if (staticResponse) return staticResponse;
}
// Rewrite: / → /demo/{moduleId}, /foo → /demo/{moduleId}/foo
const suffix = url.pathname === "/" ? "" : url.pathname;
const rewrittenPath = `/demo/${standaloneModuleId}${suffix}`;
// Determine space from URL path: /{space} prefix on standalone domains
// / → /demo/{moduleId} (anon default)
// /jeff → /jeff/{moduleId} (personal space)
// /api/... → /demo/{moduleId}/api/... (module API)
const pathParts = url.pathname.split("/").filter(Boolean);
let space = "demo";
let suffix = "";
if (
pathParts.length > 0 &&
!pathParts[0].includes(".") &&
pathParts[0] !== "api" &&
pathParts[0] !== "ws"
) {
space = pathParts[0];
suffix = pathParts.length > 1 ? "/" + pathParts.slice(1).join("/") : "";
} else if (url.pathname !== "/") {
suffix = url.pathname;
}
const rewrittenPath = `/${space}/${standaloneModuleId}${suffix}`;
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
const rewrittenReq = new Request(rewrittenUrl, req);
return app.fetch(rewrittenReq);

View File

@ -482,6 +482,287 @@ const DEMO_SHAPES: Record<string, unknown>[] = [
targetId: "demo-ledger-cred",
color: "#059669",
},
// ─── rFiles: Shared Documents ──────────────────────────────
{
id: "demo-file-permits",
type: "folk-file",
x: 1550, y: 900, width: 280, height: 80, rotation: 0,
fileName: "Swiss Hiking Permits.pdf",
fileSize: "2.4 MB",
mimeType: "application/pdf",
uploadedBy: "Priya",
uploadedAt: "2026-06-28",
tags: ["permits", "switzerland", "logistics"],
},
{
id: "demo-file-insurance",
type: "folk-file",
x: 1550, y: 1000, width: 280, height: 80, rotation: 0,
fileName: "Travel Insurance Policy.pdf",
fileSize: "1.1 MB",
mimeType: "application/pdf",
uploadedBy: "Omar",
uploadedAt: "2026-06-25",
tags: ["insurance", "emergency", "documents"],
},
{
id: "demo-file-topo",
type: "folk-file",
x: 1550, y: 1100, width: 280, height: 80, rotation: 0,
fileName: "Tre Cime Topo Map.gpx",
fileSize: "340 KB",
mimeType: "application/gpx+xml",
uploadedBy: "Liam",
uploadedAt: "2026-07-01",
tags: ["maps", "hiking", "dolomites"],
},
// ─── rForum: Discussion Threads ────────────────────────────
{
id: "demo-forum-weather",
type: "folk-forum-thread",
x: 1870, y: 900, width: 320, height: 160, rotation: 0,
threadTitle: "Weather Contingency Plan",
author: "Omar",
createdAt: "2026-06-30",
replyCount: 8,
lastReply: "Maya — 2 hours ago",
preview: "What's our plan if the Matterhorn trek gets rained out? I found a cheese museum in Zermatt as backup...",
tags: ["planning", "weather"],
},
{
id: "demo-forum-transport",
type: "folk-forum-thread",
x: 1870, y: 1080, width: 320, height: 140, rotation: 0,
threadTitle: "Train vs Rental Car — Dolomites Transfer",
author: "Liam",
createdAt: "2026-06-28",
replyCount: 5,
lastReply: "Priya — yesterday",
preview: "The Bernina Express is scenic but 6 hours. Rental car is 3.5 hours. Thoughts?",
tags: ["transport", "logistics"],
},
// ─── rBooks: Shared Reading ────────────────────────────────
{
id: "demo-book-alpine",
type: "folk-book",
x: 2350, y: 50, width: 280, height: 200, rotation: 0,
bookTitle: "The Alps: A Human History",
author: "Stephen O'Shea",
coverColor: "#7c3aed",
pageCount: 320,
currentPage: 145,
readers: ["Maya", "Liam"],
status: "reading",
},
{
id: "demo-book-wild",
type: "folk-book",
x: 2350, y: 280, width: 280, height: 200, rotation: 0,
bookTitle: "Wild: A Journey from Lost to Found",
author: "Cheryl Strayed",
coverColor: "#059669",
pageCount: 315,
currentPage: 315,
readers: ["Priya"],
status: "finished",
},
// ─── rPubs: Published Artifacts ────────────────────────────
{
id: "demo-pub-zine",
type: "folk-pub",
x: 2350, y: 520, width: 300, height: 180, rotation: 0,
pubTitle: "Alpine Explorer Zine",
pubType: "zine",
creator: "Maya",
format: "A5 risograph",
status: "in-production",
copies: 50,
price: 12.00,
currency: "EUR",
description: "Photo zine documenting the Alpine Explorer 2026 trip. Risograph printed on recycled paper.",
},
// ─── rSwag: Merchandise ────────────────────────────────────
{
id: "demo-swag-tee",
type: "folk-swag",
x: 2350, y: 730, width: 280, height: 120, rotation: 0,
swagTitle: "Alpine Explorer 2026 Tee",
swagType: "t-shirt",
designer: "Liam",
sizes: ["S", "M", "L", "XL"],
price: 28.00,
currency: "EUR",
status: "available",
orderCount: 12,
},
// ─── rProviders: Local Production ──────────────────────────
{
id: "demo-provider-risograph",
type: "folk-provider",
x: 2350, y: 880, width: 300, height: 160, rotation: 0,
providerName: "Chamonix Print Collective",
location: "Chamonix, France",
capabilities: ["risograph", "screen-print", "letterpress"],
substrates: ["recycled paper", "card stock", "cotton"],
turnaround: "5-7 days",
rating: 4.8,
ordersFulfilled: 127,
},
// ─── rWork: Task Board ─────────────────────────────────────
{
id: "demo-work-board",
type: "folk-work-board",
x: 750, y: 1350, width: 500, height: 280, rotation: 0,
boardTitle: "Trip Preparation Tasks",
columns: [
{
name: "To Do",
tasks: [
{ title: "Book paragliding (2 remaining spots)", assignee: "Liam", priority: "high" },
{ title: "Buy trekking poles", assignee: "Maya", priority: "medium" },
],
},
{
name: "In Progress",
tasks: [
{ title: "Research Italian drone regulations", assignee: "Liam", priority: "high" },
{ title: "First aid training refresher", assignee: "Omar", priority: "medium" },
],
},
{
name: "Done",
tasks: [
{ title: "Book hut reservations", assignee: "Priya", priority: "high" },
{ title: "Get travel insurance", assignee: "Omar", priority: "high" },
{ title: "Break in hiking boots", assignee: "Maya", priority: "medium" },
],
},
],
},
// ─── rCal: Shared Calendar ─────────────────────────────────
{
id: "demo-cal-events",
type: "folk-calendar",
x: 50, y: 1350, width: 350, height: 250, rotation: 0,
calTitle: "Alpine Explorer 2026",
month: "July 2026",
events: [
{ date: "Jul 6", title: "Fly to Geneva", color: "#3b82f6" },
{ date: "Jul 7", title: "Lac Blanc Hike", color: "#22c55e" },
{ date: "Jul 8", title: "Via Ferrata", color: "#ef4444" },
{ date: "Jul 10", title: "Train to Zermatt", color: "#3b82f6" },
{ date: "Jul 13", title: "Paragliding", color: "#ef4444" },
{ date: "Jul 14", title: "Transfer to Dolomites", color: "#3b82f6" },
{ date: "Jul 15", title: "Tre Cime Loop", color: "#22c55e" },
{ date: "Jul 18", title: "Cooking Class", color: "#f59e0b" },
{ date: "Jul 20", title: "Fly Home", color: "#3b82f6" },
],
},
// ─── rNetwork: Contact Graph ───────────────────────────────
{
id: "demo-network-graph",
type: "folk-network",
x: 1300, y: 1350, width: 400, height: 280, rotation: 0,
networkTitle: "Trip Contacts",
nodes: [
{ id: "maya", label: "Maya", role: "organizer" },
{ id: "liam", label: "Liam", role: "photographer" },
{ id: "priya", label: "Priya", role: "logistics" },
{ id: "omar", label: "Omar", role: "safety" },
{ id: "vrony", label: "Chez Vrony", role: "restaurant" },
{ id: "rega", label: "REGA Rescue", role: "emergency" },
{ id: "locatelli", label: "Rif. Locatelli", role: "hut" },
],
edges: [
{ from: "maya", to: "liam" },
{ from: "maya", to: "priya" },
{ from: "maya", to: "omar" },
{ from: "priya", to: "locatelli" },
{ from: "omar", to: "rega" },
{ from: "liam", to: "vrony" },
],
},
// ─── rTube: Shared Videos ──────────────────────────────────
{
id: "demo-tube-vlog",
type: "folk-video",
x: 2680, y: 50, width: 300, height: 180, rotation: 0,
videoTitle: "Lac Blanc Sunrise — Test Footage",
duration: "3:42",
creator: "Liam",
uploadedAt: "2026-07-08",
views: 24,
thumbnail: "sunrise-lacblanc",
},
// ─── rInbox: Group Messages ────────────────────────────────
{
id: "demo-inbox-msg",
type: "folk-inbox",
x: 2680, y: 260, width: 300, height: 160, rotation: 0,
inboxTitle: "Trip Group Chat",
messages: [
{ from: "Maya", text: "Don't forget passports tomorrow!", time: "10:32 AM" },
{ from: "Liam", text: "Drone batteries charging. All 3 ready.", time: "10:45 AM" },
{ from: "Omar", text: "First aid kit packed. Added altitude sickness meds.", time: "11:02 AM" },
{ from: "Priya", text: "Locatelli confirmed our reservation!", time: "11:15 AM" },
],
},
// ─── rData: Trip Analytics ─────────────────────────────────
{
id: "demo-data-dashboard",
type: "folk-dashboard",
x: 2680, y: 450, width: 320, height: 220, rotation: 0,
dashTitle: "Trip Statistics",
metrics: [
{ label: "Total Budget", value: "€4,000", trend: "neutral" },
{ label: "Spent", value: "€1,203", trend: "up" },
{ label: "Remaining", value: "€2,797", trend: "down" },
{ label: "Tasks Done", value: "3/7", trend: "up" },
{ label: "Hike Distance", value: "~85 km", trend: "neutral" },
{ label: "Peak Altitude", value: "3,842m", trend: "neutral" },
],
},
// ─── rChoices: Decision Matrix ─────────────────────────────
{
id: "demo-choices-camera",
type: "folk-choice-matrix",
x: 2680, y: 700, width: 320, height: 200, rotation: 0,
choiceTitle: "Camera Gear Decision",
options: [
{ name: "DJI Mini 4 Pro", score: 8.5, criteria: { weight: 9, quality: 8, price: 7, battery: 10 } },
{ name: "GoPro Hero 12", score: 7.2, criteria: { weight: 10, quality: 6, price: 8, battery: 5 } },
{ name: "Sony A7C II", score: 7.8, criteria: { weight: 4, quality: 10, price: 5, battery: 8 } },
],
decidedBy: "Liam",
status: "decided",
winner: "DJI Mini 4 Pro",
},
// ─── rSplat: 3D Captures ───────────────────────────────────
{
id: "demo-splat-matterhorn",
type: "folk-splat",
x: 2680, y: 930, width: 300, height: 160, rotation: 0,
splatTitle: "Matterhorn Base Camp — 3D Scan",
pointCount: "2.4M",
capturedBy: "Liam",
capturedAt: "2026-07-12",
fileSize: "48 MB",
status: "processing",
},
];
/**

View File

@ -59,6 +59,7 @@ export function renderShell(opts: ShellOptions): string {
<link rel="stylesheet" href="/shell.css">
${styles}
${head}
<style>${WELCOME_CSS}</style>
</head>
<body data-theme="${theme}">
<header class="rstack-header" data-theme="${theme}">
@ -79,11 +80,60 @@ export function renderShell(opts: ShellOptions): string {
<main id="app">
${body}
</main>
${renderWelcomeOverlay()}
<script type="module">
import '/shell.js';
// Provide module list to app switcher
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
// ── Auto-space resolution ──
// Logged-in users on demo space → redirect to personal space
(function() {
try {
var raw = localStorage.getItem('encryptid_session');
if (!raw) return;
var session = JSON.parse(raw);
if (!session || !session.claims || !session.claims.username) return;
var currentSpace = '${escapeAttr(spaceSlug)}';
if (currentSpace !== 'demo') return;
fetch('/api/spaces/auto-provision', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + session.accessToken,
'Content-Type': 'application/json'
}
}).then(function(r) { return r.json(); })
.then(function(data) {
if (data.slug) {
var host = window.location.host.split(':')[0];
var isStandalone = host !== 'rspace.online' && host !== 'localhost' && host !== '127.0.0.1';
var moduleId = '${escapeAttr(moduleId)}';
if (isStandalone) {
window.location.replace('/' + data.slug);
} else {
window.location.replace('/' + data.slug + '/' + moduleId);
}
}
}).catch(function() {});
} catch(e) {}
})();
// ── Welcome overlay (first visit to demo) ──
(function() {
var currentSpace = '${escapeAttr(spaceSlug)}';
if (currentSpace !== 'demo') return;
if (localStorage.getItem('rspace_welcomed')) return;
var el = document.getElementById('rspace-welcome');
if (el) el.style.display = 'flex';
})();
window.__rspaceDismissWelcome = function() {
localStorage.setItem('rspace_welcomed', '1');
var el = document.getElementById('rspace-welcome');
if (el) el.style.display = 'none';
};
// ── Tab bar / Layer system initialization ──
const tabBar = document.querySelector('rstack-tab-bar');
const spaceSlug = '${escapeAttr(spaceSlug)}';
@ -266,6 +316,108 @@ export function renderStandaloneShell(opts: {
</html>`;
}
// ── Welcome overlay (quarter-screen popup for first-time visitors on demo) ──
function renderWelcomeOverlay(): string {
return `
<div id="rspace-welcome" class="rspace-welcome" style="display:none">
<div class="rspace-welcome__popup">
<button class="rspace-welcome__close" onclick="window.__rspaceDismissWelcome()">&times;</button>
<h2 class="rspace-welcome__title">Welcome to rSpace</h2>
<p class="rspace-welcome__text">
A collaborative, local-first community platform with 22+ interoperable tools.
You're viewing the <strong>demo space</strong> &mdash; sign in to access your own.
</p>
<div class="rspace-welcome__grid">
<span>🎨 Canvas</span><span>📝 Notes</span>
<span>🗳 Voting</span><span>💸 Funds</span>
<span>🗺 Maps</span><span>📁 Files</span>
<span>🔐 Passkeys</span><span>📡 Offline-First</span>
</div>
<div class="rspace-welcome__actions">
<a href="/create-space" class="rspace-welcome__btn rspace-welcome__btn--primary">Create a Space</a>
<button onclick="window.__rspaceDismissWelcome()" class="rspace-welcome__btn rspace-welcome__btn--secondary">Explore Demo</button>
</div>
<div class="rspace-welcome__footer">
<a href="/about" class="rspace-welcome__link">Learn more about rSpace</a>
<span class="rspace-welcome__dot">&middot;</span>
<a href="https://ridentity.online" class="rspace-welcome__link">EncryptID</a>
</div>
</div>
</div>`;
}
const WELCOME_CSS = `
.rspace-welcome {
position: fixed; bottom: 20px; right: 20px; z-index: 10000;
display: none; align-items: flex-end; justify-content: flex-end;
}
.rspace-welcome__popup {
position: relative;
width: min(380px, 44vw); max-height: 50vh;
background: #1e293b; border: 1px solid rgba(255,255,255,0.12);
border-radius: 16px; padding: 24px 24px 18px;
box-shadow: 0 20px 60px rgba(0,0,0,0.5); color: #e2e8f0;
overflow-y: auto; animation: rspace-welcome-in 0.3s ease-out;
}
@keyframes rspace-welcome-in {
from { opacity: 0; transform: translateY(20px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.rspace-welcome__close {
position: absolute; top: 10px; right: 12px;
background: none; border: none; color: #64748b;
font-size: 1.4rem; cursor: pointer; line-height: 1;
padding: 4px; border-radius: 4px;
}
.rspace-welcome__close:hover { color: #e2e8f0; background: rgba(255,255,255,0.08); }
.rspace-welcome__title {
font-size: 1.35rem; margin: 0 0 8px;
background: linear-gradient(135deg, #14b8a6, #22d3ee);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.rspace-welcome__text {
font-size: 0.85rem; color: #94a3b8; margin: 0 0 14px; line-height: 1.55;
}
.rspace-welcome__text strong { color: #e2e8f0; }
.rspace-welcome__grid {
display: grid; grid-template-columns: 1fr 1fr;
gap: 5px; margin-bottom: 14px; font-size: 0.8rem; color: #cbd5e1;
}
.rspace-welcome__grid span { padding: 3px 0; }
.rspace-welcome__actions {
display: flex; gap: 8px; margin-bottom: 12px;
}
.rspace-welcome__btn {
padding: 8px 16px; border-radius: 8px; font-size: 0.82rem;
font-weight: 600; text-decoration: none; cursor: pointer; border: none;
transition: transform 0.15s, box-shadow 0.15s;
}
.rspace-welcome__btn:hover { transform: translateY(-1px); }
.rspace-welcome__btn--primary {
background: linear-gradient(135deg, #14b8a6, #0d9488); color: white;
box-shadow: 0 2px 8px rgba(20,184,166,0.3);
}
.rspace-welcome__btn--secondary {
background: rgba(255,255,255,0.08); color: #94a3b8;
}
.rspace-welcome__btn--secondary:hover { color: #e2e8f0; }
.rspace-welcome__footer {
display: flex; align-items: center; gap: 6px;
}
.rspace-welcome__link {
font-size: 0.72rem; color: #64748b; text-decoration: none;
transition: color 0.15s;
}
.rspace-welcome__link:hover { color: #c4b5fd; }
.rspace-welcome__dot { color: #475569; font-size: 0.6rem; }
@media (max-width: 600px) {
.rspace-welcome { bottom: 12px; right: 12px; left: 12px; }
.rspace-welcome__popup { width: 100%; max-width: none; }
}
`;
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

View File

@ -108,6 +108,43 @@ function storeSession(token: string, username: string, did: string): void {
if (username) localStorage.setItem("rspace-username", username);
}
// ── Auto-space resolution after auth ──
function autoResolveSpace(token: string, username: string): void {
if (!username) return;
const slug = username.toLowerCase();
// Detect current space from URL
const parts = window.location.pathname.split("/").filter(Boolean);
const currentSpace = parts[0] || "demo";
if (currentSpace !== "demo") return; // Already on a non-demo space
// Provision personal space and redirect
fetch("/api/spaces/auto-provision", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.then((r) => r.json())
.then((data) => {
if (!data.slug) return;
const host = window.location.host.split(":")[0];
const isStandalone =
host !== "rspace.online" &&
host !== "localhost" &&
host !== "127.0.0.1";
const moduleId = parts[1] || "canvas";
if (isStandalone) {
window.location.replace("/" + data.slug);
} else {
window.location.replace("/" + data.slug + "/" + moduleId);
}
})
.catch(() => {});
}
// ── The custom element ──
export class RStackIdentity extends HTMLElement {
@ -279,6 +316,8 @@ export class RStackIdentity extends HTMLElement {
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
callbacks?.onSuccess?.();
// Auto-redirect to personal space
autoResolveSpace(data.token, data.username || "");
} catch (err: any) {
btn.disabled = false;
btn.innerHTML = "🔑 Sign In with Passkey";
@ -352,6 +391,8 @@ export class RStackIdentity extends HTMLElement {
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
callbacks?.onSuccess?.();
// Auto-redirect to personal space
autoResolveSpace(data.token, username);
} catch (err: any) {
btn.disabled = false;
btn.innerHTML = "🔐 Create Passkey";

View File

@ -378,8 +378,8 @@
<p class="tagline">Collaborative community spaces powered by FolkJS</p>
<div class="cta-buttons">
<a href="/create-space" class="cta-primary">Create a Space</a>
<a href="/demo/canvas" class="cta-secondary">Try the Demo</a>
<a href="/create-space" class="cta-primary" id="cta-primary">Create a Space</a>
<a href="/demo/canvas" class="cta-secondary" id="cta-demo">Try the Demo</a>
</div>
<div class="features">
@ -603,7 +603,7 @@
</div>
<script type="module">
import { RStackIdentity } from "@shared/components/rstack-identity";
import { RStackIdentity, isAuthenticated, getAccessToken } from "@shared/components/rstack-identity";
import { RStackAppSwitcher } from "@shared/components/rstack-app-switcher";
import { RStackSpaceSwitcher } from "@shared/components/rstack-space-switcher";
@ -615,6 +615,34 @@
document.querySelector("rstack-app-switcher")?.setModules(data.modules || []);
}).catch(() => {});
// If logged in, update CTA to go to personal space
try {
const raw = localStorage.getItem('encryptid_session');
if (raw) {
const session = JSON.parse(raw);
if (session?.claims?.username) {
const username = session.claims.username.toLowerCase();
const primary = document.getElementById('cta-primary');
const demo = document.getElementById('cta-demo');
if (primary) {
primary.textContent = 'Go to My Space';
primary.href = '/' + username + '/canvas';
}
if (demo) {
demo.textContent = 'View Demo Space';
}
// Auto-provision personal space
const token = session.accessToken;
if (token) {
fetch('/api/spaces/auto-provision', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }
}).catch(() => {});
}
}
}
} catch(e) {}
// Newsletter signup
const newsletterForm = document.getElementById("newsletter-form");
const newsletterEmail = document.getElementById("newsletter-email");