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 { padding: 12px 12px 48px; }
.flows-detail--fullpage { padding: 0; } .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 flowManagerOpen = false;
private _lfcUnsub: (() => void) | null = null; 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() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); 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" 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 ${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="share">🔗 Share</button>
<button class="flows-toolbar-btn" data-canvas-action="tour">🎓 Tour</button>
</div> </div>
<svg class="flows-canvas-svg" id="flow-canvas"> <svg class="flows-canvas-svg" id="flow-canvas">
<defs> <defs>
@ -1026,6 +1038,10 @@ class FolkFlowsApp extends HTMLElement {
if (!this.canvasInitialized) { if (!this.canvasInitialized) {
this.canvasInitialized = true; this.canvasInitialized = true;
requestAnimationFrame(() => this.fitView()); requestAnimationFrame(() => this.fitView());
// Auto-start tour on first visit
if (!localStorage.getItem("rflows_tour_done")) {
setTimeout(() => this.startTour(), 1200);
}
} }
this.loadFromHash(); this.loadFromHash();
} }
@ -1424,6 +1440,7 @@ class FolkFlowsApp extends HTMLElement {
else if (action === "analytics") this.toggleAnalytics(); else if (action === "analytics") this.toggleAnalytics();
else if (action === "quick-fund") this.quickFund(); else if (action === "quick-fund") this.quickFund();
else if (action === "share") this.shareState(); 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-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 === "zoom-out") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); }
else if (action === "flow-picker") this.toggleFlowDropdown(); else if (action === "flow-picker") this.toggleFlowDropdown();
@ -4953,6 +4970,110 @@ class FolkFlowsApp extends HTMLElement {
this.render(); 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 { private esc(s: string): string {
return s return s
.replace(/&/g, "&amp;") .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"> <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. Build your flow in the demo, then sign in to save it to your own space.
</p> </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> </div>
<!-- What rFlows Does --> <!-- What rFlows Does -->

View File

@ -693,6 +693,15 @@ app.get("/api/modules", (c) => {
return c.json({ modules: getModuleInfoList() }); 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) ── // ── x402 test endpoint (no auth, payment-gated only) ──
import { setupX402FromEnv } from "../shared/x402/hono-middleware"; import { setupX402FromEnv } from "../shared/x402/hono-middleware";
const x402Test = setupX402FromEnv({ description: "x402 test endpoint", resource: "/api/x402-test" }); const x402Test = setupX402FromEnv({ description: "x402 test endpoint", resource: "/api/x402-test" });

View File

@ -128,6 +128,7 @@ export function renderShell(opts: ShellOptions): string {
${head} ${head}
<style>${WELCOME_CSS}</style> <style>${WELCOME_CSS}</style>
<style>${ACCESS_GATE_CSS}</style> <style>${ACCESS_GATE_CSS}</style>
<style>${INFO_PANEL_CSS}</style>
</head> </head>
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}" data-scope-overrides="${escapeAttr(JSON.stringify(scopeOverrides))}"> <body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}" data-scope-overrides="${escapeAttr(JSON.stringify(scopeOverrides))}">
<header class="rstack-header"> <header class="rstack-header">
@ -151,11 +152,20 @@ export function renderShell(opts: ShellOptions): string {
<rstack-history-panel></rstack-history-panel> <rstack-history-panel></rstack-history-panel>
<div class="rstack-tab-row"> <div class="rstack-tab-row">
<rstack-tab-bar space="${escapeAttr(spaceSlug)}" active="" view-mode="flat"></rstack-tab-bar> <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> </div>
<rstack-user-dashboard space="${escapeAttr(spaceSlug)}" style="display:none"></rstack-user-dashboard> <rstack-user-dashboard space="${escapeAttr(spaceSlug)}" style="display:none"></rstack-user-dashboard>
<main id="app"${moduleId === "rspace" ? ' class="canvas-layout"' : ''}> <main id="app"${moduleId === "rspace" ? ' class="canvas-layout"' : ''}>
${body} ${body}
</main> </main>
<rstack-collab-overlay module-id="${escapeAttr(moduleId)}" space="${escapeAttr(spaceSlug)}"></rstack-collab-overlay>
${renderWelcomeOverlay()} ${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 ── // ── Invite acceptance on page load ──
(function() { (function() {
var params = new URLSearchParams(window.location.search); var params = new URLSearchParams(window.location.search);
@ -346,6 +427,16 @@ export function renderShell(opts: ShellOptions): string {
return m ? m.name : id; 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 // Helper: create a layer object
function makeLayer(id, order) { function makeLayer(id, order) {
return { return {
@ -424,6 +515,7 @@ export function renderShell(opts: ShellOptions): string {
layers.push(makeLayer(moduleId, layers.length)); layers.push(makeLayer(moduleId, layers.length));
} }
saveTabs(); saveTabs();
tabBar.setLayers(layers);
if (tabCache) { if (tabCache) {
tabCache.switchTo(moduleId).then(ok => { tabCache.switchTo(moduleId).then(ok => {
if (ok) { if (ok) {
@ -582,7 +674,10 @@ export function renderShell(opts: ShellOptions): string {
} }
// Add tab if not already open // Add tab if not already open
if (!layers.find(l => l.moduleId === moduleId)) { 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(); saveTabs();
tabBar.setLayers(layers); tabBar.setLayers(layers);
@ -637,6 +732,8 @@ export function renderShell(opts: ShellOptions): string {
// Expose tabBar for CommunitySync integration // Expose tabBar for CommunitySync integration
window.__rspaceTabBar = tabBar; window.__rspaceTabBar = tabBar;
// Sync reference (set when CommunitySync connects)
let communitySync = null;
// ── CommunitySync: merge with Automerge once connected ── // ── CommunitySync: merge with Automerge once connected ──
// NOTE: The TAB LIST is shared via Automerge (which modules are open), // 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) => { document.addEventListener('community-sync-ready', (e) => {
const sync = e.detail?.sync; const sync = e.detail?.sync;
if (!sync) return; if (!sync) return;
communitySync = sync;
const localActiveId = 'layer-' + currentModuleId; const localActiveId = 'layer-' + currentModuleId;
@ -656,7 +754,7 @@ export function renderShell(opts: ShellOptions): string {
const newLayer = makeLayer(currentModuleId, remoteLayers.length); const newLayer = makeLayer(currentModuleId, remoteLayers.length);
sync.addLayer(newLayer); sync.addLayer(newLayer);
} }
layers = sync.getLayers(); layers = deduplicateLayers(sync.getLayers());
tabBar.setLayers(layers); tabBar.setLayers(layers);
tabBar.setFlows(sync.getFlows()); tabBar.setFlows(sync.getFlows());
} else { } else {
@ -695,7 +793,7 @@ export function renderShell(opts: ShellOptions): string {
// Never touch the active tab: it's managed locally by TabCache // Never touch the active tab: it's managed locally by TabCache
// and the tab-bar component via layer-switch events. // and the tab-bar component via layer-switch events.
sync.addEventListener('change', () => { sync.addEventListener('change', () => {
layers = sync.getLayers(); layers = deduplicateLayers(sync.getLayers());
tabBar.setLayers(layers); tabBar.setLayers(layers);
tabBar.setFlows(sync.getFlows()); tabBar.setFlows(sync.getFlows());
const viewMode = sync.doc.layerViewMode; const viewMode = sync.doc.layerViewMode;
@ -796,6 +894,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
></iframe> ></iframe>
<a class="rspace-iframe-newtab" href="${escapeAttr(appUrl)}" target="_blank" rel="noopener">Open in new tab </a> <a class="rspace-iframe-newtab" href="${escapeAttr(appUrl)}" target="_blank" rel="noopener">Open in new tab </a>
</div> </div>
<rstack-collab-overlay module-id="${escapeAttr(moduleId)}" space="${escapeAttr(spaceSlug)}" mode="badge-only"></rstack-collab-overlay>
<script type="module"> <script type="module">
import '/shell.js'; 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}) ── // ── Module landing page (bare-domain rspace.online/{moduleId}) ──
@ -1035,6 +1189,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
</div> </div>
</header> </header>
${bodyContent} ${bodyContent}
<rstack-collab-overlay module-id="${escapeAttr(mod.id)}" mode="badge-only"></rstack-collab-overlay>
<script type="module"> <script type="module">
import '/shell.js'; import '/shell.js';
if ("serviceWorker" in navigator && location.hostname !== "localhost") { if ("serviceWorker" in navigator && location.hostname !== "localhost") {
@ -1378,6 +1533,7 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
</div> </div>
</header> </header>
${bodyContent} ${bodyContent}
<rstack-collab-overlay module-id="${escapeAttr(mod.id)}" mode="badge-only"></rstack-collab-overlay>
<script type="module"> <script type="module">
import '/shell.js'; import '/shell.js';
if ("serviceWorker" in navigator && location.hostname !== "localhost") { if ("serviceWorker" in navigator && location.hostname !== "localhost") {