/* * fee[dB]ack v0.3.0 — app shell: sidebar, topbar, client routing. * * Vanilla JS, no framework/bundler (constitution P-II). Reuses the engine * from app.js: navigation is the shared window.showScreen across both the new * #v3-* screens and the reused legacy/#plugin-* screens. UI placement is a * DEFERRED capability domain, so plugin nav/screens are consumed via the * legacy plugin loader + /api/plugins, NOT via capability dispatch * (design/05-capability-pipelines.md). All v3 UI state uses the `v3:` prefix. */ (function () { 'use strict'; // HTML-escape untrusted strings (plugin manifest id/label) before they go // into innerHTML, so a hostile/buggy manifest can't inject markup or event // attributes into the sidebar plugin nav. const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); // Plugin ids whose manifest declared `"fullscreen": true`. Populated from // /api/plugins in renderPromotedNav(); read by syncActive() to toggle the // immersive (chrome-collapsed) shell whenever such a plugin's screen is the // active one. Empty until plugins resolve — the worst case is one extra // syncActive() once the fetch lands, which re-applies the class. const FULLSCREEN_PLUGIN_IDS = new Set(); // ── Navigation registry ──────────────────────────────────────────────── // Each entry maps a stable hash key → a screen id (showScreen target) and a // label. Legacy screens are reused: "Songs" = #home (library), "Favorites" // = #favorites, "Settings" = #settings. New screens use #v3-* ids. const NAV = [ { key: 'home', screen: 'v3-home', label: 'Home', group: 'HOME', icon: 'home' }, { key: 'progress', screen: 'v3-progress', label: 'Progress', group: 'HOME', icon: 'trophy' }, { key: 'shop', screen: 'v3-shop', label: 'Unlockables', group: 'HOME', icon: 'tag' }, { key: 'feedbarcade', screen: 'v3-feedbarcade', label: 'FeedBarcade', group: 'HOME', icon: 'arcade' }, { key: 'plugins', screen: 'v3-plugins', label: 'Plugins', group: 'HOME', icon: 'plug' }, { key: 'settings', screen: 'settings', label: 'Settings', group: 'HOME', icon: 'gear' }, { key: 'playlists', screen: 'v3-playlists', label: 'Playlists', group: 'LIBRARY', icon: 'list' }, { key: 'songs', screen: 'v3-songs', label: 'Song Library', group: 'LIBRARY', icon: 'disc' }, { key: 'lessons', screen: 'v3-lessons', label: 'Lessons', group: 'LIBRARY', icon: 'lessons' }, { key: 'favorites', screen: 'favorites', label: 'Favorites', group: 'LIBRARY', icon: 'star' }, { key: 'saved', screen: 'v3-saved', label: 'Saved for Later', group: 'LIBRARY', icon: 'bookmark' }, // Promoted plugins (group: null) — bundled plugins given their own // first-class sidebar entry instead of the generic plugin gallery. They // are placed by PROMOTED_PLUGINS below and their slots are filled by // renderPromotedNav() only when the plugin is actually installed. All // other plugins are reached solely via the single "Plugins" entry // above. Screens are injected async by the plugin loader, so go()'s // plugin- guard applies. { key: 'virtuoso', screen: 'plugin-virtuoso', label: 'Virtuoso - Practice', group: null, icon: 'target' }, { key: 'career', screen: 'plugin-career', label: 'Career', group: null, icon: 'trophy' }, { key: 'rig_builder', screen: 'plugin-rig_builder', label: 'Rig Builder', group: null, icon: 'amp' }, { key: 'editor', screen: 'plugin-editor', label: 'Song Editor', group: null, icon: 'edit' }, { key: 'audio_engine', screen: 'plugin-audio_engine', label: 'Audio', group: null, icon: 'amp' }, // Not in the sidebar groups, but routable (profile badge → here). { key: 'profile', screen: 'v3-profile', label: 'Profile', group: null, icon: 'user' }, ]; // Bundled plugins promoted to dedicated sidebar entries. `anchorAfter` is // the nav key the slot is rendered immediately below (within that key's // group); a key that's the last item of the last group lands right after // that group. Each is gated on the plugin actually being installed. const PROMOTED_PLUGINS = [ { navKey: 'virtuoso', pluginId: 'virtuoso', slotId: 'v3-nav-virtuoso', anchorAfter: 'feedbarcade' }, { navKey: 'career', pluginId: 'career', slotId: 'v3-nav-career', anchorAfter: 'feedbarcade' }, { navKey: 'rig_builder', pluginId: 'rig_builder', slotId: 'v3-nav-rig-builder', anchorAfter: 'saved' }, { navKey: 'editor', pluginId: 'editor', slotId: 'v3-nav-editor', anchorAfter: 'songs' }, { navKey: 'audio_engine', pluginId: 'audio_engine', slotId: 'v3-nav-audio-engine', anchorAfter: 'settings' }, ]; const TOPBAR_KEYS = ['home', 'songs', 'plugins', 'settings']; const SIDEBAR_GROUPS = ['HOME', 'LIBRARY']; // Minimal inline icon set (currentColor 24x24 stroke paths). const ICONS = { home: 'M3 11l9-8 9 8M5 10v10h5v-6h4v6h5V10', plug: 'M9 7V3m6 4V3M7 7h10v4a5 5 0 01-10 0V7zm5 9v5', gear: 'M12 9a3 3 0 100 6 3 3 0 000-6zm8.4 3a8.4 8.4 0 00-.1-1.3l2-1.6-2-3.4-2.4 1a8 8 0 00-2.2-1.3l-.4-2.6H9.7l-.4 2.6A8 8 0 007.1 6l-2.4-1-2 3.4 2 1.6a8.4 8.4 0 000 2.6l-2 1.6 2 3.4 2.4-1a8 8 0 002.2 1.3l.4 2.6h4.6l.4-2.6a8 8 0 002.2-1.3l2.4 1 2-3.4-2-1.6c.1-.4.1-.9.1-1.3z', list: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01', disc: 'M12 3a9 9 0 100 18 9 9 0 000-18zm0 6a3 3 0 100 6 3 3 0 000-6z', star: 'M12 3l2.9 6 6.6.9-4.8 4.6 1.1 6.5L12 18.8 6.2 21l1.1-6.5L2.5 9.9 9.1 9 12 3z', bookmark: 'M6 3h12a1 1 0 011 1v17l-7-4-7 4V4a1 1 0 011-1z', user: 'M12 12a4 4 0 100-8 4 4 0 000 8zm-7 8a7 7 0 0114 0', arcade: 'M7 8h10a4 4 0 014 4 4 4 0 01-4 4H7a4 4 0 01-4-4 4 4 0 014-4zm0 4h3m-1.5-1.5v3M15 11h.01M17.5 13h.01', lessons: 'M12 4L2 9l10 5 10-5-10-5zM6 11.5V16c0 1 2.7 2.5 6 2.5s6-1.5 6-2.5v-4.5', trophy: 'M8 21h8m-4-4v4m-6-17h12v5a6 6 0 01-12 0V4zm12 2h2a2 2 0 01-2 4M6 6H4a2 2 0 002 4', tag: 'M20.6 13.4l-7.2 7.2a2 2 0 01-2.8 0l-7-7V4h9.6l7.4 7.4a2 2 0 010 2zM7.5 7.5h.01', amp: 'M4 5h16a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V6a1 1 0 011-1zm11 4a3 3 0 100 6 3 3 0 000-6zM6.5 8.5h.01M9 8.5h.01', target: 'M12 3a9 9 0 100 18 9 9 0 000-18zm0 4a5 5 0 100 10 5 5 0 000-10zm0 4a1 1 0 100 2 1 1 0 000-2z', edit: 'M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7M18.5 2.5a2.1 2.1 0 013 3L12 15l-4 1 1-4 9.5-9.5z', }; function iconSvg(name) { const d = ICONS[name] || ICONS.disc; return ''; } const byKey = (k) => NAV.find((n) => n.key === k); const byScreen = (s) => NAV.find((n) => n.screen === s); const currentScreenId = () => (document.querySelector('.screen.active') || {}).id || 'v3-home'; // The topbar IS each page's header row, so its title mirrors the screen. function titleFor(screenId) { if (screenId === 'v3-home') { const p = (window.v3Profile && window.v3Profile.get && window.v3Profile.get()) || null; return 'Welcome back, ' + ((p && p.display_name) || 'there') + '!'; } const entry = byScreen(screenId); if (entry) return entry.label; if (screenId && screenId.indexOf('plugin-') === 0) return 'Plugins'; return ''; } function setTopbarTitle(text) { const el = document.getElementById('v3-topbar-title'); if (el) el.textContent = text || ''; // textContent → no escaping needed } // ── Active-state sync ─────────────────────────────────────────────────── function syncActive(screenId) { const entry = byScreen(screenId); document.querySelectorAll('[data-v3-nav]').forEach((el) => { const on = entry && el.getAttribute('data-v3-nav') === entry.key; el.classList.toggle('bg-fb-card', on); el.classList.toggle('text-fb-text', on); el.classList.toggle('text-fb-textDim', !on); }); setTopbarTitle(titleFor(screenId)); // Immersive (full-screen) plugin screens: when the active screen belongs // to a plugin that opted in via `"fullscreen": true`, collapse the host // chrome (topbar hidden, sidebar → icon rail; see v3.css) and let the // plugin own the content area. Toggled here so it tracks every // navigation, including deep-link load and programmatic showScreen(). const fsPluginId = (screenId && screenId.indexOf('plugin-') === 0) ? screenId.slice('plugin-'.length) : null; const immersive = !!(fsPluginId && FULLSCREEN_PLUGIN_IDS.has(fsPluginId)); document.documentElement.classList.toggle('fb-immersive', immersive); // Show the song search only on the library screen. Everywhere else the // box is irrelevant (and would silently no-op against v3Songs.search). const searchWrap = document.getElementById('v3-search-wrap'); if (searchWrap) searchWrap.classList.toggle('hidden', screenId !== 'v3-songs'); // NOTE: we deliberately do NOT reflect the screen into location.hash on // every navigation. app.js's audio 'error' handler suppresses empty-src // errors only when `audio.src === window.location.href`; a `#/...` // fragment makes href differ from the fragment-less resolved empty src, // so screen-switch audio cleanup would log a spurious media error (and // pollute the diagnostics console capture). Deep-linking IN is still // supported on load (see boot()); live reflection OUT is intentionally // omitted — it's optional per the prompt, console cleanliness is not. } // ── Navigation ────────────────────────────────────────────────────────── function go(screenId) { if (typeof window.showScreen !== 'function') return; // Guard plugin screens that may not be injected yet (loadPlugins runs // async at app.js boot). showScreen() throws on a missing element. if (screenId.indexOf('plugin-') === 0 && !document.getElementById(screenId)) { window.showScreen('v3-plugins'); return; } window.showScreen(screenId); // wrapper below re-syncs active state closeMobileSidebar(); } // ── Sidebar ───────────────────────────────────────────────────────────-- function navItemHTML(entry, labelOverride) { return '' + iconSvg(entry.icon) + '' + esc(labelOverride != null ? labelOverride : entry.label) + ''; } // Empty slot for a promoted plugin, anchored after a nav item. Filled by // renderPromotedNav() only when the plugin is installed, so an absent // bundle shows nothing rather than a dead entry that bounces to Plugins. const promotedSlotHTML = (key) => PROMOTED_PLUGINS .filter((p) => p.anchorAfter === key) .map((p) => '
') .join(''); function renderSidebar() { const nav = document.getElementById('v3-nav'); if (!nav) return; let html = ''; for (const group of SIDEBAR_GROUPS) { const items = NAV.filter((n) => n.group === group); if (!items.length) continue; const itemsHTML = items.map((it) => navItemHTML(it) + promotedSlotHTML(it.key)).join(''); html += '
';
renderSidebar();
renderTopbar();
ensureBackdrop();
installShowScreenHook();
renderPromotedNav(); // async, non-blocking
// First-run gate: onboarding overlay is owned by prompt 15. Until it
// exists, degrade gracefully and go straight to the dashboard.
let profile = null;
try {
const res = await fetch('/api/profile');
if (res.ok) profile = await res.json();
} catch (e) { /* profile endpoint lands in prompt 15 */ }
if (profile && profile.onboarded === false && window.v3Onboarding && typeof window.v3Onboarding.show === 'function') {
try { window.v3Onboarding.show(profile); } catch (e) { /* fall through */ }
}
// Splitscreen pop-out windows (`?ssFollower=1`) get sent to
// showScreen('player') by app.js's bootstrap (app.js:9716) before
// this runs, exactly so the library doesn't flash on the popup.
// Don't undo that here — the splitscreen IIFE loads next and takes
// the popup the rest of the way into follower mode. Without this
// bail, the default 'v3-home' target below would re-activate the
// library screen and bring the flash back.
let isFollowerWindow = false;
try { isFollowerWindow = new URLSearchParams(location.search).get('ssFollower') === '1'; }
catch (_) { /* file:// or sandboxed iframe — fall through */ }
if (isFollowerWindow) {
syncActive('player');
} else {
// Deep-link IN on load: honor a #/key fragment, then strip it so
// subsequent screen-switch audio cleanup doesn't trip app.js's
// href-based empty-src guard (see syncActive). #v3-home is already
// `.active` in the HTML, so when that's the target we only sync the
// chrome — calling showScreen() redundantly would run its non-player
// teardown branch and log a spurious "Empty src" media error.
const m = (location.hash || '').match(/^#\/([\w-]+)/);
const entry = m && byKey(m[1]);
if (location.hash) {
try { history.replaceState(null, '', location.pathname + location.search); } catch (e) { /* file:// */ }
}
const target = entry ? entry.screen : 'v3-home';
const el = document.getElementById(target);
if (el && el.classList.contains('active')) {
syncActive(target);
} else {
go(target);
}
}
// The "Welcome back, {name}!" title needs the profile, which loads
// async — refresh once it's in (and whenever it changes).
function refreshHomeTitle() { if (currentScreenId() === 'v3-home') setTopbarTitle(titleFor('v3-home')); }
if (window.feedBack && typeof window.feedBack.on === 'function') {
window.feedBack.on('v3:profile-updated', refreshHomeTitle);
}
setTimeout(refreshHomeTitle, 700);
}
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})();