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:
parent
656b34ac1d
commit
d96130f919
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "&")
|
||||
|
|
|
|||
|
|
@ -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 →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- What rFlows Does -->
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
162
server/shell.ts
162
server/shell.ts
|
|
@ -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">×</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") {
|
||||
|
|
|
|||
Loading…
Reference in New Issue