From 184da55813147e63fc5f7dffe88434e401bdb37f Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 15 Apr 2026 13:08:36 -0400 Subject: [PATCH] feat: brandedAppName helper, rData cloud refactor, branding color tweaks Co-Authored-By: Claude Opus 4.6 --- modules/rdata/components/folk-content-tree.ts | 5 +- modules/rdata/components/folk-data-cloud.ts | 639 ++++++++++-------- server/landing.ts | 14 +- server/shell.ts | 16 +- server/welcome-email.ts | 8 +- shared/components/rstack-app-switcher.ts | 16 +- shared/components/rstack-identity.ts | 13 +- 7 files changed, 420 insertions(+), 291 deletions(-) diff --git a/modules/rdata/components/folk-content-tree.ts b/modules/rdata/components/folk-content-tree.ts index c2cee645..27c9c4e7 100644 --- a/modules/rdata/components/folk-content-tree.ts +++ b/modules/rdata/components/folk-content-tree.ts @@ -9,6 +9,7 @@ import { TourEngine } from '../../../shared/tour-engine'; import type { TourStep } from '../../../shared/tour-engine'; import { startPresenceHeartbeat } from '../../../shared/collab-presence'; +import { rspaceNavUrl } from '../../../shared/url-helpers'; interface TreeItem { docId: string; @@ -284,10 +285,8 @@ class FolkContentTree extends HTMLElement { } 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}`; - window.location.href = `${base}/${modPath}`; + window.open(rspaceNavUrl(this.space, modPath), "_blank"); } private render() { diff --git a/modules/rdata/components/folk-data-cloud.ts b/modules/rdata/components/folk-data-cloud.ts index 39700371..fbd0cb55 100644 --- a/modules/rdata/components/folk-data-cloud.ts +++ b/modules/rdata/components/folk-data-cloud.ts @@ -1,89 +1,99 @@ /** - * folk-data-cloud — Concentric-ring SVG visualization of data objects - * across user spaces, grouped by visibility level (private/permissioned/public). + * folk-data-cloud — Graph visualization of all data objects (documents) + * 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 module row → navigate to that module page. + * Click any node → opens that module in a new tab. + * Demo mode shows dummy document nodes when unauthenticated. */ import { startPresenceHeartbeat } from '../../../shared/collab-presence'; +import { rspaceNavUrl } from '../../../shared/url-helpers'; -interface SpaceInfo { - slug: string; - name: string; +// ── Types ── + +interface DocNode { + docId: string; + title: string; + modId: string; + modName: string; + modIcon: string; + space: string; + spaceName: string; visibility: string; - role?: string; - relationship?: string; } -interface ModuleSummary { +interface GraphNode { id: string; - name: string; + label: 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 { - docCount: number; - modules: ModuleSummary[]; +interface GraphEdge { + from: string; + to: string; + color: string; } -type Ring = "private" | "permissioned" | "public"; +// ── Colors ── -const RING_CONFIG: Record = { - private: { color: "#ef4444", label: "Private", radius: 0.28 }, - permissioned: { color: "#eab308", label: "Permissioned", radius: 0.54 }, - public: { color: "#22c55e", label: "Public", radius: 0.80 }, +const VIS_COLORS: Record = { + private: "#ef4444", + permissioned: "#eab308", + public: "#22c55e", }; -const RINGS: Ring[] = ["private", "permissioned", "public"]; +const MOD_COLORS: Record = { + 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[] = [ - { slug: "personal", name: "Personal", visibility: "private", role: "owner", relationship: "owner", docCount: 14, modules: [ - { id: "notes", name: "rNotes", icon: "📝", docCount: 5 }, - { id: "tasks", name: "rTasks", icon: "📋", docCount: 4 }, - { id: "cal", name: "rCal", icon: "📅", docCount: 3 }, - { id: "wallet", name: "rWallet", icon: "💰", docCount: 2 }, - ]}, - { slug: "my-project", name: "Side Project", visibility: "private", role: "owner", relationship: "owner", docCount: 8, modules: [ - { id: "docs", name: "rDocs", icon: "📓", docCount: 3 }, - { id: "tasks", name: "rTasks", icon: "📋", docCount: 5 }, - ]}, - { slug: "team-alpha", name: "Team Alpha", visibility: "permissioned", role: "owner", relationship: "owner", docCount: 22, modules: [ - { id: "docs", name: "rDocs", icon: "📓", docCount: 6 }, - { id: "vote", name: "rVote", icon: "🗳", docCount: 4 }, - { id: "flows", name: "rFlows", icon: "🌊", docCount: 3 }, - { id: "tasks", name: "rTasks", icon: "📋", docCount: 5 }, - { id: "cal", name: "rCal", icon: "📅", docCount: 4 }, - ]}, - { slug: "dao-gov", name: "DAO Governance", visibility: "permissioned", relationship: "member", docCount: 11, modules: [ - { id: "vote", name: "rVote", icon: "🗳", docCount: 7 }, - { id: "flows", name: "rFlows", icon: "🌊", docCount: 4 }, - ]}, - { 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 }, - ]}, +function modColor(modId: string): string { + return MOD_COLORS[modId] || "#94a3b8"; +} + +// ── Demo data ── + +const DEMO_DOCS: DocNode[] = [ + { docId: "demo:notes:notebooks:nb1", title: "Product Roadmap", modId: "notes", modName: "rNotes", modIcon: "📝", space: "demo", spaceName: "Demo Space", visibility: "public" }, + { docId: "demo:notes:notebooks:nb2", title: "Meeting Notes", modId: "notes", modName: "rNotes", modIcon: "📝", space: "demo", spaceName: "Demo Space", visibility: "public" }, + { 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" }, + { docId: "demo:vote:proposals:p2", title: "Budget Q2", modId: "vote", modName: "rVote", modIcon: "🗳", space: "demo", spaceName: "Demo Space", visibility: "public" }, + { docId: "demo:tasks:boards:b1", title: "Dev Board", modId: "tasks", modName: "rTasks", modIcon: "📋", space: "demo", spaceName: "Demo Space", visibility: "public" }, + { docId: "demo:tasks:boards:b2", title: "Design Sprint", modId: "tasks", modName: "rTasks", modIcon: "📋", space: "demo", spaceName: "Demo Space", visibility: "public" }, + { docId: "demo:tasks:boards:b3", title: "Bug Tracker", modId: "tasks", modName: "rTasks", modIcon: "📋", space: "demo", spaceName: "Demo Space", visibility: "public" }, + { docId: "demo:cal:calendars:c1", title: "Team Calendar", modId: "cal", modName: "rCal", modIcon: "📅", space: "demo", spaceName: "Demo Space", visibility: "public" }, + { 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" }, + { docId: "demo:flows:streams:s1", title: "Contributor Fund", modId: "flows", modName: "rFlows", modIcon: "🌊", space: "demo", spaceName: "Demo Space", visibility: "public" }, + { docId: "demo:flows:streams:s2", title: "Community Pool", modId: "flows", modName: "rFlows", modIcon: "🌊", space: "demo", spaceName: "Demo Space", visibility: "public" }, + { 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" }, ]; +// ── Component ── + class FolkDataCloud extends HTMLElement { private shadow: ShadowRoot; private space = "demo"; - private spaces: SpaceBubble[] = []; + private docs: DocNode[] = []; + private nodes: GraphNode[] = []; + private edges: GraphEdge[] = []; private loading = true; private isDemo = false; - private selected: string | null = null; - private hoveredSlug: string | null = null; - private width = 600; - private height = 600; + private hoveredId: string | null = null; + private width = 700; + private height = 700; private _stopPresence: (() => void) | null = null; private _resizeObserver: ResizeObserver | null = null; @@ -95,10 +105,10 @@ class FolkDataCloud extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "demo"; this._resizeObserver = new ResizeObserver((entries) => { - const w = entries[0]?.contentRect.width || 600; - this.width = Math.min(w, 800); - this.height = this.width; - if (!this.loading) this.render(); + const w = entries[0]?.contentRect.width || 700; + this.width = Math.min(w, 900); + this.height = Math.max(this.width * 0.85, 500); + if (!this.loading) { this.layout(); this.render(); } }); this._resizeObserver.observe(this); this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rdata', context: 'Cloud' })); @@ -110,6 +120,8 @@ class FolkDataCloud extends HTMLElement { this._resizeObserver?.disconnect(); } + // ── Data loading ── + private async loadData() { this.loading = true; this.render(); @@ -117,9 +129,8 @@ class FolkDataCloud extends HTMLElement { const token = localStorage.getItem("rspace_auth"); if (!token) { this.isDemo = true; - this.spaces = DEMO_SPACES; - this.loading = false; - this.render(); + this.docs = DEMO_DOCS; + this.finalize(); return; } @@ -129,238 +140,347 @@ class FolkDataCloud extends HTMLElement { signal: AbortSignal.timeout(8000), }); 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 bubbles: SpaceBubble[] = await Promise.all( - spacesData.spaces.map(async (sp) => { - try { - const treeResp = await fetch( - `${base}/api/content-tree?space=${encodeURIComponent(sp.slug)}`, - { signal: AbortSignal.timeout(8000) } - ); - if (!treeResp.ok) return { ...sp, docCount: 0, modules: [] }; - const tree = await treeResp.json(); - const modules: ModuleSummary[] = (tree.modules || []).map((m: any) => ({ - id: m.id, - name: m.name, - icon: m.icon, - docCount: m.collections.reduce((s: number, c: any) => s + c.items.length, 0), - })); - const docCount = modules.reduce((s, m) => s + m.docCount, 0); - return { ...sp, docCount, modules }; - } catch { - return { ...sp, docCount: 0, modules: [] }; - } - }) - ); + const allDocs: DocNode[] = []; - this.spaces = bubbles; + await Promise.all(spaces.map(async (sp) => { + try { + const resp = await fetch( + `${base}/api/content-tree?space=${encodeURIComponent(sp.slug)}`, + { signal: AbortSignal.timeout(8000) } + ); + if (!resp.ok) return; + const tree = await resp.json(); + for (const mod of (tree.modules || [])) { + for (const col of (mod.collections || [])) { + for (const item of (col.items || [])) { + allDocs.push({ + docId: item.docId, + title: item.title || col.collection, + modId: mod.id, + modName: mod.name, + modIcon: mod.icon, + space: sp.slug, + spaceName: sp.name, + visibility: sp.visibility || "private", + }); + } + } + } + } catch { /* skip space */ } + })); + + this.docs = allDocs; this.isDemo = false; } catch { this.isDemo = true; - this.spaces = DEMO_SPACES; + this.docs = DEMO_DOCS; } + this.finalize(); + } + + private finalize() { this.loading = false; + this.layout(); this.render(); } - private groupByRing(): Record { - const groups: Record = { private: [], permissioned: [], public: [] }; - for (const sp of this.spaces) { - const ring = (sp.visibility as Ring) || "private"; - (groups[ring] || groups.private).push(sp); + // ── Graph layout ── + // Central node per space, module nodes around it, doc nodes orbiting modules. + + private layout() { + 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 }>(); + for (const doc of this.docs) { + if (!spaceMap.has(doc.space)) { + spaceMap.set(doc.space, { name: doc.spaceName, vis: doc.visibility, mods: new Map() }); + } + const sp = spaceMap.get(doc.space)!; + if (!sp.mods.has(doc.modId)) sp.mods.set(doc.modId, []); + sp.mods.get(doc.modId)!.push(doc); + } + + const spaceKeys = [...spaceMap.keys()]; + 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) }); + } + } } - return groups; } - private isMobile(): boolean { - return this.width < 500; - } + // ── Rendering ── private render() { - const selected = this.selected ? this.spaces.find(s => s.slug === this.selected) : null; - this.shadow.innerHTML = `
${this.isDemo ? `
Sign in to see your data cloud
` : ""} - ${this.loading ? this.renderLoading() : this.renderSVG()} - ${selected ? this.renderDetailPanel(selected) : ""} + ${this.loading ? this.renderLoading() : this.renderGraph()} + ${!this.loading ? this.renderLegend() : ""}
`; - this.attachEvents(); } private renderLoading(): string { - const cx = this.width / 2; - const cy = this.height / 2; return ` - ${RINGS.map(ring => { - const r = RING_CONFIG[ring].radius * (this.width / 2) * 0.9; - return ``; - }).join("")} - Loading your data cloud… `; } - private renderSVG(): string { - const groups = this.groupByRing(); - const cx = this.width / 2; - 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)); + private renderGraph(): string { + if (this.nodes.length === 0) { + return `
No data objects found
`; + } + const mobile = this.width < 500; let svg = ``; - // Render rings (outer to inner so inner draws on top) - for (const ring of [...RINGS].reverse()) { - const cfg = RING_CONFIG[ring]; - const r = cfg.radius * scale; - svg += ``; - - // Ring label at top - const labelY = cy - r - 8; - svg += `${cfg.label}`; + // Edges first (behind nodes) + for (const edge of this.edges) { + const from = this.nodes.find(n => n.id === edge.from); + const to = this.nodes.find(n => n.id === edge.to); + if (!from || !to) continue; + svg += ``; } - // Render bubbles per ring - for (const ring of RINGS) { - const cfg = RING_CONFIG[ring]; - const ringR = cfg.radius * scale; - const ringSpaces = groups[ring]; - if (ringSpaces.length === 0) continue; + // Nodes + for (const node of this.nodes) { + const isHovered = this.hoveredId === node.id; + const strokeW = isHovered ? 2.5 : (node.type === "space" ? 2 : 1.2); + const fillOpacity = node.type === "doc" ? 0.5 : (node.type === "module" ? 0.3 : 0.15); + const hoverOpacity = isHovered ? 0.8 : fillOpacity; - const angleStep = (2 * Math.PI) / ringSpaces.length; - const startAngle = -Math.PI / 2; // Start from top + svg += ``; - for (let i = 0; i < ringSpaces.length; i++) { - const sp = ringSpaces[i]; - const angle = startAngle + i * angleStep; - const bx = cx + ringR * Math.cos(angle); - const by = cy + ringR * Math.sin(angle); + // Circle + svg += ``; - // Scale bubble size by doc count (min 60%, max 100%) - const sizeScale = 0.6 + 0.4 * (sp.docCount / maxDocCount); - const r = bubbleR * sizeScale; - const isSelected = this.selected === sp.slug; - const isHovered = this.hoveredSlug === sp.slug; - const strokeW = isSelected ? 3 : (isHovered ? 2.5 : 1.5); - const fillOpacity = isSelected ? 0.25 : (isHovered ? 0.18 : 0.1); - - // Bubble circle - svg += ``; - if (isSelected) { - svg += ` - - `; - } - svg += ``; - - // Label - const label = mobile ? sp.name.slice(0, 6) : (sp.name.length > 12 ? sp.name.slice(0, 11) + "…" : sp.name); - svg += ` 14 ? node.label.slice(0, 13) + "…" : node.label); + svg += `${this.esc(label)}`; - - // Doc count badge - svg += `${sp.docCount}`; - - // Tooltip (title element) - svg += `${this.esc(sp.name)} — ${sp.docCount} doc${sp.docCount !== 1 ? "s" : ""} (${sp.visibility})`; - svg += ``; + } else if (node.type === "module") { + svg += `${node.icon}`; + if (!mobile) { + svg += `${this.esc(node.label)}`; + } + } else { + // Doc — show label on hover via title } - } - // Center label - const totalDocs = this.spaces.reduce((s, sp) => s + sp.docCount, 0); - svg += `${totalDocs}`; - svg += `total documents`; + // Tooltip + const tooltipText = node.type === "space" + ? `${node.label} (${this.nodes.filter(n => n.space === node.space && n.type === "doc").length} docs)` + : node.type === "module" + ? `${node.label} — click to open in new tab` + : `${node.label} — click to open in new tab`; + svg += `${this.esc(tooltipText)}`; + + svg += ``; + } svg += ``; return svg; } - private renderDetailPanel(sp: SpaceBubble): string { - const ring = (sp.visibility as Ring) || "private"; - const cfg = RING_CONFIG[ring]; - const visBadgeColor = cfg.color; + private renderLegend(): string { + // Collect unique modules present + const mods = new Map(); + 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(); + for (const doc of this.docs) visLevels.add(doc.visibility); return ` -
-
- ${this.esc(sp.name)} - ${sp.visibility} - ${sp.docCount} doc${sp.docCount !== 1 ? "s" : ""} +
+
+ ${[...visLevels].map(v => ` + + + ${this.esc(v)} + + `).join("")}
- ${sp.modules.length === 0 - ? `
No documents in this space
` - : `
- ${sp.modules.map(m => ` -
- ${m.icon} - ${this.esc(m.name)} - ${m.docCount} -
- `).join("")} -
` - } +
+ ${[...mods.entries()].map(([, m]) => ` + ${m.icon} ${this.esc(m.name)} + `).join("")} +
+
Click any node to open in new tab
`; } + // ── Events ── + private attachEvents() { - // Bubble click — toggle selection - for (const g of this.shadow.querySelectorAll(".dc-bubble")) { - const slug = g.dataset.slug!; + for (const g of this.shadow.querySelectorAll(".dc-node")) { + const nodeId = g.dataset.id!; + const space = g.dataset.space || ""; + const modId = g.dataset.mod || ""; g.addEventListener("click", () => { - this.selected = this.selected === slug ? null : slug; - this.render(); + if (!space) return; + const modPath = modId + ? (modId.startsWith("r") ? modId : `r${modId}`) + : "rspace"; + window.open(rspaceNavUrl(space, modPath), "_blank"); }); g.addEventListener("mouseenter", () => { - this.hoveredSlug = slug; - // Update stroke without full re-render for perf - const circle = g.querySelector("circle:not([stroke-dasharray='4 3'])") as SVGCircleElement; - if (circle) circle.setAttribute("stroke-width", "2.5"); + this.hoveredId = nodeId; + const circle = g.querySelector("circle") as SVGCircleElement; + if (circle) { + 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("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", () => { - this.hoveredSlug = null; - const circle = g.querySelector("circle:not([stroke-dasharray='4 3'])") as SVGCircleElement; - if (circle && this.selected !== slug) circle.setAttribute("stroke-width", "1.5"); - }); - } - - // Module row click — navigate - for (const row of this.shadow.querySelectorAll(".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}`; + this.hoveredId = null; + const circle = g.querySelector("circle") as SVGCircleElement; + 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("line")) { + line.setAttribute("opacity", "0.2"); + line.setAttribute("stroke-width", "1"); + } }); } } + // ── Helpers ── + private esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } @@ -381,53 +501,38 @@ class FolkDataCloud extends HTMLElement { 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; } - /* Detail panel */ - .dc-panel { - width: 100%; max-width: 500px; margin-top: 1rem; - background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border); - border-radius: 10px; padding: 1rem; animation: dc-slideIn 0.2s ease-out; + /* Legend */ + .dc-legend { + width: 100%; display: flex; flex-wrap: wrap; gap: 0.75rem; + justify-content: center; align-items: center; + padding: 0.75rem; margin-top: 0.5rem; + border-top: 1px solid var(--rs-border); } - @keyframes dc-slideIn { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } + .dc-legend__section { + display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; } - - .dc-panel__header { - display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; - padding-bottom: 0.5rem; border-bottom: 1px solid var(--rs-border); + .dc-legend__item { + display: flex; align-items: center; gap: 0.3rem; + font-size: 0.75rem; color: var(--rs-text-secondary); } - .dc-panel__name { font-weight: 600; font-size: 1rem; flex: 1; } - .dc-panel__vis { - 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-legend__dot { + width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } - .dc-panel__count { font-size: 0.8rem; color: var(--rs-text-muted); } - - .dc-panel__empty { - 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); + .dc-legend__hint { + width: 100%; text-align: center; + font-size: 0.7rem; color: var(--rs-text-muted); margin-top: 0.25rem; } @media (max-width: 500px) { - .dc-panel { max-height: 50vh; overflow-y: auto; } - .dc-panel__name { font-size: 0.9rem; } + .dc-legend { gap: 0.4rem; padding: 0.5rem; } + .dc-legend__item { font-size: 0.65rem; } } `; } diff --git a/server/landing.ts b/server/landing.ts index c38e3578..d206607f 100644 --- a/server/landing.ts +++ b/server/landing.ts @@ -7,7 +7,7 @@ */ 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. */ const CATEGORY_GROUPS: Record = { @@ -40,7 +40,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string { return ` ${m.icon}
- ${escapeHtml(m.name)} + ${brandedAppName(m.name)} ${m.standaloneDomain ? `${escapeHtml(m.standaloneDomain)}` : ""} ${escapeHtml(m.description)}
@@ -131,7 +131,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
- Reclaim (you)rSpace on the internet + Reclaim (you)rSpace on the internet

rSpace

Coordinate around what you care about — without stitching together a dozen corporate apps. @@ -293,7 +293,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string {

-

Reclaim (you)rSpace.

+

Reclaim (you)rSpace.

No algorithms deciding what you see. No ads. No data harvesting. 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

-

${escapeHtml(m.name)}

+

${brandedAppName(m.name)}

${escapeHtml(m.description)}

`; @@ -609,8 +609,8 @@ body { } .lp-wordmark__r { font-weight: 400; - color: #f97316; - -webkit-text-fill-color: #f97316; + color: #dc8300; + -webkit-text-fill-color: #dc8300; } .lp-wordmark__space { background: var(--rs-gradient-brand); diff --git a/server/shell.ts b/server/shell.ts index 0e951ef7..4d1dec29 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -2088,7 +2088,7 @@ function renderModuleSubNav(moduleId: string, spaceSlug: string, modules: Module const minimizeBtn = ``; const pills = [ - `${escapeHtml(mod.name)}`, + `${brandedAppName(mod.name)}`, ...items.map(it => `${it.icon ? escapeHtml(it.icon) + ' ' : ''}${escapeHtml(it.label)}` ), @@ -2232,7 +2232,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string { : `
${mod.icon} -

${escapeHtml(mod.name)}

+

${brandedAppName(mod.name)}

${escapeHtml(mod.description)}

Try Demo @@ -2580,7 +2580,7 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
${featuresGrid} `; return versionAssetUrls(` @@ -2736,7 +2736,7 @@ export function renderOnboarding(opts: OnboardingOptions): string {
${moduleIcon} -

${escapeHtml(moduleName)}

+

${brandedAppName(moduleName)}

${escapeHtml(moduleDescription)}

This app hasn't been used in ${escapeHtml(spaceSlug)} yet. Load sample data to explore, or jump into the public demo.

@@ -2925,6 +2925,14 @@ export function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } +/** Render rApp name with orange "r" prefix for visible HTML (not 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 { return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">"); } diff --git a/server/welcome-email.ts b/server/welcome-email.ts index 1e1c9e48..306377bb 100644 --- a/server/welcome-email.ts +++ b/server/welcome-email.ts @@ -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="background: #1e293b; border-radius: 12px; padding: 28px; color: #e2e8f0;"> <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> <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 — 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 — one place for your group to coordinate around what you care about. </p> <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;"> Instead of scattering your group across Slack, Google Docs, Trello, Zoom, Splitwise, and a dozen other apps — - <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 style="margin: 0; font-size: 14px; color: #94a3b8; line-height: 1.6;"> 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;"> <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> <p style="margin: 14px 0 0; font-size: 11px; color: #64748b; text-align: center;"> diff --git a/shared/components/rstack-app-switcher.ts b/shared/components/rstack-app-switcher.ts index 6934cb66..52f283d0 100644 --- a/shared/components/rstack-app-switcher.ts +++ b/shared/components/rstack-app-switcher.ts @@ -126,6 +126,14 @@ const MODULE_CATEGORIES: Record<string, string> = { 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 = [ "Create", "Communicate", @@ -392,7 +400,7 @@ export class RStackAppSwitcher extends HTMLElement { ${badgeHtml} <div class="item-text"> <span class="item-name-row"> - <span class="item-name">${m.name}</span> + <span class="item-name">${brandR(m.name)}</span> ${scopeBadge} </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 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.icon} ${currentMod.name}` - : `<span class="trigger-badge rstack-gradient">r✨</span> rSpace`; + ? `${currentMod.icon} ${brandR(currentMod.name)}` + : `<span class="trigger-badge rstack-gradient">r✨</span> <span style="color:#dc8300">r</span>Space`; this.#shadow.innerHTML = ` <style>${STYLES}</style> diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index bc9320db..0761e9ee 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -512,8 +512,13 @@ export class RStackIdentity extends HTMLElement { const session = getSession(); if (!session?.accessToken) return; - // Don't nag if dismissed within the last 7 days 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); 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; 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 const toast = document.createElement("div");