feat(tours+solo): add tours to remaining modules and solo mode toggle

Add guided tours to 6 modules that were missing them:
- Shadow DOM: rsocials dashboard, crowdsurf, rdata (TourEngine)
- Light DOM: rsplat, rbnb, rvnb (new LightTourEngine class)

Add solo mode toggle to collab overlay — click the presence badge
to hide your cursor/presence from others. Persists via localStorage,
dispatches event to canvas PresenceManager.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-21 17:26:57 -07:00
parent e933e6238b
commit 524356d233
9 changed files with 347 additions and 10 deletions

View File

@ -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() {

View File

@ -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 {
</button>
</nav>
</div>`;
this._tour.renderOverlay();
}
private renderActiveView(isLive: boolean): string {
@ -356,6 +381,7 @@ class FolkCrowdSurfDashboard extends HTMLElement {
<span class="cs-title">CrowdSurf</span>
${isLive ? '<span class="cs-live"><span class="cs-live-dot"></span>LIVE</span>' : ''}
${this.space === 'demo' ? '<span class="cs-demo-badge">DEMO</span>' : ''}
<button style="margin-left:auto;padding:4px 10px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:0.78rem;font-family:inherit;" id="btn-tour">Tour</button>
</div>
<div class="cs-discover">
@ -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) {

View File

@ -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 {
<button class="bnb-view__toggle" data-view="map" style="padding:0.4rem 0.75rem;border-radius:0.375rem;border:1px solid var(--rs-border,#334155);background:${this.#view === 'map' ? 'rgba(245,158,11,0.15)' : 'transparent'};color:var(--rs-text,#e2e8f0);cursor:pointer;font-size:0.82rem">
\u{1F5FA} Map
</button>
<button id="btn-tour" style="padding:0.4rem 0.75rem;border-radius:0.375rem;border:1px solid var(--rs-border,#334155);background:transparent;color:var(--rs-text-muted,#94a3b8);cursor:pointer;font-size:0.78rem">Tour</button>
</div>
</div>
@ -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() {

View File

@ -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<string, string> = {
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<string>();
private activeModules = new Set<string>();
@ -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 ? `<button class="ct-tag ct-tag--clear">Clear filters</button>` : ""}
</div>` : ""}
<div class="ct-summary">${totalModules} module${totalModules !== 1 ? "s" : ""}, ${totalItems} item${totalItems !== 1 ? "s" : ""}</div>
<div class="ct-summary">${totalModules} module${totalModules !== 1 ? "s" : ""}, ${totalItems} item${totalItems !== 1 ? "s" : ""} <button style="margin-left:8px;padding:2px 8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:0.72rem;" id="btn-tour">Tour</button></div>
</div>
<div class="ct-tree">
${totalItems === 0 ? `<div class="ct-empty">No content found${this.search || this.activeTags.size ? " matching your filters" : " in this space"}.</div>` : ""}
@ -311,6 +332,7 @@ class FolkContentTree extends HTMLElement {
</div>
`;
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<HTMLElement>(".ct-node__row--leaf[data-nav]")) {
row.addEventListener("click", () => {

View File

@ -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 {
<div class="cd-header__actions">
<button class="cd-btn cd-btn--wizard" id="btn-wizard">\uD83E\uDDD9 Campaign Wizard</button>
${!this.loading && this.workflows.length > 0 ? '<button class="cd-btn cd-btn--primary" id="btn-new">+ New Workflow</button>' : ''}
<button style="padding:4px 10px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:0.78rem;" id="btn-tour">Tour</button>
</div>
</div>
${loadingState}
@ -304,6 +325,7 @@ class FolkCampaignsDashboard extends HTMLElement {
</div>
`;
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());
}
}

View File

@ -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 = `
<div class="splat-gallery">
<div class="splat-gallery__header">
<h1>rSplat</h1>
<h1 style="display:flex;align-items:center;gap:0.5rem">rSplat <button style="padding:3px 8px;border-radius:6px;border:1px solid var(--rs-border,#334155);background:var(--rs-bg-surface,#1e293b);color:var(--rs-text-secondary,#94a3b8);cursor:pointer;font-size:0.72rem;font-weight:400" id="btn-tour">Tour</button></h1>
<p class="splat-gallery__subtitle">Explore and create 3D Gaussian splat scenes</p>
</div>
${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 || "";

View File

@ -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 {
<button class="vnb-view__toggle" data-view="map" style="padding:0.4rem 0.75rem;border-radius:0.375rem;border:1px solid var(--rs-border,#334155);background:${this.#view === 'map' ? 'rgba(16,185,129,0.15)' : 'transparent'};color:var(--rs-text,#e2e8f0);cursor:pointer;font-size:0.82rem">
\u{1F5FA} Map
</button>
<button id="btn-tour" style="padding:0.4rem 0.75rem;border-radius:0.375rem;border:1px solid var(--rs-border,#334155);background:transparent;color:var(--rs-text-muted,#94a3b8);cursor:pointer;font-size:0.78rem">Tour</button>
</div>
</div>
@ -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() {

View File

@ -46,6 +46,7 @@ export class RStackCollabOverlay extends HTMLElement {
#gcInterval: ReturnType<typeof setInterval> | 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 = `
<style>${OVERLAY_CSS}</style>
<div class="collab-badge" id="badge"></div>
<div class="collab-badge visible" id="badge"></div>
<div class="collab-cursors" id="cursors"></div>
`;
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 = `<span class="count solo">\u{1F464} Solo</span>`;
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 => `<span class="dot" style="background:${p.color}"></span>`)
@ -316,9 +348,10 @@ export class RStackCollabOverlay extends HTMLElement {
badge.innerHTML = `
<span class="dot" style="background:${this.#localColor}"></span>
${dots}
<span class="count">${count} online</span>
<span class="count">\u{1F465} ${count} online</span>
`;
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;

View File

@ -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 = `
<div class="rspace-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="rspace-tour-spotlight" style="left:${spotX}px;top:${spotY}px;width:${spotW}px;height:${spotH}px"></div>
<div class="rspace-tour-tooltip" style="top:${tooltipTop}px;left:${tooltipLeft}px">
<div class="rspace-tour-tooltip__step">${stepNum} / ${totalSteps}</div>
<div class="rspace-tour-tooltip__title">${step.title}</div>
<div class="rspace-tour-tooltip__msg">${step.message}</div>
<div class="rspace-tour-tooltip__nav">
${this._step > 0 ? '<button class="rspace-tour-tooltip__btn rspace-tour-tooltip__btn--prev" data-tour="prev">Back</button>' : ''}
${step.advanceOnClick
? `<span class="rspace-tour-tooltip__hint">or click the button above</span>`
: `<button class="rspace-tour-tooltip__btn rspace-tour-tooltip__btn--next" data-tour="next">${isLast ? 'Finish' : 'Next'}</button>`
}
<button class="rspace-tour-tooltip__btn rspace-tour-tooltip__btn--skip" data-tour="skip">Skip</button>
</div>
</div>
`;
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;