diff --git a/lib/presence.ts b/lib/presence.ts
index 3aca8ea..07191d8 100644
--- a/lib/presence.ts
+++ b/lib/presence.ts
@@ -45,6 +45,7 @@ export class PresenceManager extends EventTarget {
#panX = 0;
#panY = 0;
#scale = 1;
+ #soloModeHandler: ((e: Event) => void) | null = null;
constructor(container: HTMLElement, peerId: string, username?: string) {
super();
@@ -57,6 +58,18 @@ export class PresenceManager extends EventTarget {
// Start fade check interval
this.#fadeInterval = window.setInterval(() => this.#checkFades(), 1000);
+
+ // Listen for solo-mode-change events from collab overlay
+ this.#soloModeHandler = (e: Event) => {
+ const solo = (e as CustomEvent).detail?.solo ?? false;
+ this.setVisible(!solo);
+ };
+ document.addEventListener('solo-mode-change', this.#soloModeHandler);
+
+ // Apply initial solo mode state
+ if (localStorage.getItem('rspace_solo_mode') === '1') {
+ this.setVisible(false);
+ }
}
get localPeerId() {
@@ -171,6 +184,10 @@ export class PresenceManager extends EventTarget {
for (const [peerId] of this.#users) {
this.removeUser(peerId);
}
+ if (this.#soloModeHandler) {
+ document.removeEventListener('solo-mode-change', this.#soloModeHandler);
+ this.#soloModeHandler = null;
+ }
}
#refreshCursors() {
diff --git a/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts b/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts
index aaef262..0448c76 100644
--- a/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts
+++ b/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts
@@ -5,6 +5,8 @@
* Multiplayer: uses CrowdSurfLocalFirstClient for real-time sync via Automerge.
*/
+import { TourEngine } from '../../../shared/tour-engine';
+import type { TourStep } from '../../../shared/tour-engine';
import { CrowdSurfLocalFirstClient } from '../local-first-client';
import type { CrowdSurfDoc, CrowdSurfPrompt, Contribution } from '../schemas';
import { getDecayProgress, getTimeRemaining, getRightSwipeCount, isReadyToTrigger, getUrgency, parseContributions } from '../schemas';
@@ -26,9 +28,17 @@ function getMyDid(): string | null {
type ViewTab = 'discover' | 'create' | 'rank' | 'profile';
+const CROWDSURF_TOUR_STEPS: TourStep[] = [
+ { target: '.cs-nav-btn[data-tab="discover"]', title: 'Discover', message: 'Swipe through nearby activities — join ones that interest you.' },
+ { target: '#cs-current-card', title: 'Swipe Cards', message: 'Swipe right to join or left to skip. You can also tap the buttons below.' },
+ { target: '.cs-nav-btn[data-tab="create"]', title: 'Create', message: 'Propose a new activity for your community.' },
+ { target: '.cs-nav-btn[data-tab="rank"]', title: 'Rank', message: 'Elo pairwise ranking to surface the best activities.' },
+];
+
class FolkCrowdSurfDashboard extends HTMLElement {
private shadow: ShadowRoot;
private space: string;
+ private _tour!: TourEngine;
// State
private activeTab: ViewTab = 'discover';
@@ -66,6 +76,12 @@ class FolkCrowdSurfDashboard extends HTMLElement {
super();
this.shadow = this.attachShadow({ mode: 'open' });
this.space = this.getAttribute('space') || 'demo';
+ this._tour = new TourEngine(
+ this.shadow,
+ CROWDSURF_TOUR_STEPS,
+ 'crowdsurf_tour_done',
+ () => this.shadow.querySelector('.cs-app') as HTMLElement,
+ );
}
connectedCallback() {
@@ -109,6 +125,9 @@ class FolkCrowdSurfDashboard extends HTMLElement {
this.loading = false;
this.render();
this.bindEvents();
+ if (!localStorage.getItem('crowdsurf_tour_done')) {
+ setTimeout(() => this._tour.start(), 800);
+ }
// Check expiry every 30s
this._expiryTimer = window.setInterval(() => this.checkExpiry(), 30000);
@@ -181,8 +200,13 @@ class FolkCrowdSurfDashboard extends HTMLElement {
this.loading = false;
this.render();
this.bindEvents();
+ if (!localStorage.getItem('crowdsurf_tour_done')) {
+ setTimeout(() => this._tour.start(), 800);
+ }
}
+ startTour() { this._tour.start(); }
+
// ── Swipe mechanics ──
private getActivePrompts(): CrowdSurfPrompt[] {
@@ -333,6 +357,7 @@ class FolkCrowdSurfDashboard extends HTMLElement {
`;
+ this._tour.renderOverlay();
}
private renderActiveView(isLive: boolean): string {
@@ -356,6 +381,7 @@ class FolkCrowdSurfDashboard extends HTMLElement {
CrowdSurf
${isLive ? 'LIVE' : ''}
${this.space === 'demo' ? 'DEMO' : ''}
+
@@ -740,6 +766,9 @@ class FolkCrowdSurfDashboard extends HTMLElement {
// Rank: start
this.shadow.querySelector('[data-action="rank-start"]')?.addEventListener('click', () => this.loadRankPair());
+
+ // Tour button
+ this.shadow.getElementById('btn-tour')?.addEventListener('click', () => this.startTour());
}
private setupSwipeGestures(card: HTMLElement) {
diff --git a/modules/rbnb/components/folk-bnb-view.ts b/modules/rbnb/components/folk-bnb-view.ts
index 5e263b7..44f850a 100644
--- a/modules/rbnb/components/folk-bnb-view.ts
+++ b/modules/rbnb/components/folk-bnb-view.ts
@@ -5,9 +5,17 @@
* host dashboard (my listings + incoming requests), stay request sidebar.
*/
+import { LightTourEngine } from '../../../shared/tour-engine';
+import type { TourStep } from '../../../shared/tour-engine';
import './folk-listing';
import './folk-stay-request';
+const BNB_TOUR_STEPS: TourStep[] = [
+ { target: '.bnb-search', title: 'Search', message: 'Filter listings by location, type, or economy model.' },
+ { target: '#bnb-content', title: 'Browse Stays', message: 'Tap a listing card to see details and stay requests.' },
+ { target: '.bnb-view__toggle[data-view="map"]', title: 'Map View', message: 'See all listings on an interactive map.' },
+];
+
// ── Leaflet CDN Loader ──
let _leafletReady = false;
@@ -56,6 +64,7 @@ class FolkBnbView extends HTMLElement {
#economyFilter = '';
#map: any = null;
#mapContainer: HTMLElement | null = null;
+ #tour: LightTourEngine | null = null;
connectedCallback() {
this.#space = this.getAttribute('space') || 'demo';
@@ -129,6 +138,7 @@ class FolkBnbView extends HTMLElement {
+
@@ -166,6 +176,13 @@ class FolkBnbView extends HTMLElement {
`;
this.#wireEvents();
+
+ // Tour
+ this.#tour = new LightTourEngine(this.querySelector('.bnb-view') as HTMLElement || this, BNB_TOUR_STEPS, 'rbnb_tour_done');
+ if (!localStorage.getItem('rbnb_tour_done')) {
+ setTimeout(() => this.#tour?.start(), 800);
+ }
+ this.querySelector('#btn-tour')?.addEventListener('click', () => this.#tour?.start());
}
#wireEvents() {
diff --git a/modules/rdata/components/folk-content-tree.ts b/modules/rdata/components/folk-content-tree.ts
index 5bf00d1..f3908c0 100644
--- a/modules/rdata/components/folk-content-tree.ts
+++ b/modules/rdata/components/folk-content-tree.ts
@@ -6,6 +6,9 @@
* click-to-navigate, demo mode fallback.
*/
+import { TourEngine } from '../../../shared/tour-engine';
+import type { TourStep } from '../../../shared/tour-engine';
+
interface TreeItem {
docId: string;
title: string;
@@ -118,10 +121,17 @@ const COLLECTION_ICONS: Record = {
pages: "📃", books: "📚", items: "🏷", channels: "📺",
};
+const CONTENT_TREE_TOUR_STEPS: TourStep[] = [
+ { target: '.ct-search', title: 'Search Content', message: 'Find items across all modules by name or tag.' },
+ { target: '.ct-tags', title: 'Filter', message: 'Narrow results by module or tag.' },
+ { target: '.ct-tree', title: 'Content Tree', message: 'Browse all space data hierarchically — expand modules and collections.' },
+];
+
class FolkContentTree extends HTMLElement {
private shadow: ShadowRoot;
private space = "demo";
private data: TreeData | null = null;
+ private _tour!: TourEngine;
private search = "";
private activeTags = new Set();
private activeModules = new Set();
@@ -133,6 +143,12 @@ class FolkContentTree extends HTMLElement {
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
+ this._tour = new TourEngine(
+ this.shadow,
+ CONTENT_TREE_TOUR_STEPS,
+ 'rdata_tour_done',
+ () => this.shadow.querySelector('.ct') as HTMLElement,
+ );
}
connectedCallback() {
@@ -181,8 +197,13 @@ class FolkContentTree extends HTMLElement {
this.expanded.add(`mod:${mod.id}`);
}
this.render();
+ if (!localStorage.getItem('rdata_tour_done')) {
+ setTimeout(() => this._tour.start(), 800);
+ }
}
+ startTour() { this._tour.start(); }
+
private matchesSearch(text: string): boolean {
if (!this.search) return true;
return text.toLowerCase().includes(this.search.toLowerCase());
@@ -301,7 +322,7 @@ class FolkContentTree extends HTMLElement {
`).join("")}
${this.activeTags.size > 0 ? `` : ""}
` : ""}
- ${totalModules} module${totalModules !== 1 ? "s" : ""}, ${totalItems} item${totalItems !== 1 ? "s" : ""}
+ ${totalModules} module${totalModules !== 1 ? "s" : ""}, ${totalItems} item${totalItems !== 1 ? "s" : ""}
${totalItems === 0 ? `
No content found${this.search || this.activeTags.size ? " matching your filters" : " in this space"}.
` : ""}
@@ -311,6 +332,7 @@ class FolkContentTree extends HTMLElement {
`;
+ this._tour.renderOverlay();
this.attachEvents();
}
@@ -440,6 +462,9 @@ class FolkContentTree extends HTMLElement {
});
}
+ // Tour button
+ this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
+
// Navigate on leaf click
for (const row of this.shadow.querySelectorAll(".ct-node__row--leaf[data-nav]")) {
row.addEventListener("click", () => {
diff --git a/modules/rsocials/components/folk-campaigns-dashboard.ts b/modules/rsocials/components/folk-campaigns-dashboard.ts
index b9c469a..9ff753a 100644
--- a/modules/rsocials/components/folk-campaigns-dashboard.ts
+++ b/modules/rsocials/components/folk-campaigns-dashboard.ts
@@ -8,6 +8,8 @@
* space — space slug (default "demo")
*/
+import { TourEngine } from '../../../shared/tour-engine';
+import type { TourStep } from '../../../shared/tour-engine';
import { CAMPAIGN_NODE_CATALOG } from '../schemas';
import type {
CampaignWorkflowNodeDef,
@@ -107,11 +109,18 @@ function renderMiniSVG(nodes: CampaignWorkflowNode[], edges: CampaignWorkflowEdg
// ── Component ──
+const DASHBOARD_TOUR_STEPS: TourStep[] = [
+ { target: '#btn-wizard', title: 'Campaign Wizard', message: 'AI-guided campaign creation flow — answer a few questions and get a ready-to-run workflow.' },
+ { target: '#btn-new', title: 'New Workflow', message: 'Create a blank workflow from scratch and wire up your own nodes.' },
+ { target: '.cd-card', title: 'Workflow Cards', message: 'Click any card to open and edit its node graph.' },
+];
+
class FolkCampaignsDashboard extends HTMLElement {
private shadow: ShadowRoot;
private space = '';
private workflows: CampaignWorkflow[] = [];
private loading = true;
+ private _tour!: TourEngine;
private get basePath() {
const host = window.location.hostname;
@@ -122,6 +131,12 @@ class FolkCampaignsDashboard extends HTMLElement {
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
+ this._tour = new TourEngine(
+ this.shadow,
+ DASHBOARD_TOUR_STEPS,
+ 'rsocials_dashboard_tour_done',
+ () => this.shadow.querySelector('.cd-root') as HTMLElement,
+ );
}
connectedCallback() {
@@ -142,8 +157,13 @@ class FolkCampaignsDashboard extends HTMLElement {
}
this.loading = false;
this.render();
+ if (!localStorage.getItem('rsocials_dashboard_tour_done')) {
+ setTimeout(() => this._tour.start(), 800);
+ }
}
+ startTour() { this._tour.start(); }
+
private async createWorkflow() {
try {
const res = await fetch(`${this.basePath}api/campaign-workflows`, {
@@ -296,6 +316,7 @@ class FolkCampaignsDashboard extends HTMLElement {
${loadingState}
@@ -304,6 +325,7 @@ class FolkCampaignsDashboard extends HTMLElement {
`;
+ this._tour.renderOverlay();
this.attachListeners();
}
@@ -329,6 +351,8 @@ class FolkCampaignsDashboard extends HTMLElement {
if (btnWizard) btnWizard.addEventListener('click', () => { window.location.href = wizardUrl; });
const btnWizardEmpty = this.shadow.getElementById('btn-wizard-empty');
if (btnWizardEmpty) btnWizardEmpty.addEventListener('click', () => { window.location.href = wizardUrl; });
+
+ this.shadow.getElementById('btn-tour')?.addEventListener('click', () => this.startTour());
}
}
diff --git a/modules/rsplat/components/folk-splat-viewer.ts b/modules/rsplat/components/folk-splat-viewer.ts
index 766e0a4..3a5bf1c 100644
--- a/modules/rsplat/components/folk-splat-viewer.ts
+++ b/modules/rsplat/components/folk-splat-viewer.ts
@@ -7,10 +7,18 @@
* Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled).
*/
+import { LightTourEngine } from "../../../shared/tour-engine";
+import type { TourStep } from "../../../shared/tour-engine";
import { makeDraggableAll } from "../../../shared/draggable";
import { splatScenesSchema, splatScenesDocId, type SplatScenesDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
+const SPLAT_TOUR_STEPS: TourStep[] = [
+ { target: '.splat-grid', title: '3D Gallery', message: 'Browse Gaussian splat models — click any card to view in 3D.' },
+ { target: '#splat-drop', title: 'Upload / Generate', message: 'Add .ply or .splat files, or generate a 3D model from a single image using AI.' },
+ { target: '.splat-gallery__header', title: 'rSplat', message: 'Orbit, zoom, and inspect photorealistic 3D captures.' },
+];
+
interface SplatItem {
id: string;
slug: string;
@@ -32,6 +40,7 @@ export class FolkSplatViewer extends HTMLElement {
private _mode: "gallery" | "viewer" = "gallery";
private _splats: SplatItem[] = [];
private _spaceSlug = "demo";
+ private _tour: LightTourEngine | null = null;
private _splatUrl = "";
private _splatTitle = "";
private _splatDesc = "";
@@ -231,7 +240,7 @@ export class FolkSplatViewer extends HTMLElement {
this.innerHTML = `
${myModelsHtml}
@@ -288,6 +297,13 @@ export class FolkSplatViewer extends HTMLElement {
this.setupToggle();
this.setupDemoCardHandlers();
+ // Tour
+ this._tour = new LightTourEngine(this.querySelector('.splat-gallery') as HTMLElement || this, SPLAT_TOUR_STEPS, 'rsplat_tour_done');
+ if (!localStorage.getItem('rsplat_tour_done')) {
+ setTimeout(() => this._tour?.start(), 800);
+ }
+ this.querySelector('#btn-tour')?.addEventListener('click', () => this._tour?.start());
+
// Make splat cards draggable for calendar reminders
makeDraggableAll(this, ".splat-card[data-collab-id]", (el) => {
const title = el.querySelector(".splat-card__title")?.textContent || "";
diff --git a/modules/rvnb/components/folk-vnb-view.ts b/modules/rvnb/components/folk-vnb-view.ts
index 2469698..528bf4d 100644
--- a/modules/rvnb/components/folk-vnb-view.ts
+++ b/modules/rvnb/components/folk-vnb-view.ts
@@ -5,9 +5,17 @@
* owner dashboard (my vehicles + incoming requests), rental request sidebar.
*/
+import { LightTourEngine } from '../../../shared/tour-engine';
+import type { TourStep } from '../../../shared/tour-engine';
import './folk-vehicle-card';
import './folk-rental-request';
+const VNB_TOUR_STEPS: TourStep[] = [
+ { target: '.vnb-search', title: 'Search', message: 'Filter by vehicle type, dates, or economy model.' },
+ { target: '#vnb-content', title: 'Browse Vehicles', message: 'Tap a card for details and rental requests.' },
+ { target: '.vnb-view__toggle[data-view="map"]', title: 'Map View', message: 'See vehicles on an interactive map.' },
+];
+
// ── Leaflet CDN Loader ──
let _leafletReady = false;
@@ -61,6 +69,7 @@ class FolkVnbView extends HTMLElement {
#economyFilter = '';
#map: any = null;
#mapContainer: HTMLElement | null = null;
+ #tour: LightTourEngine | null = null;
connectedCallback() {
this.#space = this.getAttribute('space') || 'demo';
@@ -136,6 +145,7 @@ class FolkVnbView extends HTMLElement {
+
@@ -170,6 +180,13 @@ class FolkVnbView extends HTMLElement {
`;
this.#wireEvents();
+
+ // Tour
+ this.#tour = new LightTourEngine(this.querySelector('.vnb-view') as HTMLElement || this, VNB_TOUR_STEPS, 'rvnb_tour_done');
+ if (!localStorage.getItem('rvnb_tour_done')) {
+ setTimeout(() => this.#tour?.start(), 800);
+ }
+ this.querySelector('#btn-tour')?.addEventListener('click', () => this.#tour?.start());
}
#wireEvents() {
diff --git a/shared/components/rstack-collab-overlay.ts b/shared/components/rstack-collab-overlay.ts
index f52dab0..719fc96 100644
--- a/shared/components/rstack-collab-overlay.ts
+++ b/shared/components/rstack-collab-overlay.ts
@@ -46,6 +46,7 @@ export class RStackCollabOverlay extends HTMLElement {
#gcInterval: ReturnType | null = null;
#badgeOnly = false;
#hidden = false; // true on canvas page
+ #soloMode = false;
constructor() {
super();
@@ -55,10 +56,13 @@ export class RStackCollabOverlay extends HTMLElement {
connectedCallback() {
this.#moduleId = this.getAttribute('module-id');
this.#badgeOnly = this.getAttribute('mode') === 'badge-only';
+ this.#soloMode = localStorage.getItem('rspace_solo_mode') === '1';
// Hide on canvas page — it has its own CommunitySync + PresenceManager
if (this.#moduleId === 'rspace') {
this.#hidden = true;
+ // Still dispatch initial solo-mode event for canvas PresenceManager
+ document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo: this.#soloMode } }));
return;
}
@@ -72,8 +76,9 @@ export class RStackCollabOverlay extends HTMLElement {
// Resolve local identity
this.#resolveIdentity();
- // Render initial (empty badge)
+ // Render initial (badge always visible)
this.#render();
+ this.#renderBadge();
// Try connecting to runtime
this.#tryConnect();
@@ -171,6 +176,8 @@ export class RStackCollabOverlay extends HTMLElement {
// ── Remote awareness handling ──
#handleRemoteAwareness(msg: AwarenessMessage) {
+ if (this.#soloMode) return; // suppress incoming awareness in solo mode
+
const existing = this.#peers.get(msg.peer);
const peer: PeerState = {
peerId: msg.peer,
@@ -191,6 +198,8 @@ export class RStackCollabOverlay extends HTMLElement {
// ── Local broadcasting ──
#broadcastPresence(cursor?: { x: number; y: number }, selection?: string) {
+ if (this.#soloMode) return; // suppress outgoing awareness in solo mode
+
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime || !this.#docId) return;
@@ -292,22 +301,45 @@ export class RStackCollabOverlay extends HTMLElement {
#render() {
this.#shadow.innerHTML = `
-
+
`;
+ this.#shadow.getElementById('badge')?.addEventListener('click', () => this.#toggleSoloMode());
+ }
+
+ #toggleSoloMode() {
+ this.#soloMode = !this.#soloMode;
+ localStorage.setItem('rspace_solo_mode', this.#soloMode ? '1' : '0');
+
+ if (this.#soloMode) {
+ // Clear remote peers and their visual artifacts
+ this.#peers.clear();
+ if (!this.#badgeOnly) {
+ this.#renderCursors();
+ this.#renderFocusRings();
+ }
+ }
+
+ this.#renderBadge();
+
+ // Notify canvas PresenceManager and any other listeners
+ document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo: this.#soloMode } }));
}
#renderBadge() {
const badge = this.#shadow.getElementById('badge');
if (!badge) return;
- const count = this.#peers.size + 1; // +1 for self
- if (count <= 1) {
- badge.innerHTML = '';
- badge.classList.remove('visible');
+ if (this.#soloMode) {
+ badge.innerHTML = `\u{1F464} Solo`;
+ badge.classList.add('visible', 'solo');
+ badge.title = 'Solo mode \u2014 your presence is hidden. Click to go collaborative.';
return;
}
+ badge.classList.remove('solo');
+ const count = this.#peers.size + 1; // +1 for self
+
const dots = Array.from(this.#peers.values())
.slice(0, 5) // show max 5 dots
.map(p => ``)
@@ -316,9 +348,10 @@ export class RStackCollabOverlay extends HTMLElement {
badge.innerHTML = `
${dots}
- ${count} online
+ \u{1F465} ${count} online
`;
badge.classList.add('visible');
+ badge.title = 'Collaborative \u2014 sharing your presence. Click for solo mode.';
}
#renderCursors() {
@@ -433,16 +466,29 @@ const OVERLAY_CSS = `
font-size: 11px;
color: var(--rs-text-secondary, #ccc);
pointer-events: auto;
- cursor: default;
+ cursor: pointer;
user-select: none;
z-index: 10000;
border: 1px solid var(--rs-border, rgba(255,255,255,0.08));
+ transition: opacity 0.2s, border-color 0.2s;
+ }
+
+ .collab-badge:hover {
+ border-color: rgba(255,255,255,0.2);
}
.collab-badge.visible {
display: inline-flex;
}
+ .collab-badge.solo {
+ opacity: 0.7;
+ }
+
+ .count.solo {
+ color: var(--rs-text-muted, #888);
+ }
+
.dot {
width: 7px;
height: 7px;
diff --git a/shared/tour-engine.ts b/shared/tour-engine.ts
index 637c41c..305383a 100644
--- a/shared/tour-engine.ts
+++ b/shared/tour-engine.ts
@@ -183,6 +183,152 @@ export class TourEngine {
}
}
+/**
+ * LightTourEngine — tour engine for non-shadow-DOM components.
+ * Uses a container element instead of ShadowRoot, injects CSS into document.head.
+ */
+export class LightTourEngine {
+ private container: HTMLElement;
+ private steps: TourStep[];
+ private storageKey: string;
+
+ private _active = false;
+ private _step = 0;
+ private _clickHandler: (() => void) | null = null;
+ private _clickTarget: HTMLElement | null = null;
+
+ get isActive() { return this._active; }
+
+ constructor(container: HTMLElement, steps: TourStep[], storageKey: string) {
+ this.container = container;
+ this.steps = steps;
+ this.storageKey = storageKey;
+ }
+
+ start() {
+ this._active = true;
+ this._step = 0;
+ this.renderOverlay();
+ }
+
+ advance() {
+ this._detachClickHandler();
+ this._step++;
+ if (this._step >= this.steps.length) {
+ this.end();
+ } else {
+ this.renderOverlay();
+ }
+ }
+
+ end() {
+ this._detachClickHandler();
+ this._active = false;
+ this._step = 0;
+ localStorage.setItem(this.storageKey, "1");
+ this.container.querySelector("#rspace-tour-overlay")?.remove();
+ }
+
+ renderOverlay() {
+ if (!this._active) return;
+ this._ensureStyles();
+
+ const step = this.steps[this._step];
+ const targetEl = this.container.querySelector(step.target) as HTMLElement | null;
+ if (!targetEl && this._step < this.steps.length - 1) {
+ this._step++;
+ this.renderOverlay();
+ return;
+ }
+
+ let overlay = this.container.querySelector("#rspace-tour-overlay") as HTMLElement | null;
+ if (!overlay) {
+ overlay = document.createElement("div");
+ overlay.id = "rspace-tour-overlay";
+ overlay.className = "rspace-tour-overlay";
+ this.container.appendChild(overlay);
+ }
+
+ let spotX = 0, spotY = 0, spotW = 120, spotH = 40;
+ if (targetEl) {
+ const containerRect = this.container.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._step >= this.steps.length - 1;
+ const stepNum = this._step + 1;
+ const totalSteps = this.steps.length;
+ const tooltipTop = spotY + spotH + 12;
+ const tooltipLeft = Math.max(8, spotX);
+
+ overlay.innerHTML = `
+
+
+
+ `;
+
+ overlay.querySelectorAll("[data-tour]").forEach(btn => {
+ btn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const action = (btn as HTMLElement).dataset.tour;
+ if (action === "next") this.advance();
+ else if (action === "prev") {
+ this._detachClickHandler();
+ this._step = Math.max(0, this._step - 1);
+ this.renderOverlay();
+ }
+ else if (action === "skip") this.end();
+ });
+ });
+
+ this._detachClickHandler();
+ if (step.advanceOnClick && targetEl) {
+ this._clickHandler = () => {
+ this._detachClickHandler();
+ setTimeout(() => this.advance(), 300);
+ };
+ this._clickTarget = targetEl;
+ targetEl.addEventListener("click", this._clickHandler);
+ }
+ }
+
+ private _detachClickHandler() {
+ if (this._clickHandler && this._clickTarget) {
+ this._clickTarget.removeEventListener("click", this._clickHandler);
+ }
+ this._clickHandler = null;
+ this._clickTarget = null;
+ }
+
+ private _ensureStyles() {
+ if (document.head.querySelector("[data-light-tour-styles]")) return;
+ const style = document.createElement("style");
+ style.setAttribute("data-light-tour-styles", "");
+ style.textContent = TOUR_CSS;
+ document.head.appendChild(style);
+ }
+}
+
const TOUR_CSS = `
.rspace-tour-overlay {
position: absolute; inset: 0; z-index: 10000;