feat: brandedAppName helper, rData cloud refactor, branding color tweaks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-15 13:08:36 -04:00
parent 166182a82a
commit 184da55813
7 changed files with 420 additions and 291 deletions

View File

@ -9,6 +9,7 @@
import { TourEngine } from '../../../shared/tour-engine'; import { TourEngine } from '../../../shared/tour-engine';
import type { TourStep } from '../../../shared/tour-engine'; import type { TourStep } from '../../../shared/tour-engine';
import { startPresenceHeartbeat } from '../../../shared/collab-presence'; import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { rspaceNavUrl } from '../../../shared/url-helpers';
interface TreeItem { interface TreeItem {
docId: string; docId: string;
@ -284,10 +285,8 @@ class FolkContentTree extends HTMLElement {
} }
private navigate(modId: string) { private navigate(modId: string) {
const base = window.location.pathname.split("/").slice(0, -1).join("/");
// Navigate to the module: /{space}/r{modId} or /{space}/{modId}
const modPath = modId.startsWith("r") ? modId : `r${modId}`; const modPath = modId.startsWith("r") ? modId : `r${modId}`;
window.location.href = `${base}/${modPath}`; window.open(rspaceNavUrl(this.space, modPath), "_blank");
} }
private render() { private render() {

View File

@ -1,89 +1,99 @@
/** /**
* folk-data-cloud Concentric-ring SVG visualization of data objects * folk-data-cloud Graph visualization of all data objects (documents)
* across user spaces, grouped by visibility level (private/permissioned/public). * across the user's spaces. Nodes represent individual documents,
* grouped radially by module around a central space node.
* *
* Two-level interaction: click space bubble detail panel with modules, * Click any node opens that module in a new tab.
* click module row navigate to that module page. * Demo mode shows dummy document nodes when unauthenticated.
*/ */
import { startPresenceHeartbeat } from '../../../shared/collab-presence'; import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { rspaceNavUrl } from '../../../shared/url-helpers';
interface SpaceInfo { // ── Types ──
slug: string;
name: string; interface DocNode {
docId: string;
title: string;
modId: string;
modName: string;
modIcon: string;
space: string;
spaceName: string;
visibility: string; visibility: string;
role?: string;
relationship?: string;
} }
interface ModuleSummary { interface GraphNode {
id: string; id: string;
name: string; label: string;
icon: string; icon: string;
docCount: number; type: "space" | "module" | "doc";
modId?: string;
space?: string;
color: string;
x: number;
y: number;
r: number;
} }
interface SpaceBubble extends SpaceInfo { interface GraphEdge {
docCount: number; from: string;
modules: ModuleSummary[]; to: string;
color: string;
} }
type Ring = "private" | "permissioned" | "public"; // ── Colors ──
const RING_CONFIG: Record<Ring, { color: string; label: string; radius: number }> = { const VIS_COLORS: Record<string, string> = {
private: { color: "#ef4444", label: "Private", radius: 0.28 }, private: "#ef4444",
permissioned: { color: "#eab308", label: "Permissioned", radius: 0.54 }, permissioned: "#eab308",
public: { color: "#22c55e", label: "Public", radius: 0.80 }, public: "#22c55e",
}; };
const RINGS: Ring[] = ["private", "permissioned", "public"]; const MOD_COLORS: Record<string, string> = {
notes: "#f97316", docs: "#f97316", vote: "#a855f7", tasks: "#3b82f6",
cal: "#06b6d4", wallet: "#eab308", flows: "#14b8a6", pubs: "#ec4899",
files: "#64748b", forum: "#8b5cf6", inbox: "#f43f5e", network: "#22d3ee",
trips: "#10b981", tube: "#f59e0b", choices: "#6366f1", cart: "#84cc16",
};
const DEMO_SPACES: SpaceBubble[] = [ function modColor(modId: string): string {
{ slug: "personal", name: "Personal", visibility: "private", role: "owner", relationship: "owner", docCount: 14, modules: [ return MOD_COLORS[modId] || "#94a3b8";
{ id: "notes", name: "rNotes", icon: "📝", docCount: 5 }, }
{ id: "tasks", name: "rTasks", icon: "📋", docCount: 4 },
{ id: "cal", name: "rCal", icon: "📅", docCount: 3 }, // ── Demo data ──
{ id: "wallet", name: "rWallet", icon: "💰", docCount: 2 },
]}, const DEMO_DOCS: DocNode[] = [
{ slug: "my-project", name: "Side Project", visibility: "private", role: "owner", relationship: "owner", docCount: 8, modules: [ { docId: "demo:notes:notebooks:nb1", title: "Product Roadmap", modId: "notes", modName: "rNotes", modIcon: "📝", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ id: "docs", name: "rDocs", icon: "📓", docCount: 3 }, { docId: "demo:notes:notebooks:nb2", title: "Meeting Notes", modId: "notes", modName: "rNotes", modIcon: "📝", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ id: "tasks", name: "rTasks", icon: "📋", docCount: 5 }, { docId: "demo:notes:notebooks:nb3", title: "Research Log", modId: "notes", modName: "rNotes", modIcon: "📝", space: "demo", spaceName: "Demo Space", visibility: "public" },
]}, { docId: "demo:vote:proposals:p1", title: "Dark mode proposal", modId: "vote", modName: "rVote", modIcon: "🗳", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ slug: "team-alpha", name: "Team Alpha", visibility: "permissioned", role: "owner", relationship: "owner", docCount: 22, modules: [ { docId: "demo:vote:proposals:p2", title: "Budget Q2", modId: "vote", modName: "rVote", modIcon: "🗳", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ id: "docs", name: "rDocs", icon: "📓", docCount: 6 }, { docId: "demo:tasks:boards:b1", title: "Dev Board", modId: "tasks", modName: "rTasks", modIcon: "📋", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ id: "vote", name: "rVote", icon: "🗳", docCount: 4 }, { docId: "demo:tasks:boards:b2", title: "Design Sprint", modId: "tasks", modName: "rTasks", modIcon: "📋", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ id: "flows", name: "rFlows", icon: "🌊", docCount: 3 }, { docId: "demo:tasks:boards:b3", title: "Bug Tracker", modId: "tasks", modName: "rTasks", modIcon: "📋", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ id: "tasks", name: "rTasks", icon: "📋", docCount: 5 }, { docId: "demo:cal:calendars:c1", title: "Team Calendar", modId: "cal", modName: "rCal", modIcon: "📅", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ id: "cal", name: "rCal", icon: "📅", docCount: 4 }, { docId: "demo:cal:calendars:c2", title: "Personal", modId: "cal", modName: "rCal", modIcon: "📅", space: "demo", spaceName: "Demo Space", visibility: "public" },
]}, { docId: "demo:wallet:ledgers:l1", title: "cUSDC Ledger", modId: "wallet", modName: "rWallet", modIcon: "💰", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ slug: "dao-gov", name: "DAO Governance", visibility: "permissioned", relationship: "member", docCount: 11, modules: [ { docId: "demo:flows:streams:s1", title: "Contributor Fund", modId: "flows", modName: "rFlows", modIcon: "🌊", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ id: "vote", name: "rVote", icon: "🗳", docCount: 7 }, { docId: "demo:flows:streams:s2", title: "Community Pool", modId: "flows", modName: "rFlows", modIcon: "🌊", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ id: "flows", name: "rFlows", icon: "🌊", docCount: 4 }, { docId: "demo:docs:notebooks:d1", title: "Onboarding Guide", modId: "docs", modName: "rDocs", modIcon: "📓", space: "demo", spaceName: "Demo Space", visibility: "public" },
]}, { docId: "demo:pubs:pages:pub1", title: "Launch Announcement", modId: "pubs", modName: "rPubs", modIcon: "📰", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ slug: "demo", name: "Demo Space", visibility: "public", relationship: "demo", docCount: 18, modules: [
{ id: "notes", name: "rNotes", icon: "📝", docCount: 3 },
{ id: "vote", name: "rVote", icon: "🗳", docCount: 2 },
{ id: "tasks", name: "rTasks", icon: "📋", docCount: 4 },
{ id: "cal", name: "rCal", icon: "📅", docCount: 3 },
{ id: "wallet", name: "rWallet", icon: "💰", docCount: 1 },
{ id: "flows", name: "rFlows", icon: "🌊", docCount: 5 },
]},
{ slug: "open-commons", name: "Open Commons", visibility: "public", relationship: "other", docCount: 9, modules: [
{ id: "docs", name: "rDocs", icon: "📓", docCount: 4 },
{ id: "pubs", name: "rPubs", icon: "📰", docCount: 5 },
]},
]; ];
// ── Component ──
class FolkDataCloud extends HTMLElement { class FolkDataCloud extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private space = "demo"; private space = "demo";
private spaces: SpaceBubble[] = []; private docs: DocNode[] = [];
private nodes: GraphNode[] = [];
private edges: GraphEdge[] = [];
private loading = true; private loading = true;
private isDemo = false; private isDemo = false;
private selected: string | null = null; private hoveredId: string | null = null;
private hoveredSlug: string | null = null; private width = 700;
private width = 600; private height = 700;
private height = 600;
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
private _resizeObserver: ResizeObserver | null = null; private _resizeObserver: ResizeObserver | null = null;
@ -95,10 +105,10 @@ class FolkDataCloud extends HTMLElement {
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
this._resizeObserver = new ResizeObserver((entries) => { this._resizeObserver = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width || 600; const w = entries[0]?.contentRect.width || 700;
this.width = Math.min(w, 800); this.width = Math.min(w, 900);
this.height = this.width; this.height = Math.max(this.width * 0.85, 500);
if (!this.loading) this.render(); if (!this.loading) { this.layout(); this.render(); }
}); });
this._resizeObserver.observe(this); this._resizeObserver.observe(this);
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rdata', context: 'Cloud' })); this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rdata', context: 'Cloud' }));
@ -110,6 +120,8 @@ class FolkDataCloud extends HTMLElement {
this._resizeObserver?.disconnect(); this._resizeObserver?.disconnect();
} }
// ── Data loading ──
private async loadData() { private async loadData() {
this.loading = true; this.loading = true;
this.render(); this.render();
@ -117,9 +129,8 @@ class FolkDataCloud extends HTMLElement {
const token = localStorage.getItem("rspace_auth"); const token = localStorage.getItem("rspace_auth");
if (!token) { if (!token) {
this.isDemo = true; this.isDemo = true;
this.spaces = DEMO_SPACES; this.docs = DEMO_DOCS;
this.loading = false; this.finalize();
this.render();
return; return;
} }
@ -129,237 +140,346 @@ class FolkDataCloud extends HTMLElement {
signal: AbortSignal.timeout(8000), signal: AbortSignal.timeout(8000),
}); });
if (!spacesResp.ok) throw new Error("spaces fetch failed"); if (!spacesResp.ok) throw new Error("spaces fetch failed");
const spacesData: { spaces: SpaceInfo[] } = await spacesResp.json(); const { spaces } = await spacesResp.json() as { spaces: Array<{ slug: string; name: string; visibility: string }> };
// Fetch content-tree for each space in parallel
const base = window.location.pathname.replace(/\/(tree|analytics|cloud)?\/?$/, ""); const base = window.location.pathname.replace(/\/(tree|analytics|cloud)?\/?$/, "");
const bubbles: SpaceBubble[] = await Promise.all( const allDocs: DocNode[] = [];
spacesData.spaces.map(async (sp) => {
await Promise.all(spaces.map(async (sp) => {
try { try {
const treeResp = await fetch( const resp = await fetch(
`${base}/api/content-tree?space=${encodeURIComponent(sp.slug)}`, `${base}/api/content-tree?space=${encodeURIComponent(sp.slug)}`,
{ signal: AbortSignal.timeout(8000) } { signal: AbortSignal.timeout(8000) }
); );
if (!treeResp.ok) return { ...sp, docCount: 0, modules: [] }; if (!resp.ok) return;
const tree = await treeResp.json(); const tree = await resp.json();
const modules: ModuleSummary[] = (tree.modules || []).map((m: any) => ({ for (const mod of (tree.modules || [])) {
id: m.id, for (const col of (mod.collections || [])) {
name: m.name, for (const item of (col.items || [])) {
icon: m.icon, allDocs.push({
docCount: m.collections.reduce((s: number, c: any) => s + c.items.length, 0), docId: item.docId,
})); title: item.title || col.collection,
const docCount = modules.reduce((s, m) => s + m.docCount, 0); modId: mod.id,
return { ...sp, docCount, modules }; modName: mod.name,
} catch { modIcon: mod.icon,
return { ...sp, docCount: 0, modules: [] }; space: sp.slug,
spaceName: sp.name,
visibility: sp.visibility || "private",
});
} }
}) }
); }
} catch { /* skip space */ }
}));
this.spaces = bubbles; this.docs = allDocs;
this.isDemo = false; this.isDemo = false;
} catch { } catch {
this.isDemo = true; this.isDemo = true;
this.spaces = DEMO_SPACES; this.docs = DEMO_DOCS;
} }
this.finalize();
}
private finalize() {
this.loading = false; this.loading = false;
this.layout();
this.render(); this.render();
} }
private groupByRing(): Record<Ring, SpaceBubble[]> { // ── Graph layout ──
const groups: Record<Ring, SpaceBubble[]> = { private: [], permissioned: [], public: [] }; // Central node per space, module nodes around it, doc nodes orbiting modules.
for (const sp of this.spaces) {
const ring = (sp.visibility as Ring) || "private"; private layout() {
(groups[ring] || groups.private).push(sp); this.nodes = [];
this.edges = [];
const cx = this.width / 2;
const cy = this.height / 2;
const mobile = this.width < 500;
// Group docs by space, then by module
const spaceMap = new Map<string, { name: string; vis: string; mods: Map<string, DocNode[]> }>();
for (const doc of this.docs) {
if (!spaceMap.has(doc.space)) {
spaceMap.set(doc.space, { name: doc.spaceName, vis: doc.visibility, mods: new Map() });
} }
return groups; const sp = spaceMap.get(doc.space)!;
if (!sp.mods.has(doc.modId)) sp.mods.set(doc.modId, []);
sp.mods.get(doc.modId)!.push(doc);
} }
private isMobile(): boolean { const spaceKeys = [...spaceMap.keys()];
return this.width < 500; const spaceCount = spaceKeys.length;
if (spaceCount === 0) return;
// Single space → center layout. Multiple → distribute around center.
const spaceR = mobile ? 18 : 24;
const modR = mobile ? 12 : 16;
const docR = mobile ? 6 : 8;
const orbitMod = mobile ? 70 : 100; // module distance from space center
const orbitDoc = mobile ? 28 : 38; // doc distance from module center
for (let si = 0; si < spaceCount; si++) {
const spaceSlug = spaceKeys[si];
const sp = spaceMap.get(spaceSlug)!;
const visColor = VIS_COLORS[sp.vis] || VIS_COLORS.private;
// Space position
let sx: number, sy: number;
if (spaceCount === 1) {
sx = cx; sy = cy;
} else {
const spaceOrbit = Math.min(this.width, this.height) * 0.3;
const spAngle = (2 * Math.PI * si / spaceCount) - Math.PI / 2;
sx = cx + spaceOrbit * Math.cos(spAngle);
sy = cy + spaceOrbit * Math.sin(spAngle);
} }
const spaceNodeId = `space:${spaceSlug}`;
this.nodes.push({
id: spaceNodeId,
label: sp.name,
icon: "",
type: "space",
space: spaceSlug,
color: visColor,
x: sx, y: sy, r: spaceR,
});
// Modules around space
const modKeys = [...sp.mods.keys()];
const modCount = modKeys.length;
const actualModOrbit = Math.min(orbitMod, (spaceCount === 1 ? orbitMod * 1.5 : orbitMod));
for (let mi = 0; mi < modCount; mi++) {
const mId = modKeys[mi];
const docs = sp.mods.get(mId)!;
const firstDoc = docs[0];
const mAngle = (2 * Math.PI * mi / modCount) - Math.PI / 2;
const mx = sx + actualModOrbit * Math.cos(mAngle);
const my = sy + actualModOrbit * Math.sin(mAngle);
const modNodeId = `mod:${spaceSlug}:${mId}`;
this.nodes.push({
id: modNodeId,
label: firstDoc.modName,
icon: firstDoc.modIcon,
type: "module",
modId: mId,
space: spaceSlug,
color: modColor(mId),
x: mx, y: my, r: modR,
});
this.edges.push({ from: spaceNodeId, to: modNodeId, color: visColor });
// Doc nodes around module
for (let di = 0; di < docs.length; di++) {
const doc = docs[di];
const dAngle = (2 * Math.PI * di / docs.length) - Math.PI / 2;
// Offset by module angle to spread outward
const dx = mx + orbitDoc * Math.cos(dAngle);
const dy = my + orbitDoc * Math.sin(dAngle);
const docNodeId = `doc:${doc.docId}`;
this.nodes.push({
id: docNodeId,
label: doc.title,
icon: "",
type: "doc",
modId: mId,
space: spaceSlug,
color: modColor(mId),
x: dx, y: dy, r: docR,
});
this.edges.push({ from: modNodeId, to: docNodeId, color: modColor(mId) });
}
}
}
}
// ── Rendering ──
private render() { private render() {
const selected = this.selected ? this.spaces.find(s => s.slug === this.selected) : null;
this.shadow.innerHTML = ` this.shadow.innerHTML = `
<style>${this.styles()}</style> <style>${this.styles()}</style>
<div class="dc"> <div class="dc">
${this.isDemo ? `<div class="dc-banner">Sign in to see your data cloud</div>` : ""} ${this.isDemo ? `<div class="dc-banner">Sign in to see your data cloud</div>` : ""}
${this.loading ? this.renderLoading() : this.renderSVG()} ${this.loading ? this.renderLoading() : this.renderGraph()}
${selected ? this.renderDetailPanel(selected) : ""} ${!this.loading ? this.renderLegend() : ""}
</div> </div>
`; `;
this.attachEvents(); this.attachEvents();
} }
private renderLoading(): string { private renderLoading(): string {
const cx = this.width / 2;
const cy = this.height / 2;
return ` return `
<svg class="dc-svg" viewBox="0 0 ${this.width} ${this.height}" width="${this.width}" height="${this.height}"> <svg class="dc-svg" viewBox="0 0 ${this.width} ${this.height}" width="${this.width}" height="${this.height}">
${RINGS.map(ring => { <text x="${this.width / 2}" y="${this.height / 2}" text-anchor="middle" dominant-baseline="central"
const r = RING_CONFIG[ring].radius * (this.width / 2) * 0.9;
return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none"
stroke="var(--rs-border)" stroke-width="1" stroke-dasharray="4 4" opacity="0.3"/>`;
}).join("")}
<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central"
fill="var(--rs-text-muted)" font-size="14">Loading your data cloud</text> fill="var(--rs-text-muted)" font-size="14">Loading your data cloud</text>
</svg> </svg>
`; `;
} }
private renderSVG(): string { private renderGraph(): string {
const groups = this.groupByRing(); if (this.nodes.length === 0) {
const cx = this.width / 2; return `<div class="dc-empty">No data objects found</div>`;
const cy = this.height / 2; }
const scale = (this.width / 2) * 0.9;
const mobile = this.isMobile();
const bubbleR = mobile ? 20 : 28;
const maxDocCount = Math.max(1, ...this.spaces.map(s => s.docCount));
const mobile = this.width < 500;
let svg = `<svg class="dc-svg" viewBox="0 0 ${this.width} ${this.height}" width="${this.width}" height="${this.height}">`; let svg = `<svg class="dc-svg" viewBox="0 0 ${this.width} ${this.height}" width="${this.width}" height="${this.height}">`;
// Render rings (outer to inner so inner draws on top) // Edges first (behind nodes)
for (const ring of [...RINGS].reverse()) { for (const edge of this.edges) {
const cfg = RING_CONFIG[ring]; const from = this.nodes.find(n => n.id === edge.from);
const r = cfg.radius * scale; const to = this.nodes.find(n => n.id === edge.to);
svg += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" if (!from || !to) continue;
stroke="${cfg.color}" stroke-width="1.5" stroke-dasharray="6 4" opacity="0.4"/>`; svg += `<line x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}"
stroke="${edge.color}" stroke-width="1" opacity="0.2"/>`;
// Ring label at top
const labelY = cy - r - 8;
svg += `<text x="${cx}" y="${labelY}" text-anchor="middle" fill="${cfg.color}"
font-size="${mobile ? 10 : 12}" font-weight="600" opacity="0.7">${cfg.label}</text>`;
} }
// Render bubbles per ring // Nodes
for (const ring of RINGS) { for (const node of this.nodes) {
const cfg = RING_CONFIG[ring]; const isHovered = this.hoveredId === node.id;
const ringR = cfg.radius * scale; const strokeW = isHovered ? 2.5 : (node.type === "space" ? 2 : 1.2);
const ringSpaces = groups[ring]; const fillOpacity = node.type === "doc" ? 0.5 : (node.type === "module" ? 0.3 : 0.15);
if (ringSpaces.length === 0) continue; const hoverOpacity = isHovered ? 0.8 : fillOpacity;
const angleStep = (2 * Math.PI) / ringSpaces.length; svg += `<g class="dc-node" data-id="${this.escAttr(node.id)}" data-space="${this.escAttr(node.space || "")}" data-mod="${this.escAttr(node.modId || "")}" style="cursor:pointer">`;
const startAngle = -Math.PI / 2; // Start from top
for (let i = 0; i < ringSpaces.length; i++) { // Circle
const sp = ringSpaces[i]; svg += `<circle cx="${node.x}" cy="${node.y}" r="${node.r}"
const angle = startAngle + i * angleStep; fill="${node.color}" fill-opacity="${hoverOpacity}"
const bx = cx + ringR * Math.cos(angle); stroke="${node.color}" stroke-width="${strokeW}"/>`;
const by = cy + ringR * Math.sin(angle);
// Scale bubble size by doc count (min 60%, max 100%) // Labels
const sizeScale = 0.6 + 0.4 * (sp.docCount / maxDocCount); if (node.type === "space") {
const r = bubbleR * sizeScale; const label = mobile ? node.label.slice(0, 8) : (node.label.length > 14 ? node.label.slice(0, 13) + "…" : node.label);
const isSelected = this.selected === sp.slug; svg += `<text x="${node.x}" y="${node.y + node.r + 14}" text-anchor="middle"
const isHovered = this.hoveredSlug === sp.slug; fill="var(--rs-text-primary)" font-size="${mobile ? 9 : 11}" font-weight="600"
const strokeW = isSelected ? 3 : (isHovered ? 2.5 : 1.5);
const fillOpacity = isSelected ? 0.25 : (isHovered ? 0.18 : 0.1);
// Bubble circle
svg += `<g class="dc-bubble" data-slug="${this.escAttr(sp.slug)}" style="cursor:pointer">`;
if (isSelected) {
svg += `<circle cx="${bx}" cy="${by}" r="${r + 5}" fill="none"
stroke="${cfg.color}" stroke-width="2" stroke-dasharray="4 3" opacity="0.6">
<animate attributeName="stroke-dashoffset" from="0" to="-14" dur="1s" repeatCount="indefinite"/>
</circle>`;
}
svg += `<circle cx="${bx}" cy="${by}" r="${r}" fill="${cfg.color}" fill-opacity="${fillOpacity}"
stroke="${cfg.color}" stroke-width="${strokeW}"/>`;
// Label
const label = mobile ? sp.name.slice(0, 6) : (sp.name.length > 12 ? sp.name.slice(0, 11) + "…" : sp.name);
svg += `<text x="${bx}" y="${by - 2}" text-anchor="middle" dominant-baseline="central"
fill="var(--rs-text-primary)" font-size="${mobile ? 8 : 10}" font-weight="500"
pointer-events="none">${this.esc(label)}</text>`; pointer-events="none">${this.esc(label)}</text>`;
} else if (node.type === "module") {
svg += `<text x="${node.x}" y="${node.y + 1}" text-anchor="middle" dominant-baseline="central"
fill="var(--rs-text-primary)" font-size="${mobile ? 10 : 12}"
pointer-events="none">${node.icon}</text>`;
if (!mobile) {
svg += `<text x="${node.x}" y="${node.y + node.r + 12}" text-anchor="middle"
fill="var(--rs-text-secondary)" font-size="9" pointer-events="none">${this.esc(node.label)}</text>`;
}
} else {
// Doc — show label on hover via title
}
// Doc count badge // Tooltip
svg += `<text x="${bx}" y="${by + (mobile ? 9 : 11)}" text-anchor="middle" const tooltipText = node.type === "space"
fill="${cfg.color}" font-size="${mobile ? 7 : 9}" font-weight="600" ? `${node.label} (${this.nodes.filter(n => n.space === node.space && n.type === "doc").length} docs)`
pointer-events="none">${sp.docCount}</text>`; : node.type === "module"
? `${node.label} — click to open in new tab`
: `${node.label} — click to open in new tab`;
svg += `<title>${this.esc(tooltipText)}</title>`;
// Tooltip (title element)
svg += `<title>${this.esc(sp.name)}${sp.docCount} doc${sp.docCount !== 1 ? "s" : ""} (${sp.visibility})</title>`;
svg += `</g>`; svg += `</g>`;
} }
}
// Center label
const totalDocs = this.spaces.reduce((s, sp) => s + sp.docCount, 0);
svg += `<text x="${cx}" y="${cy - 6}" text-anchor="middle" fill="var(--rs-text-primary)"
font-size="${mobile ? 16 : 20}" font-weight="700">${totalDocs}</text>`;
svg += `<text x="${cx}" y="${cy + 12}" text-anchor="middle" fill="var(--rs-text-muted)"
font-size="${mobile ? 9 : 11}">total documents</text>`;
svg += `</svg>`; svg += `</svg>`;
return svg; return svg;
} }
private renderDetailPanel(sp: SpaceBubble): string { private renderLegend(): string {
const ring = (sp.visibility as Ring) || "private"; // Collect unique modules present
const cfg = RING_CONFIG[ring]; const mods = new Map<string, { name: string; icon: string }>();
const visBadgeColor = cfg.color; for (const doc of this.docs) {
if (!mods.has(doc.modId)) mods.set(doc.modId, { name: doc.modName, icon: doc.modIcon });
}
// Collect unique visibility levels
const visLevels = new Set<string>();
for (const doc of this.docs) visLevels.add(doc.visibility);
return ` return `
<div class="dc-panel"> <div class="dc-legend">
<div class="dc-panel__header"> <div class="dc-legend__section">
<span class="dc-panel__name">${this.esc(sp.name)}</span> ${[...visLevels].map(v => `
<span class="dc-panel__vis" style="color:${visBadgeColor};border-color:${visBadgeColor}">${sp.visibility}</span> <span class="dc-legend__item">
<span class="dc-panel__count">${sp.docCount} doc${sp.docCount !== 1 ? "s" : ""}</span> <span class="dc-legend__dot" style="background:${VIS_COLORS[v] || VIS_COLORS.private}"></span>
</div> ${this.esc(v)}
${sp.modules.length === 0 </span>
? `<div class="dc-panel__empty">No documents in this space</div>`
: `<div class="dc-panel__modules">
${sp.modules.map(m => `
<div class="dc-panel__mod" data-nav-space="${this.escAttr(sp.slug)}" data-nav-mod="${this.escAttr(m.id)}">
<span class="dc-panel__mod-icon">${m.icon}</span>
<span class="dc-panel__mod-name">${this.esc(m.name)}</span>
<span class="dc-panel__mod-count">${m.docCount}</span>
</div>
`).join("")} `).join("")}
</div>` </div>
} <div class="dc-legend__section">
${[...mods.entries()].map(([, m]) => `
<span class="dc-legend__item">${m.icon} ${this.esc(m.name)}</span>
`).join("")}
</div>
<div class="dc-legend__hint">Click any node to open in new tab</div>
</div> </div>
`; `;
} }
// ── Events ──
private attachEvents() { private attachEvents() {
// Bubble click — toggle selection for (const g of this.shadow.querySelectorAll<SVGGElement>(".dc-node")) {
for (const g of this.shadow.querySelectorAll<SVGGElement>(".dc-bubble")) { const nodeId = g.dataset.id!;
const slug = g.dataset.slug!; const space = g.dataset.space || "";
const modId = g.dataset.mod || "";
g.addEventListener("click", () => { g.addEventListener("click", () => {
this.selected = this.selected === slug ? null : slug; if (!space) return;
this.render(); const modPath = modId
? (modId.startsWith("r") ? modId : `r${modId}`)
: "rspace";
window.open(rspaceNavUrl(space, modPath), "_blank");
}); });
g.addEventListener("mouseenter", () => { g.addEventListener("mouseenter", () => {
this.hoveredSlug = slug; this.hoveredId = nodeId;
// Update stroke without full re-render for perf const circle = g.querySelector("circle") as SVGCircleElement;
const circle = g.querySelector("circle:not([stroke-dasharray='4 3'])") as SVGCircleElement; if (circle) {
if (circle) circle.setAttribute("stroke-width", "2.5"); circle.setAttribute("stroke-width", "2.5");
circle.setAttribute("fill-opacity", "0.8");
}
// Highlight connected edges
const connectedEdges = this.edges.filter(e => e.from === nodeId || e.to === nodeId);
for (const edge of connectedEdges) {
const lines = this.shadow.querySelectorAll<SVGLineElement>("line");
for (const line of lines) {
const fromNode = this.nodes.find(n => n.id === edge.from);
const toNode = this.nodes.find(n => n.id === edge.to);
if (!fromNode || !toNode) continue;
if (Math.abs(parseFloat(line.getAttribute("x1")!) - fromNode.x) < 1 &&
Math.abs(parseFloat(line.getAttribute("y1")!) - fromNode.y) < 1) {
line.setAttribute("opacity", "0.6");
line.setAttribute("stroke-width", "2");
}
}
}
}); });
g.addEventListener("mouseleave", () => { g.addEventListener("mouseleave", () => {
this.hoveredSlug = null; this.hoveredId = null;
const circle = g.querySelector("circle:not([stroke-dasharray='4 3'])") as SVGCircleElement; const circle = g.querySelector("circle") as SVGCircleElement;
if (circle && this.selected !== slug) circle.setAttribute("stroke-width", "1.5"); if (circle) {
const node = this.nodes.find(n => n.id === nodeId);
if (node) {
circle.setAttribute("stroke-width", node.type === "space" ? "2" : "1.2");
const fo = node.type === "doc" ? "0.5" : (node.type === "module" ? "0.3" : "0.15");
circle.setAttribute("fill-opacity", fo);
}
}
// Reset edges
for (const line of this.shadow.querySelectorAll<SVGLineElement>("line")) {
line.setAttribute("opacity", "0.2");
line.setAttribute("stroke-width", "1");
}
}); });
} }
}
// Module row click — navigate // ── Helpers ──
for (const row of this.shadow.querySelectorAll<HTMLElement>(".dc-panel__mod")) {
row.addEventListener("click", () => {
const spaceSlug = row.dataset.navSpace!;
const modId = row.dataset.navMod!;
const modPath = modId.startsWith("r") ? modId : `r${modId}`;
window.location.href = `/${spaceSlug}/${modPath}`;
});
}
}
private esc(s: string): string { private esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@ -381,53 +501,38 @@ class FolkDataCloud extends HTMLElement {
border-radius: 8px; color: #eab308; font-size: 0.85rem; margin-bottom: 1rem; border-radius: 8px; color: #eab308; font-size: 0.85rem; margin-bottom: 1rem;
} }
.dc-empty {
text-align: center; padding: 3rem 1rem;
color: var(--rs-text-muted); font-size: 0.9rem;
}
.dc-svg { display: block; margin: 0 auto; max-width: 100%; height: auto; } .dc-svg { display: block; margin: 0 auto; max-width: 100%; height: auto; }
/* Detail panel */ /* Legend */
.dc-panel { .dc-legend {
width: 100%; max-width: 500px; margin-top: 1rem; width: 100%; display: flex; flex-wrap: wrap; gap: 0.75rem;
background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border); justify-content: center; align-items: center;
border-radius: 10px; padding: 1rem; animation: dc-slideIn 0.2s ease-out; padding: 0.75rem; margin-top: 0.5rem;
border-top: 1px solid var(--rs-border);
} }
@keyframes dc-slideIn { .dc-legend__section {
from { opacity: 0; transform: translateY(8px); } display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
to { opacity: 1; transform: translateY(0); }
} }
.dc-legend__item {
.dc-panel__header { display: flex; align-items: center; gap: 0.3rem;
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; font-size: 0.75rem; color: var(--rs-text-secondary);
padding-bottom: 0.5rem; border-bottom: 1px solid var(--rs-border);
} }
.dc-panel__name { font-weight: 600; font-size: 1rem; flex: 1; } .dc-legend__dot {
.dc-panel__vis { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 10px;
border: 1px solid; text-transform: uppercase; font-weight: 600; letter-spacing: 0.03em;
} }
.dc-panel__count { font-size: 0.8rem; color: var(--rs-text-muted); } .dc-legend__hint {
width: 100%; text-align: center;
.dc-panel__empty { font-size: 0.7rem; color: var(--rs-text-muted); margin-top: 0.25rem;
text-align: center; padding: 1rem; color: var(--rs-text-muted); font-size: 0.85rem;
}
.dc-panel__modules { display: flex; flex-direction: column; gap: 0.25rem; }
.dc-panel__mod {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.5rem 0.6rem; border-radius: 6px; cursor: pointer;
transition: background 0.1s;
}
.dc-panel__mod:hover { background: rgba(34, 211, 238, 0.08); }
.dc-panel__mod-icon { font-size: 1rem; flex-shrink: 0; }
.dc-panel__mod-name { flex: 1; font-size: 0.85rem; }
.dc-panel__mod-count {
padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem;
background: var(--rs-bg-primary, #0f172a); border: 1px solid var(--rs-border);
color: var(--rs-text-muted);
} }
@media (max-width: 500px) { @media (max-width: 500px) {
.dc-panel { max-height: 50vh; overflow-y: auto; } .dc-legend { gap: 0.4rem; padding: 0.5rem; }
.dc-panel__name { font-size: 0.9rem; } .dc-legend__item { font-size: 0.65rem; }
} }
`; `;
} }

View File

@ -7,7 +7,7 @@
*/ */
import type { ModuleInfo } from "../shared/module"; import type { ModuleInfo } from "../shared/module";
import { escapeHtml, escapeAttr, MODULE_LANDING_CSS, RICH_LANDING_CSS, versionAssetUrls, getSpaceShellMeta } from "./shell"; import { escapeHtml, escapeAttr, brandedAppName, MODULE_LANDING_CSS, RICH_LANDING_CSS, versionAssetUrls, getSpaceShellMeta } from "./shell";
/** Category → module IDs mapping for the tabbed showcase. */ /** Category → module IDs mapping for the tabbed showcase. */
const CATEGORY_GROUPS: Record<string, { label: string; icon: string; ids: string[] }> = { const CATEGORY_GROUPS: Record<string, { label: string; icon: string; ids: string[] }> = {
@ -40,7 +40,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
return `<a href="/${escapeAttr(m.id)}" class="lp-app-card"> return `<a href="/${escapeAttr(m.id)}" class="lp-app-card">
<span class="lp-app-card__icon">${m.icon}</span> <span class="lp-app-card__icon">${m.icon}</span>
<div class="lp-app-card__body"> <div class="lp-app-card__body">
<span class="lp-app-card__name">${escapeHtml(m.name)}</span> <span class="lp-app-card__name">${brandedAppName(m.name)}</span>
${m.standaloneDomain ? `<span class="lp-app-card__domain">${escapeHtml(m.standaloneDomain)}</span>` : ""} ${m.standaloneDomain ? `<span class="lp-app-card__domain">${escapeHtml(m.standaloneDomain)}</span>` : ""}
<span class="lp-app-card__desc">${escapeHtml(m.description)}</span> <span class="lp-app-card__desc">${escapeHtml(m.description)}</span>
</div> </div>
@ -131,7 +131,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
<div class="lp-hero__orb lp-hero__orb--indigo" aria-hidden="true"></div> <div class="lp-hero__orb lp-hero__orb--indigo" aria-hidden="true"></div>
<div class="lp-hero__grid" aria-hidden="true"></div> <div class="lp-hero__grid" aria-hidden="true"></div>
<div class="lp-hero__content"> <div class="lp-hero__content">
<span class="rl-tagline">Reclaim (you)<span style="color:#f97316">r</span><span style="color:#14b8a6">Space</span> on the internet</span> <span class="rl-tagline">Reclaim (you)<span style="color:#dc8300">r</span><span style="color:#35b9b9">Space</span> on the internet</span>
<h1 class="lp-wordmark"><span class="lp-wordmark__r">r</span><span class="lp-wordmark__space">Space</span></h1> <h1 class="lp-wordmark"><span class="lp-wordmark__r">r</span><span class="lp-wordmark__space">Space</span></h1>
<p class="lp-hero__tagline"> <p class="lp-hero__tagline">
Coordinate around what you care about &mdash; without stitching together a dozen corporate apps. Coordinate around what you care about &mdash; without stitching together a dozen corporate apps.
@ -293,7 +293,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
<!-- 7. Final CTA --> <!-- 7. Final CTA -->
<section class="lp-final-cta"> <section class="lp-final-cta">
<div class="rl-container" style="max-width:720px;text-align:center"> <div class="rl-container" style="max-width:720px;text-align:center">
<h2 class="lp-final-cta__heading">Reclaim (you)<span style="color:#f97316;-webkit-text-fill-color:#f97316">r</span><span style="color:#14b8a6;-webkit-text-fill-color:#14b8a6">Space</span>.</h2> <h2 class="lp-final-cta__heading">Reclaim (you)<span style="color:#dc8300;-webkit-text-fill-color:#dc8300">r</span><span style="color:#35b9b9;-webkit-text-fill-color:#35b9b9">Space</span>.</h2>
<p class="rl-subtext" style="font-size:1.15rem;line-height:1.7;text-align:center"> <p class="rl-subtext" style="font-size:1.15rem;line-height:1.7;text-align:center">
No algorithms deciding what you see. No ads. No data harvesting. No algorithms deciding what you see. No ads. No data harvesting.
Just one place for your group to plan, decide, fund, and build together. Just one place for your group to plan, decide, fund, and build together.
@ -382,7 +382,7 @@ export function renderSpaceDashboard(space: string, modules: ModuleInfo[]): stri
<a href="/${escapeAttr(m.id)}" class="sd-card" data-module="${escapeAttr(m.id)}"> <a href="/${escapeAttr(m.id)}" class="sd-card" data-module="${escapeAttr(m.id)}">
<div class="sd-card__icon">${m.icon}</div> <div class="sd-card__icon">${m.icon}</div>
<div class="sd-card__body"> <div class="sd-card__body">
<h3 class="sd-card__name">${escapeHtml(m.name)}</h3> <h3 class="sd-card__name">${brandedAppName(m.name)}</h3>
<p class="sd-card__desc">${escapeHtml(m.description)}</p> <p class="sd-card__desc">${escapeHtml(m.description)}</p>
</div> </div>
</a>`; </a>`;
@ -609,8 +609,8 @@ body {
} }
.lp-wordmark__r { .lp-wordmark__r {
font-weight: 400; font-weight: 400;
color: #f97316; color: #dc8300;
-webkit-text-fill-color: #f97316; -webkit-text-fill-color: #dc8300;
} }
.lp-wordmark__space { .lp-wordmark__space {
background: var(--rs-gradient-brand); background: var(--rs-gradient-brand);

View File

@ -2088,7 +2088,7 @@ function renderModuleSubNav(moduleId: string, spaceSlug: string, modules: Module
const minimizeBtn = `<button class="rapp-minimize-btn" id="header-minimize-btn" title="Minimize headers"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button>`; const minimizeBtn = `<button class="rapp-minimize-btn" id="header-minimize-btn" title="Minimize headers"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button>`;
const pills = [ const pills = [
`<a class="rapp-nav-pill" href="${base}" data-subnav-root>${escapeHtml(mod.name)}</a>`, `<a class="rapp-nav-pill" href="${base}" data-subnav-root>${brandedAppName(mod.name)}</a>`,
...items.map(it => ...items.map(it =>
`<a class="rapp-nav-pill" href="${base}/${escapeAttr(it.path)}">${it.icon ? escapeHtml(it.icon) + ' ' : ''}${escapeHtml(it.label)}</a>` `<a class="rapp-nav-pill" href="${base}/${escapeAttr(it.path)}">${it.icon ? escapeHtml(it.icon) + ' ' : ''}${escapeHtml(it.label)}</a>`
), ),
@ -2232,7 +2232,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
: `<div class="ml-hero"> : `<div class="ml-hero">
<div class="ml-container"> <div class="ml-container">
<span class="ml-icon">${mod.icon}</span> <span class="ml-icon">${mod.icon}</span>
<h1 class="ml-name">${escapeHtml(mod.name)}</h1> <h1 class="ml-name">${brandedAppName(mod.name)}</h1>
<p class="ml-desc">${escapeHtml(mod.description)}</p> <p class="ml-desc">${escapeHtml(mod.description)}</p>
<div class="ml-ctas"> <div class="ml-ctas">
<a href="${demoUrl}" class="ml-cta-primary" id="ml-primary">Try Demo</a> <a href="${demoUrl}" class="ml-cta-primary" id="ml-primary">Try Demo</a>
@ -2580,7 +2580,7 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
</div> </div>
${featuresGrid} ${featuresGrid}
<div class="rl-back"> <div class="rl-back">
<a href="/${escapeAttr(mod.id)}"> Back to ${escapeHtml(mod.name)}</a> <a href="/${escapeAttr(mod.id)}"> Back to ${brandedAppName(mod.name)}</a>
</div>`; </div>`;
return versionAssetUrls(`<!DOCTYPE html> return versionAssetUrls(`<!DOCTYPE html>
@ -2736,7 +2736,7 @@ export function renderOnboarding(opts: OnboardingOptions): string {
<div class="onboarding__card"> <div class="onboarding__card">
<div class="onboarding__glow"></div> <div class="onboarding__glow"></div>
<span class="onboarding__icon">${moduleIcon}</span> <span class="onboarding__icon">${moduleIcon}</span>
<h1 class="onboarding__title">${escapeHtml(moduleName)}</h1> <h1 class="onboarding__title">${brandedAppName(moduleName)}</h1>
<p class="onboarding__desc">${escapeHtml(moduleDescription)}</p> <p class="onboarding__desc">${escapeHtml(moduleDescription)}</p>
<p class="onboarding__hint">This app hasn't been used in <strong>${escapeHtml(spaceSlug)}</strong> yet. Load sample data to explore, or jump into the public demo.</p> <p class="onboarding__hint">This app hasn't been used in <strong>${escapeHtml(spaceSlug)}</strong> yet. Load sample data to explore, or jump into the public demo.</p>
<div class="onboarding__ctas"> <div class="onboarding__ctas">
@ -2925,6 +2925,14 @@ export function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
} }
/** Render rApp name with orange "r" prefix for visible HTML (not <title> tags). */
export function brandedAppName(name: string): string {
if (name.startsWith("r") && name.length > 1 && name[1] === name[1].toUpperCase()) {
return `<span style="color:#dc8300">r</span>${escapeHtml(name.slice(1))}`;
}
return escapeHtml(name);
}
export function escapeAttr(s: string): string { export function escapeAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
} }

View File

@ -19,16 +19,16 @@ export async function sendWelcomeEmail(email: string, username: string): Promise
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 520px; margin: 0 auto; padding: 24px;"> <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 520px; margin: 0 auto; padding: 24px;">
<div style="background: #1e293b; border-radius: 12px; padding: 28px; color: #e2e8f0;"> <div style="background: #1e293b; border-radius: 12px; padding: 28px; color: #e2e8f0;">
<h1 style="margin: 0 0 4px; font-size: 22px; color: #f1f5f9;"> <h1 style="margin: 0 0 4px; font-size: 22px; color: #f1f5f9;">
Welcome to <span style="color: #f97316;">r</span><span style="color: #14b8a6;">Space</span>, ${escapeHtml(displayName)}! Welcome to <span style="color: #dc8300;">r</span><span style="color: #35b9b9;">Space</span>, ${escapeHtml(displayName)}!
</h1> </h1>
<p style="margin: 0 0 24px; font-size: 15px; color: #94a3b8;"> <p style="margin: 0 0 24px; font-size: 15px; color: #94a3b8;">
Reclaim (you)<span style="color: #f97316;">r</span><span style="color: #14b8a6;">Space</span> on the internet &mdash; one place for your group to coordinate around what you care about. Reclaim (you)<span style="color: #dc8300;">r</span><span style="color: #35b9b9;">Space</span> on the internet &mdash; one place for your group to coordinate around what you care about.
</p> </p>
<div style="background: #0f172a; border-radius: 8px; padding: 16px; margin-bottom: 20px;"> <div style="background: #0f172a; border-radius: 8px; padding: 16px; margin-bottom: 20px;">
<p style="margin: 0 0 12px; font-size: 14px; color: #e2e8f0; line-height: 1.6;"> <p style="margin: 0 0 12px; font-size: 14px; color: #e2e8f0; line-height: 1.6;">
Instead of scattering your group across Slack, Google Docs, Trello, Zoom, Splitwise, and a dozen other apps &mdash; Instead of scattering your group across Slack, Google Docs, Trello, Zoom, Splitwise, and a dozen other apps &mdash;
<strong style="color: #14b8a6;">(you)rSpace puts it all in one shared workspace</strong> that your group actually owns. <strong style="color: #35b9b9;">(you)rSpace puts it all in one shared workspace</strong> that your group actually owns.
</p> </p>
<p style="margin: 0; font-size: 14px; color: #94a3b8; line-height: 1.6;"> <p style="margin: 0; font-size: 14px; color: #94a3b8; line-height: 1.6;">
Plan together. Decide together. Fund together. Build together. No corporate middlemen. Plan together. Decide together. Fund together. Build together. No corporate middlemen.
@ -72,7 +72,7 @@ export async function sendWelcomeEmail(email: string, username: string): Promise
<div style="text-align: center;"> <div style="text-align: center;">
<a href="${demoUrl}" style="display: inline-block; padding: 10px 22px; background: linear-gradient(135deg, #14b8a6, #0d9488); color: white; text-decoration: none; border-radius: 6px; font-size: 14px; font-weight: 600; margin-right: 8px;">Explore the Demo Space</a> <a href="${demoUrl}" style="display: inline-block; padding: 10px 22px; background: linear-gradient(135deg, #14b8a6, #0d9488); color: white; text-decoration: none; border-radius: 6px; font-size: 14px; font-weight: 600; margin-right: 8px;">Explore the Demo Space</a>
<a href="${createUrl}" style="display: inline-block; padding: 10px 22px; background: transparent; color: #14b8a6; text-decoration: none; border-radius: 6px; font-size: 14px; font-weight: 600; border: 1px solid #14b8a6;">Create Your Space</a> <a href="${createUrl}" style="display: inline-block; padding: 10px 22px; background: transparent; color: #35b9b9; text-decoration: none; border-radius: 6px; font-size: 14px; font-weight: 600; border: 1px solid #14b8a6;">Create Your Space</a>
</div> </div>
</div> </div>
<p style="margin: 14px 0 0; font-size: 11px; color: #64748b; text-align: center;"> <p style="margin: 14px 0 0; font-size: 11px; color: #64748b; text-align: center;">

View File

@ -126,6 +126,14 @@ const MODULE_CATEGORIES: Record<string, string> = {
rstack: "Platform", rstack: "Platform",
}; };
/** Color the "r" prefix orange in rApp names. */
function brandR(name: string): string {
if (name.startsWith("r") && name.length > 1 && name[1] === name[1].toUpperCase()) {
return `<span style="color:#dc8300">r</span>${name.slice(1)}`;
}
return name;
}
const CATEGORY_ORDER = [ const CATEGORY_ORDER = [
"Create", "Create",
"Communicate", "Communicate",
@ -392,7 +400,7 @@ export class RStackAppSwitcher extends HTMLElement {
${badgeHtml} ${badgeHtml}
<div class="item-text"> <div class="item-text">
<span class="item-name-row"> <span class="item-name-row">
<span class="item-name">${m.name}</span> <span class="item-name">${brandR(m.name)}</span>
${scopeBadge} ${scopeBadge}
</span> </span>
<span class="item-desc">${m.description}</span> <span class="item-desc">${m.description}</span>
@ -411,10 +419,10 @@ export class RStackAppSwitcher extends HTMLElement {
const badgeInfo = currentMod ? MODULE_BADGES[currentMod.id] : null; const badgeInfo = currentMod ? MODULE_BADGES[currentMod.id] : null;
const triggerContent = badgeInfo const triggerContent = badgeInfo
? `<span class="trigger-badge" style="background:${badgeInfo.color}">${badgeInfo.badge}</span> ${currentMod!.name}` ? `<span class="trigger-badge" style="background:${badgeInfo.color}">${badgeInfo.badge}</span> ${brandR(currentMod!.name)}`
: currentMod : currentMod
? `${currentMod.icon} ${currentMod.name}` ? `${currentMod.icon} ${brandR(currentMod.name)}`
: `<span class="trigger-badge rstack-gradient">r✨</span> rSpace`; : `<span class="trigger-badge rstack-gradient">r✨</span> <span style="color:#dc8300">r</span>Space`;
this.#shadow.innerHTML = ` this.#shadow.innerHTML = `
<style>${STYLES}</style> <style>${STYLES}</style>

View File

@ -512,8 +512,13 @@ export class RStackIdentity extends HTMLElement {
const session = getSession(); const session = getSession();
if (!session?.accessToken) return; if (!session?.accessToken) return;
// Don't nag if dismissed within the last 7 days
const NUDGE_KEY = "eid_device_nudge_dismissed"; const NUDGE_KEY = "eid_device_nudge_dismissed";
const DONE_KEY = "eid_device_nudge_done";
// Permanently suppress if multi-device already confirmed
if (localStorage.getItem(DONE_KEY) === "1") return;
// Don't nag if dismissed within the last 7 days
const dismissed = localStorage.getItem(NUDGE_KEY); const dismissed = localStorage.getItem(NUDGE_KEY);
if (dismissed && Date.now() - parseInt(dismissed, 10) < 7 * 24 * 60 * 60 * 1000) return; if (dismissed && Date.now() - parseInt(dismissed, 10) < 7 * 24 * 60 * 60 * 1000) return;
@ -527,7 +532,11 @@ export class RStackIdentity extends HTMLElement {
}); });
if (!res.ok) return; if (!res.ok) return;
const status = await res.json(); const status = await res.json();
if (status.multiDevice) return; // already has 2+ devices if (status.multiDevice) {
// Permanently mark as done — never nudge again
localStorage.setItem(DONE_KEY, "1");
return;
}
// Show a toast nudge with QR code // Show a toast nudge with QR code
const toast = document.createElement("div"); const toast = document.createElement("div");