(function () { 'use strict'; // ───────────────────────────────────────────────────────────────────────── // FeedBack tour engine — consolidated menu version. // // One floating ? button at the bottom-right of the viewport. Click it to // see every tour that's relevant to the current screen. Click a tour to // start it (Shepherd-driven, same as before). // // Screen relevance defaults: // - has_screen: true → tour relevant on the plugin's own dedicated // screen (`plugin-`) // - otherwise → tour relevant on the player screen // - plugins can override via feedBackTour.register(id, { screens: [...] }) // // First-visit toast: on screen:changed, if any relevant tour is unseen // and un-dismissed, a small "Take a quick tour of X?" prompt pops next // to the ? button (or queues if multiple). Same hasSeen/hasDismissed // logic as the previous design — no UX regression for users who've // already taken the per-plugin tours, just one button instead of N. // ───────────────────────────────────────────────────────────────────────── // _tourPlugins: { id, name, has_screen, is_viz } populated from /api/plugins // (is_viz === true means the plugin declared type:"visualization" — used // below to gate viz tours on the currently-active viz so we don't list // tours whose DOM only exists when that viz is rendering). // _registry: imperative overrides keyed by plugin id — buildSteps, // onStart, onComplete, plus an optional screens:[] override that // wins over the defaults in _defaultScreensFor(). const _tourPlugins = {}; const _registry = {}; const _deprecationWarned = new Set(); // pluginIds we've already warned about let _activeTour = null; let _activeTourPluginId = null; let _currentScreenId = null; let _menuBtn = null; // the persistent ? button (lazily mounted) let _menuPopover = null; // the menu of available tours let _activeToastPluginId = null; // plugin currently being prompted via toast // ── localStorage helpers ────────────────────────────────────────────── function _seenKey(id) { return 'feedBack_tour_seen_' + id; } function _dismissedKey(id) { return 'feedBack_tour_dismissed_' + id; } function hasSeen(pluginId) { try { return !!localStorage.getItem(_seenKey(pluginId)); } catch { return false; } } function hasDismissed(pluginId) { try { return !!localStorage.getItem(_dismissedKey(pluginId)); } catch { return false; } } function _markSeen(pluginId) { try { localStorage.setItem(_seenKey(pluginId), '1'); } catch { /* private mode / quota */ } } function _markDismissed(pluginId) { try { localStorage.setItem(_dismissedKey(pluginId), '1'); } catch { /* private mode / quota */ } } // ── Screen relevance ────────────────────────────────────────────────── function _defaultScreensFor(meta) { if (meta.has_screen) return ['plugin-' + meta.id]; return ['player']; } function _screensFor(pluginId) { const reg = _registry[pluginId]; if (reg && Array.isArray(reg.screens) && reg.screens.length) return reg.screens; const meta = _tourPlugins[pluginId]; if (!meta) return []; return _defaultScreensFor(meta); } // Viz plugins' tours typically reference plugin-specific DOM that only // exists while that viz is rendering (e.g. 3D Highway's .h3d-wrap). On // the player screen we therefore only treat the active viz as relevant, // not every viz plugin that happens to ship a tour. Mirrors the same // localStorage/picker/auto-match precedence the previous per-plugin // _injectPlayerVizTrigger used. function _currentVizPluginId() { // Picker is the runtime source of truth — app.js treats // localStorage as a persistence mirror that can be stale or // unwritable (private mode / sandboxed contexts), so read the // picker first and only fall back to localStorage when it's // not in the DOM yet. const picker = document.getElementById('viz-picker'); let sel = picker ? picker.value : null; if (!sel) { try { sel = localStorage.getItem('vizSelection'); } catch { /* private mode */ } } if (sel && sel !== 'auto' && sel !== 'default') return sel; if (sel !== 'auto') return null; const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function') ? (highway.getSongInfo() || {}) : {}; const candidateIds = picker ? Array.from(picker.options).map(o => o.value).filter(v => v !== 'auto' && v !== 'default') : Object.keys(_tourPlugins).filter(id => _tourPlugins[id].is_viz); for (const pluginId of candidateIds) { const factory = window['feedBackViz_' + pluginId]; if (typeof factory !== 'function') continue; const predicate = factory.matchesArrangement; if (typeof predicate !== 'function') continue; try { if (predicate(songInfo)) return pluginId; } catch { /* ignore */ } } return null; } // `activeVizId` is the precomputed result of _currentVizPluginId() for // this refresh — callers pass it in so we don't re-run the picker / // localStorage / matchesArrangement chain once per plugin per refresh. // Null is allowed: the gate falls open and viz plugins are never // relevant (matches "no viz active"). function _isRelevant(pluginId, screenId, activeVizId) { if (!screenId) return false; if (_screensFor(pluginId).indexOf(screenId) === -1) return false; const meta = _tourPlugins[pluginId]; if (meta && meta.is_viz && screenId === 'player') { return activeVizId === pluginId; } return true; } function _relevantPlugins(screenId) { // Resolve the active viz once and reuse across every plugin's // relevance check — the resolver can walk the picker options and // call matchesArrangement() predicates in auto mode, which is // wasted work per plugin. const activeVizId = (screenId === 'player') ? _currentVizPluginId() : null; return Object.values(_tourPlugins).filter(p => _isRelevant(p.id, screenId, activeVizId)); } function _unseenRelevant(screenId) { // Drives the toast prompt + button "has-unseen" pulse. A tour registered // with autoPrompt:false is excluded — it's started programmatically by // its owner (e.g. the first-run home tour), so nagging via toast/pulse // would double up. It still lists in the menu and runs on demand. return _relevantPlugins(screenId).filter(p => !hasSeen(p.id) && !hasDismissed(p.id) && (_registry[p.id] ? _registry[p.id].autoPrompt !== false : true)); } // ── Menu UI ──────────────────────────────────────────────────────────── function _ensureMenu() { if (_menuBtn) return; _menuBtn = document.createElement('button'); _menuBtn.className = 'feedBack-tour-menu-btn'; _menuBtn.setAttribute('aria-label', 'Available tours'); _menuBtn.setAttribute('aria-haspopup', 'dialog'); _menuBtn.setAttribute('aria-expanded', 'false'); _menuBtn.setAttribute('aria-controls', 'feedBack-tour-menu-popover'); _menuBtn.title = 'Available tours'; _menuBtn.textContent = '?'; _menuBtn.style.display = 'none'; _menuBtn.addEventListener('click', _toggleMenu); document.body.appendChild(_menuBtn); _menuPopover = document.createElement('div'); _menuPopover.id = 'feedBack-tour-menu-popover'; _menuPopover.className = 'feedBack-tour-menu-popover'; // Plain popover; not a WAI-ARIA "menu" — that role implies roving // focus + arrow-key navigation we don't implement (the rows are // ordinary