From d96130f919e633c9e0a21c11ef045f81f5d3e5d5 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 11 Mar 2026 11:04:33 -0700 Subject: [PATCH] feat: fix tab duplication, add info popover, add rFlows guided tour Fix tab duplication by syncing tabBar state after layer-add, deduplicating Automerge layers, and syncing app-switcher tabs to CommunitySync. Add info icon popover that lazy-loads module landing page content with auto-show on first visit. Add 5-step guided tour for rFlows with spotlight overlay, auto-advance on click, and toolbar restart button. Co-Authored-By: Claude Opus 4.6 --- modules/rflows/components/flows.css | 92 +++++++++++ modules/rflows/components/folk-flows-app.ts | 121 +++++++++++++++ modules/rflows/landing.ts | 5 + server/index.ts | 9 ++ server/shell.ts | 162 +++++++++++++++++++- 5 files changed, 386 insertions(+), 3 deletions(-) diff --git a/modules/rflows/components/flows.css b/modules/rflows/components/flows.css index 42a1b11..7f1f384 100644 --- a/modules/rflows/components/flows.css +++ b/modules/rflows/components/flows.css @@ -1169,3 +1169,95 @@ .flows-detail { padding: 12px 12px 48px; } .flows-detail--fullpage { padding: 0; } } + +/* ── Guided Tour ────────────────────────────────────── */ +.flows-tour-overlay { + position: absolute; inset: 0; z-index: 10000; + pointer-events: none; +} +.flows-tour-backdrop { + position: absolute; inset: 0; + background: rgba(0, 0, 0, 0.55); + pointer-events: auto; + transition: clip-path 0.3s ease; +} +.flows-tour-spotlight { + position: absolute; + border: 2px solid var(--rs-primary, #06b6d4); + border-radius: 8px; + box-shadow: 0 0 0 4px rgba(6, 182, 212, 0.25); + pointer-events: none; + transition: all 0.3s ease; +} +.flows-tour-tooltip { + position: absolute; + width: min(320px, calc(100% - 24px)); + background: var(--rs-bg-surface, #1e293b); + border: 1px solid var(--rs-border, #334155); + border-radius: 12px; + padding: 16px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); + color: var(--rs-text-primary, #f1f5f9); + pointer-events: auto; + animation: flows-tour-pop 0.25s ease-out; +} +@keyframes flows-tour-pop { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +.flows-tour-tooltip__step { + font-size: 0.7rem; + color: var(--rs-text-muted, #64748b); + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.flows-tour-tooltip__title { + font-size: 1rem; + font-weight: 600; + margin-bottom: 6px; + background: linear-gradient(135deg, #06b6d4, #7c3aed); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} +.flows-tour-tooltip__msg { + font-size: 0.85rem; + color: var(--rs-text-secondary, #94a3b8); + line-height: 1.5; + margin-bottom: 12px; +} +.flows-tour-tooltip__nav { + display: flex; + align-items: center; + gap: 8px; +} +.flows-tour-tooltip__btn { + padding: 6px 14px; + border-radius: 6px; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + border: none; + transition: background 0.15s, transform 0.1s; +} +.flows-tour-tooltip__btn:hover { transform: translateY(-1px); } +.flows-tour-tooltip__btn--next { + background: linear-gradient(135deg, #06b6d4, #7c3aed); + color: white; +} +.flows-tour-tooltip__btn--prev { + background: var(--rs-btn-secondary-bg, #334155); + color: var(--rs-text-secondary, #94a3b8); +} +.flows-tour-tooltip__btn--skip { + background: none; + color: var(--rs-text-muted, #64748b); + margin-left: auto; +} +.flows-tour-tooltip__btn--skip:hover { color: var(--rs-text-primary, #f1f5f9); } +.flows-tour-tooltip__hint { + font-size: 0.72rem; + color: var(--rs-text-muted, #64748b); + font-style: italic; +} diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 66b53af..ddbb233 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -154,6 +154,17 @@ class FolkFlowsApp extends HTMLElement { private flowManagerOpen = false; private _lfcUnsub: (() => void) | null = null; + // Tour state + private tourActive = false; + private tourStep = 0; + private static readonly TOUR_STEPS = [ + { target: '[data-canvas-action="add-source"]', title: "Add a Source", message: "Sources represent inflows of resources. Click the + Source button to add one.", advanceOnClick: true }, + { target: '[data-canvas-action="add-funnel"]', title: "Add a Funnel", message: "Funnels allocate resources between spending and overflow. Click + Funnel to add one.", advanceOnClick: true }, + { target: '[data-canvas-action="add-outcome"]', title: "Add an Outcome", message: "Outcomes are the goals your flow is working towards. Click + Outcome to add one.", advanceOnClick: true }, + { target: '.flows-node', title: "Wire a Connection", message: "Drag from a port (the colored dots on nodes) to another node to create a flow connection. Click Next when ready.", advanceOnClick: false }, + { target: '[data-canvas-action="sim"]', title: "Run Simulation", message: "Press Play to simulate resource flows through your system. Click Play to finish the tour!", advanceOnClick: true }, + ]; + constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); @@ -914,6 +925,7 @@ class FolkFlowsApp extends HTMLElement { + @@ -1026,6 +1038,10 @@ class FolkFlowsApp extends HTMLElement { if (!this.canvasInitialized) { this.canvasInitialized = true; requestAnimationFrame(() => this.fitView()); + // Auto-start tour on first visit + if (!localStorage.getItem("rflows_tour_done")) { + setTimeout(() => this.startTour(), 1200); + } } this.loadFromHash(); } @@ -1424,6 +1440,7 @@ class FolkFlowsApp extends HTMLElement { else if (action === "analytics") this.toggleAnalytics(); else if (action === "quick-fund") this.quickFund(); else if (action === "share") this.shareState(); + else if (action === "tour") this.startTour(); else if (action === "zoom-in") { this.canvasZoom = Math.min(4, this.canvasZoom * 1.2); this.updateCanvasTransform(); } else if (action === "zoom-out") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); } else if (action === "flow-picker") this.toggleFlowDropdown(); @@ -4953,6 +4970,110 @@ class FolkFlowsApp extends HTMLElement { this.render(); } + // ── Guided Tour ── + + startTour() { + this.tourActive = true; + this.tourStep = 0; + this.renderTourOverlay(); + } + + private advanceTour() { + this.tourStep++; + if (this.tourStep >= FolkFlowsApp.TOUR_STEPS.length) { + this.endTour(); + } else { + this.renderTourOverlay(); + } + } + + private endTour() { + this.tourActive = false; + this.tourStep = 0; + localStorage.setItem("rflows_tour_done", "1"); + const overlay = this.shadow.getElementById("flows-tour-overlay"); + if (overlay) overlay.remove(); + } + + private renderTourOverlay() { + // Remove existing overlay + let overlay = this.shadow.getElementById("flows-tour-overlay"); + if (!overlay) { + overlay = document.createElement("div"); + overlay.id = "flows-tour-overlay"; + overlay.className = "flows-tour-overlay"; + const container = this.shadow.getElementById("canvas-container"); + if (container) container.appendChild(overlay); + else this.shadow.appendChild(overlay); + } + + const step = FolkFlowsApp.TOUR_STEPS[this.tourStep]; + const targetEl = this.shadow.querySelector(step.target) as HTMLElement | null; + + // Compute spotlight position + let spotX = 0, spotY = 0, spotW = 120, spotH = 40; + if (targetEl) { + const containerEl = this.shadow.getElementById("canvas-container") || this.shadow.host as HTMLElement; + const containerRect = containerEl.getBoundingClientRect(); + const rect = targetEl.getBoundingClientRect(); + spotX = rect.left - containerRect.left - 6; + spotY = rect.top - containerRect.top - 6; + spotW = rect.width + 12; + spotH = rect.height + 12; + } + + const isLast = this.tourStep >= FolkFlowsApp.TOUR_STEPS.length - 1; + const stepNum = this.tourStep + 1; + const totalSteps = FolkFlowsApp.TOUR_STEPS.length; + + // Position tooltip below target + const tooltipTop = spotY + spotH + 12; + const tooltipLeft = Math.max(8, spotX); + + overlay.innerHTML = ` +
+
+
+
${stepNum} / ${totalSteps}
+
${step.title}
+
${step.message}
+
+ ${this.tourStep > 0 ? '' : ''} + ${step.advanceOnClick + ? `or click the button above` + : `` + } + +
+
+ `; + + // Wire tour navigation + overlay.querySelectorAll("[data-tour]").forEach(btn => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const action = (btn as HTMLElement).dataset.tour; + if (action === "next") this.advanceTour(); + else if (action === "prev") { this.tourStep = Math.max(0, this.tourStep - 1); this.renderTourOverlay(); } + else if (action === "skip") this.endTour(); + }); + }); + + // For advanceOnClick steps, listen for the target button click + if (step.advanceOnClick && targetEl) { + const handler = () => { + targetEl.removeEventListener("click", handler); + // Delay slightly so the action completes first + setTimeout(() => this.advanceTour(), 300); + }; + targetEl.addEventListener("click", handler); + } + } + private esc(s: string): string { return s .replace(/&/g, "&") diff --git a/modules/rflows/landing.ts b/modules/rflows/landing.ts index d5257b5..96563c1 100644 --- a/modules/rflows/landing.ts +++ b/modules/rflows/landing.ts @@ -176,6 +176,11 @@ export function renderLanding(): string {

Build your flow in the demo, then sign in to save it to your own space.

+

+ + Start Guided Tour → + +

diff --git a/server/index.ts b/server/index.ts index 8f3d026..9e47487 100644 --- a/server/index.ts +++ b/server/index.ts @@ -693,6 +693,15 @@ app.get("/api/modules", (c) => { return c.json({ modules: getModuleInfoList() }); }); +// ── Module landing HTML API (for info popover) ── +app.get("/api/modules/:moduleId/landing", (c) => { + const moduleId = c.req.param("moduleId"); + const mod = getModule(moduleId); + if (!mod) return c.json({ error: "Module not found" }, 404); + const html = mod.landingPage ? mod.landingPage() : `

${mod.description || "No description available."}

`; + return c.json({ html }); +}); + // ── x402 test endpoint (no auth, payment-gated only) ── import { setupX402FromEnv } from "../shared/x402/hono-middleware"; const x402Test = setupX402FromEnv({ description: "x402 test endpoint", resource: "/api/x402-test" }); diff --git a/server/shell.ts b/server/shell.ts index 2e369b1..79c6a10 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -128,6 +128,7 @@ export function renderShell(opts: ShellOptions): string { ${head} +
@@ -151,11 +152,20 @@ export function renderShell(opts: ShellOptions): string {
+ +
+
${body}
+ ${renderWelcomeOverlay()} @@ -198,6 +208,77 @@ export function renderShell(opts: ShellOptions): string { } } + // ── Info popover: shows landing page content for the active rApp ── + (function() { + const infoBtn = document.getElementById('rapp-info-btn'); + const infoPanel = document.getElementById('rapp-info-panel'); + const infoBody = document.getElementById('rapp-info-body'); + const infoClose = document.getElementById('rapp-info-close'); + if (!infoBtn || !infoPanel || !infoBody) return; + + let infoPanelModuleId = ''; + + function showInfoPanel(moduleId) { + infoPanelModuleId = moduleId; + infoPanel.style.display = ''; + infoBtn.classList.add('rapp-info-btn--active'); + // Lazy-load content + if (infoPanel.dataset.loadedModule !== moduleId) { + infoBody.innerHTML = '
Loading…
'; + fetch('/api/modules/' + encodeURIComponent(moduleId) + '/landing') + .then(r => r.json()) + .then(data => { + if (infoPanelModuleId !== moduleId) return; // stale + infoBody.innerHTML = data.html || '

No info available.

'; + infoPanel.dataset.loadedModule = moduleId; + }) + .catch(() => { infoBody.innerHTML = '

Failed to load info.

'; }); + } + } + + function hideInfoPanel() { + infoPanel.style.display = 'none'; + infoBtn.classList.remove('rapp-info-btn--active'); + } + + infoBtn.addEventListener('click', () => { + if (infoPanel.style.display !== 'none') { hideInfoPanel(); return; } + // Resolve the currently active module from the tab bar + const tb = document.querySelector('rstack-tab-bar'); + const activeLayerId = tb?.getAttribute('active') || ''; + const activeModuleId = activeLayerId.replace('layer-', '') || '${escapeAttr(moduleId)}'; + showInfoPanel(activeModuleId); + }); + if (infoClose) infoClose.addEventListener('click', hideInfoPanel); + + // Reset when switching tabs + document.addEventListener('layer-view-mode', hideInfoPanel); + const tb = document.querySelector('rstack-tab-bar'); + if (tb) { + tb.addEventListener('layer-switch', (e) => { + hideInfoPanel(); + // Reset cached module so next open loads fresh content for new tab + infoPanel.dataset.loadedModule = ''; + }); + } + + // Auto-show on first visit per rApp + const seenKey = 'rapp_info_seen_' + '${escapeAttr(moduleId)}'; + if (!localStorage.getItem(seenKey)) { + // Only auto-show for logged-in users (not on first-ever visit) + try { + if (localStorage.getItem('encryptid_session')) { + setTimeout(() => { showInfoPanel('${escapeAttr(moduleId)}'); }, 800); + localStorage.setItem(seenKey, '1'); + } + } catch(e) {} + } + + // Expose for tour integration + window.__rspaceShowInfo = showInfoPanel; + window.__rspaceHideInfo = hideInfoPanel; + })(); + // ── Invite acceptance on page load ── (function() { var params = new URLSearchParams(window.location.search); @@ -346,6 +427,16 @@ export function renderShell(opts: ShellOptions): string { return m ? m.name : id; } + // Helper: deduplicate layers by moduleId (keeps first occurrence) + function deduplicateLayers(list) { + const seen = new Set(); + return list.filter(l => { + if (seen.has(l.moduleId)) return false; + seen.add(l.moduleId); + return true; + }); + } + // Helper: create a layer object function makeLayer(id, order) { return { @@ -424,6 +515,7 @@ export function renderShell(opts: ShellOptions): string { layers.push(makeLayer(moduleId, layers.length)); } saveTabs(); + tabBar.setLayers(layers); if (tabCache) { tabCache.switchTo(moduleId).then(ok => { if (ok) { @@ -582,7 +674,10 @@ export function renderShell(opts: ShellOptions): string { } // Add tab if not already open if (!layers.find(l => l.moduleId === moduleId)) { - layers.push(makeLayer(moduleId, layers.length)); + const newLayer = makeLayer(moduleId, layers.length); + layers.push(newLayer); + // Sync to Automerge so other windows see this tab + if (communitySync) communitySync.addLayer(newLayer); } saveTabs(); tabBar.setLayers(layers); @@ -637,6 +732,8 @@ export function renderShell(opts: ShellOptions): string { // Expose tabBar for CommunitySync integration window.__rspaceTabBar = tabBar; + // Sync reference (set when CommunitySync connects) + let communitySync = null; // ── CommunitySync: merge with Automerge once connected ── // NOTE: The TAB LIST is shared via Automerge (which modules are open), @@ -645,6 +742,7 @@ export function renderShell(opts: ShellOptions): string { document.addEventListener('community-sync-ready', (e) => { const sync = e.detail?.sync; if (!sync) return; + communitySync = sync; const localActiveId = 'layer-' + currentModuleId; @@ -656,7 +754,7 @@ export function renderShell(opts: ShellOptions): string { const newLayer = makeLayer(currentModuleId, remoteLayers.length); sync.addLayer(newLayer); } - layers = sync.getLayers(); + layers = deduplicateLayers(sync.getLayers()); tabBar.setLayers(layers); tabBar.setFlows(sync.getFlows()); } else { @@ -695,7 +793,7 @@ export function renderShell(opts: ShellOptions): string { // Never touch the active tab: it's managed locally by TabCache // and the tab-bar component via layer-switch events. sync.addEventListener('change', () => { - layers = sync.getLayers(); + layers = deduplicateLayers(sync.getLayers()); tabBar.setLayers(layers); tabBar.setFlows(sync.getFlows()); const viewMode = sync.doc.layerViewMode; @@ -796,6 +894,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string { > Open in new tab ↗ +