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 {
${!this.loading && this.workflows.length > 0 ? '' : ''} +
${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 = ` @@ -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 = ` +
+
+
+
${stepNum} / ${totalSteps}
+
${step.title}
+
${step.message}
+
+ ${this._step > 0 ? '' : ''} + ${step.advanceOnClick + ? `or click the button above` + : `` + } + +
+
+ `; + + 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;