/** * Shared guided-tour engine for shadow-DOM rApp components. * * Designed to survive full innerHTML replacement — call `renderOverlay()` * at the end of each render cycle to re-inject the tour UI. */ export interface TourStep { target: string; // CSS selector within shadow root title: string; message: string; advanceOnClick?: boolean; // auto-advance when target is clicked } export class TourEngine { private shadowRoot: ShadowRoot; private steps: TourStep[]; private storageKey: string; private getContainer: () => HTMLElement | null; private _active = false; private _step = 0; private _clickHandler: (() => void) | null = null; private _clickTarget: HTMLElement | null = null; get isActive() { return this._active; } constructor( shadowRoot: ShadowRoot, steps: TourStep[], storageKey: string, getContainer: () => HTMLElement | null, ) { this.shadowRoot = shadowRoot; this.steps = steps; this.storageKey = storageKey; this.getContainer = getContainer; } /** Start (or restart) the tour from step 0. */ start() { this._active = true; this._step = 0; this.renderOverlay(); } /** Advance to next step or finish if at the end. */ advance() { this._detachClickHandler(); this._step++; if (this._step >= this.steps.length) { this.end(); } else { this.renderOverlay(); } } /** End the tour and remove the overlay. */ end() { this._detachClickHandler(); this._active = false; this._step = 0; localStorage.setItem(this.storageKey, "1"); this.shadowRoot.getElementById("rspace-tour-overlay")?.remove(); } /** * Re-inject / update the tour overlay. * Call this at the end of every host render() so the overlay survives * full innerHTML replacement. Safe to call when tour is inactive (no-op). */ renderOverlay() { if (!this._active) return; // Ensure styles exist this._ensureStyles(); // Skip steps whose target doesn't exist (e.g. mic button when unsupported) const step = this.steps[this._step]; const targetEl = this.shadowRoot.querySelector(step.target) as HTMLElement | null; if (!targetEl && this._step < this.steps.length - 1) { this._step++; this.renderOverlay(); return; } // Get or create overlay element let overlay = this.shadowRoot.getElementById("rspace-tour-overlay"); if (!overlay) { overlay = document.createElement("div"); overlay.id = "rspace-tour-overlay"; overlay.className = "rspace-tour-overlay"; const container = this.getContainer(); if (container) container.appendChild(overlay); else this.shadowRoot.appendChild(overlay); } // Compute spotlight position relative to container let spotX = 0, spotY = 0, spotW = 120, spotH = 40; if (targetEl) { const container = this.getContainer() || this.shadowRoot.host as HTMLElement; const containerRect = 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; // Position tooltip below target, clamped within container 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` : `` }
`; // Wire tour navigation buttons 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(); }); }); // For advanceOnClick steps, attach listener on the target this._detachClickHandler(); if (step.advanceOnClick && targetEl) { this._clickHandler = () => { this._detachClickHandler(); setTimeout(() => this.advance(), 300); }; this._clickTarget = targetEl; targetEl.addEventListener("click", this._clickHandler); } } /** Remove the click-to-advance listener from the previous target. */ private _detachClickHandler() { if (this._clickHandler && this._clickTarget) { this._clickTarget.removeEventListener("click", this._clickHandler); } this._clickHandler = null; this._clickTarget = null; } /** Inject the shared tour CSS once into the shadow root. */ private _ensureStyles() { if (this.shadowRoot.querySelector("[data-tour-styles]")) return; const style = document.createElement("style"); style.setAttribute("data-tour-styles", ""); style.textContent = TOUR_CSS; this.shadowRoot.appendChild(style); } } const TOUR_CSS = ` .rspace-tour-overlay { position: absolute; inset: 0; z-index: 10000; pointer-events: none; } .rspace-tour-backdrop { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.55); pointer-events: auto; transition: clip-path 0.3s ease; } .rspace-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; } .rspace-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: rspace-tour-pop 0.25s ease-out; } @keyframes rspace-tour-pop { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } .rspace-tour-tooltip__step { font-size: 0.7rem; color: var(--rs-text-muted, #64748b); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.05em; } .rspace-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; } .rspace-tour-tooltip__msg { font-size: 0.85rem; color: var(--rs-text-secondary, #94a3b8); line-height: 1.5; margin-bottom: 12px; } .rspace-tour-tooltip__nav { display: flex; align-items: center; gap: 8px; } .rspace-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; } .rspace-tour-tooltip__btn:hover { transform: translateY(-1px); } .rspace-tour-tooltip__btn--next { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; } .rspace-tour-tooltip__btn--prev { background: var(--rs-btn-secondary-bg, #334155); color: var(--rs-text-secondary, #94a3b8); } .rspace-tour-tooltip__btn--skip { background: none; color: var(--rs-text-muted, #64748b); margin-left: auto; } .rspace-tour-tooltip__btn--skip:hover { color: var(--rs-text-primary, #f1f5f9); } .rspace-tour-tooltip__hint { font-size: 0.72rem; color: var(--rs-text-muted, #64748b); font-style: italic; } `;