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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 11:04:33 -07:00
parent 656b34ac1d
commit d96130f919
5 changed files with 386 additions and 3 deletions

View File

@ -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;
}

View File

@ -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 {
<button class="flows-toolbar-btn" data-canvas-action="sim" id="sim-btn">${this.isSimulating ? "⏸ Pause" : "▶ Play"}</button>
<button class="flows-toolbar-btn ${this.analyticsOpen ? "flows-toolbar-btn--active" : ""}" data-canvas-action="analytics">📊 Analytics</button>
<button class="flows-toolbar-btn" data-canvas-action="share">🔗 Share</button>
<button class="flows-toolbar-btn" data-canvas-action="tour">🎓 Tour</button>
</div>
<svg class="flows-canvas-svg" id="flow-canvas">
<defs>
@ -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 = `
<div class="flows-tour-backdrop" style="clip-path: polygon(
0% 0%, 0% 100%, ${spotX}px 100%, ${spotX}px ${spotY}px,
${spotX + spotW}px ${spotY}px, ${spotX + spotW}px ${spotY + spotH}px,
${spotX}px ${spotY + spotH}px, ${spotX}px 100%, 100% 100%, 100% 0%
)"></div>
<div class="flows-tour-spotlight" style="left:${spotX}px;top:${spotY}px;width:${spotW}px;height:${spotH}px"></div>
<div class="flows-tour-tooltip" style="top:${tooltipTop}px;left:${tooltipLeft}px">
<div class="flows-tour-tooltip__step">${stepNum} / ${totalSteps}</div>
<div class="flows-tour-tooltip__title">${step.title}</div>
<div class="flows-tour-tooltip__msg">${step.message}</div>
<div class="flows-tour-tooltip__nav">
${this.tourStep > 0 ? '<button class="flows-tour-tooltip__btn flows-tour-tooltip__btn--prev" data-tour="prev">Back</button>' : ''}
${step.advanceOnClick
? `<span class="flows-tour-tooltip__hint">or click the button above</span>`
: `<button class="flows-tour-tooltip__btn flows-tour-tooltip__btn--next" data-tour="next">${isLast ? 'Finish' : 'Next'}</button>`
}
<button class="flows-tour-tooltip__btn flows-tour-tooltip__btn--skip" data-tour="skip">Skip</button>
</div>
</div>
`;
// 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, "&amp;")

View File

@ -176,6 +176,11 @@ export function renderLanding(): string {
<p style="font-size:0.8rem;color:var(--rs-text-muted,#64748b);margin-top:0.75rem">
Build your flow in the demo, then sign in to save it to your own space.
</p>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-flows-app')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- What rFlows Does -->

View File

@ -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() : `<p>${mod.description || "No description available."}</p>`;
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" });

View File

@ -128,6 +128,7 @@ export function renderShell(opts: ShellOptions): string {
${head}
<style>${WELCOME_CSS}</style>
<style>${ACCESS_GATE_CSS}</style>
<style>${INFO_PANEL_CSS}</style>
</head>
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}" data-scope-overrides="${escapeAttr(JSON.stringify(scopeOverrides))}">
<header class="rstack-header">
@ -151,11 +152,20 @@ export function renderShell(opts: ShellOptions): string {
<rstack-history-panel></rstack-history-panel>
<div class="rstack-tab-row">
<rstack-tab-bar space="${escapeAttr(spaceSlug)}" active="" view-mode="flat"></rstack-tab-bar>
<button class="rapp-info-btn" id="rapp-info-btn" title="About this rApp"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></button>
</div>
<div id="rapp-info-panel" class="rapp-info-panel" style="display:none">
<div class="rapp-info-panel__header">
<span class="rapp-info-panel__title">About</span>
<button class="rapp-info-panel__close" id="rapp-info-close">&times;</button>
</div>
<div class="rapp-info-panel__body" id="rapp-info-body"></div>
</div>
<rstack-user-dashboard space="${escapeAttr(spaceSlug)}" style="display:none"></rstack-user-dashboard>
<main id="app"${moduleId === "rspace" ? ' class="canvas-layout"' : ''}>
${body}
</main>
<rstack-collab-overlay module-id="${escapeAttr(moduleId)}" space="${escapeAttr(spaceSlug)}"></rstack-collab-overlay>
${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 = '<div class="rapp-info-panel__loading">Loading…</div>';
fetch('/api/modules/' + encodeURIComponent(moduleId) + '/landing')
.then(r => r.json())
.then(data => {
if (infoPanelModuleId !== moduleId) return; // stale
infoBody.innerHTML = data.html || '<p>No info available.</p>';
infoPanel.dataset.loadedModule = moduleId;
})
.catch(() => { infoBody.innerHTML = '<p>Failed to load info.</p>'; });
}
}
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 {
></iframe>
<a class="rspace-iframe-newtab" href="${escapeAttr(appUrl)}" target="_blank" rel="noopener">Open in new tab </a>
</div>
<rstack-collab-overlay module-id="${escapeAttr(moduleId)}" space="${escapeAttr(spaceSlug)}" mode="badge-only"></rstack-collab-overlay>
<script type="module">
import '/shell.js';
@ -960,6 +1059,61 @@ const WELCOME_CSS = `
}
`;
const INFO_PANEL_CSS = `
.rapp-info-btn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; padding: 0; margin-left: 4px;
background: none; border: 1px solid transparent; border-radius: 6px;
color: var(--rs-text-muted); cursor: pointer; flex-shrink: 0;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.rapp-info-btn:hover { color: var(--rs-text-primary); background: var(--rs-bg-hover); border-color: var(--rs-border); }
.rapp-info-btn--active { color: var(--rs-primary); background: var(--rs-bg-hover); border-color: var(--rs-primary); }
.rapp-info-panel {
position: fixed; top: 80px; right: 16px; z-index: 9000;
width: min(480px, calc(100vw - 32px)); max-height: calc(100vh - 100px);
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
border-radius: 12px; box-shadow: 0 12px 40px rgba(0,0,0,0.4);
display: flex; flex-direction: column; overflow: hidden;
animation: rapp-info-in 0.2s ease-out;
}
@keyframes rapp-info-in {
from { opacity: 0; transform: translateY(-8px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.rapp-info-panel__header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; border-bottom: 1px solid var(--rs-border);
}
.rapp-info-panel__title {
font-size: 0.9rem; font-weight: 600; color: var(--rs-text-primary);
}
.rapp-info-panel__close {
background: none; border: none; color: var(--rs-text-muted);
font-size: 1.3rem; cursor: pointer; padding: 2px 6px; border-radius: 4px;
line-height: 1;
}
.rapp-info-panel__close:hover { color: var(--rs-text-primary); background: var(--rs-bg-hover); }
.rapp-info-panel__body {
padding: 16px; overflow-y: auto; flex: 1;
color: var(--rs-text-secondary); font-size: 0.88rem; line-height: 1.6;
}
.rapp-info-panel__body h1, .rapp-info-panel__body h2, .rapp-info-panel__body h3 {
color: var(--rs-text-primary); margin: 0 0 8px;
}
.rapp-info-panel__body h1 { font-size: 1.3rem; }
.rapp-info-panel__body h2 { font-size: 1.1rem; }
.rapp-info-panel__body h3 { font-size: 0.95rem; }
.rapp-info-panel__body p { margin: 0 0 12px; }
.rapp-info-panel__body a { color: var(--rs-primary); }
.rapp-info-panel__loading {
display: flex; align-items: center; justify-content: center;
padding: 32px; color: var(--rs-text-muted);
}
@media (max-width: 600px) {
.rapp-info-panel { right: 8px; left: 8px; width: auto; top: 70px; }
}
`;
// ── Module landing page (bare-domain rspace.online/{moduleId}) ──
@ -1035,6 +1189,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
</div>
</header>
${bodyContent}
<rstack-collab-overlay module-id="${escapeAttr(mod.id)}" mode="badge-only"></rstack-collab-overlay>
<script type="module">
import '/shell.js';
if ("serviceWorker" in navigator && location.hostname !== "localhost") {
@ -1378,6 +1533,7 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
</div>
</header>
${bodyContent}
<rstack-collab-overlay module-id="${escapeAttr(mod.id)}" mode="badge-only"></rstack-collab-overlay>
<script type="module">
import '/shell.js';
if ("serviceWorker" in navigator && location.hostname !== "localhost") {