/* * fee[dB]ack v0.3.0 — player profile: first-run onboarding overlay, the topbar * profile badge, and the #v3-profile screen. * * Vanilla JS (constitution P-II). Reads /api/profile + /api/profile/progress * (one call for the whole badge). Degrades gracefully when stats endpoints * (prompt 14) aren't present yet. window.v3Onboarding is defined synchronously * so the shell boot (shell.js) can call it regardless of script order. */ (function () { 'use strict'; let _profile = null; // {display_name, avatar_url, player_hash, onboarded} let _progress = null; // {level, xp, xp_in_level, xp_to_next, current_streak, best_streak} const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); function avatarImg(url, sizeCls, shape) { shape = shape || 'rounded-full'; if (url) { return ''; } // Neutral fallback glyph. return '' + '' + ''; } // ── API ────────────────────────────────────────────────────────────────── async function fetchProfile() { try { const r = await fetch('/api/profile'); if (r.ok) _profile = await r.json(); } catch (e) { /* P15 */ } return _profile; } async function fetchProgress() { try { const r = await fetch('/api/profile/progress'); if (r.ok) _progress = await r.json(); } catch (e) { /* P15 */ } return _progress; } // ── Topbar profile badge ───────────────────────────────────────────────-- function renderBadge() { const host = document.getElementById('v3-badge-profile'); if (!host) return; if (!_profile || !_profile.onboarded) { host.innerHTML = ''; return; } const p = _progress || { current_streak: 0 }; // Progression (spec 010): the badge shows Mastery Rank + Decibels. // Layout still matches the Google Stitch "Profile Card Component" // (dark rounded card, white avatar tile, flame + "N DAYS"), but the // 6-bar equalizer now meters CURRENT CHALLENGE-SET progress (completed // challenges across all paths' active sets / required total) and the // big number is the Mastery Rank, with the spendable dB balance beside. const prog = (window.v3Progression && window.v3Progression.get()) || null; const rank = prog ? prog.mastery_rank : 0; const balance = prog && prog.wallet ? prog.wallet.balance : 0; let challengesDone = 0, challengesRequired = 0; ((prog && prog.paths) || []).forEach((path) => { if (path.next) { challengesDone += Math.min(path.next.completed, path.next.required); challengesRequired += path.next.required; } }); const flame = ''; const HEIGHTS = ['h-2', 'h-4', 'h-8', 'h-6', 'h-10', 'h-12']; const FILL = ['#3B82F6', '#22C55E', '#FACC15', '#F97316', '#D1D5DB', '#22C55E']; const filled = challengesRequired > 0 ? Math.max(0, Math.min(6, Math.round((challengesDone / challengesRequired) * 6))) : 0; const bars = HEIGHTS.map((h, i) => '
').join(''); host.innerHTML = ''; // Equipped avatar frame (spec 010 cosmetics). if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') { window.v3Theme.applyFrame(host.querySelector('[data-v3-avatar-tile]')); } const btn = host.querySelector('[data-v3-open-profile]'); if (btn) btn.addEventListener('click', () => window.showScreen && window.showScreen('v3-profile')); } // ── Profile screen (#v3-profile) ────────────────────────────────────────- // The screen is tabbed exactly like the v3 Settings page (.fb-tabbar / // .fb-tab[data-tab] / .fb-tabpanel[data-tab]; achievements epic): a // **Profile** (main) tab and an **Achievements** tab. Core only ships the // shell + two mount points; the bundled achievements plugin renders the // Feats trophy shelf (#v3-profile-feats-slot, earned-only) and the full // competency catalogue (#v3-profile-achievements-mount) into them on the // `v3:profile-rendered` event below. Active tab persists in localStorage, // mirroring settings.js. var PROFILE_TAB_KEY = 'v3-profile-tab'; var PROFILE_DEFAULT_TAB = 'profile'; function activateProfileTab(tab) { var root = document.getElementById('v3-profile'); if (!root) return; var bar = root.querySelector('#v3-profile-tabbar'); var tabs = []; if (bar) bar.querySelectorAll('.fb-tab').forEach(function (b) { if (b.dataset.tab) tabs.push(b.dataset.tab); }); if (tabs.indexOf(tab) === -1) tab = tabs.length ? tabs[0] : PROFILE_DEFAULT_TAB; root.querySelectorAll('#v3-profile-tabbar .fb-tab').forEach(function (b) { b.classList.toggle('active', b.dataset.tab === tab); }); root.querySelectorAll('.fb-tabpanel').forEach(function (p) { p.classList.toggle('active', p.dataset.tab === tab); }); try { localStorage.setItem(PROFILE_TAB_KEY, tab); } catch (_) { /* private mode */ } } function wireProfileTabs() { var bar = document.getElementById('v3-profile-tabbar'); if (bar && bar.dataset.wired !== '1') { bar.dataset.wired = '1'; bar.addEventListener('click', function (e) { var btn = e.target.closest ? e.target.closest('.fb-tab') : null; if (btn && btn.dataset.tab) activateProfileTab(btn.dataset.tab); }); } var saved = PROFILE_DEFAULT_TAB; try { saved = localStorage.getItem(PROFILE_TAB_KEY) || PROFILE_DEFAULT_TAB; } catch (_) { /* noop */ } activateProfileTab(saved); } function renderProfileScreen() { const root = document.getElementById('v3-profile'); if (!root) return; const p = _progress || { current_streak: 0, best_streak: 0 }; const name = (_profile && _profile.display_name) || 'Player'; // Progression (spec 010): the profile header shows Mastery Rank, // per-path levels, and Decibels (balance + lifetime) — the old // XP-level meter is replaced by the rank/challenge system. const prog = (window.v3Progression && window.v3Progression.get()) || null; const rank = prog ? prog.mastery_rank : 0; const wallet = (prog && prog.wallet) || { balance: 0, lifetime_db: 0 }; const pathChips = ((prog && prog.paths) || []).map((path) => '' + esc(path.name) + ' Lv ' + path.level + '').join(' '); // Header card (Profile main tab). const headerCard = '
' + '' + avatarImg(_profile && _profile.avatar_url, 'w-24 h-24') + '' + '
' + '

' + esc(name) + '

' + '
' + 'Mastery Rank ' + rank + '' + '' + Number(wallet.balance).toLocaleString() + ' dB' + '' + Number(wallet.lifetime_db).toLocaleString() + ' dB lifetime' + '🔥 ' + (p.current_streak || 0) + '-day streak' + 'Best: ' + (p.best_streak || 0) + '
' + (pathChips ? '
' + pathChips + '
' : '') + '
' + '' + '' + '
'; // Per-song bests — top scored songs from /api/stats/top, filled by // renderBests() after innerHTML is set. The placeholder text shows // during load and when nothing's been scored yet. const bestsCard = '
' + '

Your best scores

' + '
Play a song to start tracking your accuracy and best scores.
' + '
'; const playerIdFooter = (_profile && _profile.player_hash ? '

player id ' + esc(_profile.player_hash.slice(0, 12)) + '

' : ''); root.innerHTML = '
' + '
' + '' + '' + '
' + // ── Profile (main) panel ────────────────────────────────────────── '
' + '
' + headerCard + bestsCard + // Passport wall — rendered by the career plugin on // v3:profile-rendered (absent-not-empty: nothing shows until a // passport exists). '
' + // Feats of Power trophy shelf — rendered by the achievements plugin // (earned Feats only; hidden-until-earned, so empty when none). '
' + playerIdFooter + '
' + // ── Achievements panel ──────────────────────────────────────────── '
' + '
' + '

Install the Achievements plugin to track your skill milestones.

' + '
' + '
'; const edit = root.querySelector('[data-v3-edit-profile]'); if (edit) edit.addEventListener('click', () => show(_profile, { editing: true })); const openProgress = root.querySelector('[data-v3-open-progress]'); if (openProgress) openProgress.addEventListener('click', () => window.showScreen && window.showScreen('v3-progress')); if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') { window.v3Theme.applyFrame(root.querySelector('[data-v3-avatar-frame]')); } wireProfileTabs(); renderBests(); // Tell the achievements plugin (or any profile consumer) the shell + // mount points exist now, so it can (re)inject on every profile entry — // innerHTML above wipes prior injected content. Mirrors // `v3:settings-rendered`. Harmless if no listener is attached. try { document.dispatchEvent(new CustomEvent('v3:profile-rendered')); } catch (_) { /* noop */ } } // Fill the "Your best scores" panel from /api/stats/top (top scored songs, // best first). Leaves the placeholder text in place on error / no scores so // a fresh profile still reads sensibly. Accuracy is 0–1 (matches the library // grid + dashboard badges). async function renderBests() { const host = document.getElementById('v3-profile-bests'); if (!host) return; let rows = []; try { const r = await fetch('/api/stats/top?limit=5'); if (r.ok) rows = await r.json(); } catch (e) { /* P15 — keep placeholder */ } if (!Array.isArray(rows) || !rows.length) return; // keep the placeholder text const accColor = (a) => (a >= 0.9 ? 'text-fb-good' : a >= 0.5 ? 'text-fb-mid' : 'text-fb-low'); host.innerHTML = '
    ' + rows.map((s, i) => { const acc = Number(s.best_accuracy) || 0; const pct = Math.floor(acc * 100); const score = Number(s.best_score) || 0; return '
  1. ' + '' + (i + 1) + '' + '' + '' + esc(s.title || s.filename) + '' + (s.artist ? '' + esc(s.artist) + '' : '') + '' + '' + '' + pct + '%' + '' + score.toLocaleString() + ' pts' + '
  2. '; }).join('') + '
'; // Click a row to play that song (default arrangement). host.querySelectorAll('[data-fn]').forEach((li) => { li.addEventListener('click', () => { const fn = li.getAttribute('data-fn'); if (fn && typeof window.playSong === 'function') window.playSong(encodeURIComponent(fn)); }); }); } // ── First-run onboarding (and edit) overlay ─────────────────────────────── // First-run is a 3-step flow (spec 010): 1) name + avatar, 2) pick one or // more instrument paths, 3) the calibration challenge offer (play the // diagnostic sloppak at 100% — or skip and reach Mastery Rank 1 anyway). // The profile POST always lands before the step-3 choice so onboarded=1 is // never blocked by the calibration decision. Editing keeps the single form. // Run the input-device setup wizard (the input_setup plugin's // `input-calibration` domain) for the chosen instrument paths — BETWEEN path // selection and the calibration challenge — so the diagnostic runs against a // calibrated input. Capability-idiomatic: dispatch `run` (fire-and-launch) // and await the `calibration-done` event. Degrades gracefully when the // plugin/runtime is absent so onboarding can never be stranded. // Wait (bounded) for the bundled input_setup plugin to finish registering // its input-calibration owner. Plugins load asynchronously, so onboarding // can reach this step before the plugin is ready — without this wait the // mandatory wizard is skipped by a load-order race (it dispatches, gets a // no-owner outcome, and falls through to the calibration challenge). The // public global is set at the end of the plugin's screen.js, after the // owner is registered, so it is a reliable readiness signal. function waitForInputSetup(timeoutMs) { const ready = () => !!(window.feedBackInputSetup && typeof window.feedBackInputSetup.launch === 'function'); return new Promise((resolve) => { if (ready()) { resolve(true); return; } const t0 = Date.now(); const iv = setInterval(() => { if (ready()) { clearInterval(iv); resolve(true); } else if (Date.now() - t0 >= timeoutMs) { clearInterval(iv); resolve(false); } }, 100); }); } async function runInputSetup(paths) { const instruments = (Array.isArray(paths) ? paths : []).map((p) => String(p).toLowerCase()); if (!instruments.length) return; // Don't let a plugin-load race skip the mandatory input-setup step. if (!(await waitForInputSetup(8000))) return; // Hide this onboarding modal while the input-setup wizard (its own // full-screen overlay) runs on top. Otherwise both stay stacked, and when // the note-detect calibration wizard minimizes to expose the Tuner the // onboarding modal shows through behind the tuner. Restored in `finally` // before we advance to the calibration-challenge step. const ob = document.getElementById('v3-onboarding'); const obPrevDisplay = ob ? ob.style.display : ''; if (ob) ob.style.display = 'none'; const restoreOnboarding = () => { const o = document.getElementById('v3-onboarding'); if (o) o.style.display = obPrevDisplay; }; try { const caps = window.feedBack && window.feedBack.capabilities; if (!caps || typeof caps.command !== 'function') { try { await window.feedBackInputSetup.launch(instruments); } catch (e) { /* proceed */ } return; } await new Promise((resolve) => { let settled = false; let unsub = null; const done = () => { if (settled) return; settled = true; try { unsub && unsub(); } catch (e) { /* noop */ } resolve(); }; try { unsub = typeof caps.subscribe === 'function' ? caps.subscribe('input-calibration:calibration-done', done) : null; } catch (e) { unsub = null; } // `run` is fire-and-launch; completion arrives via the event above. // A non-handled outcome (no owner / plugin absent / error) means // nothing was launched, so proceed immediately. caps.command('input-calibration', 'run', { requester: 'onboarding', payload: { instruments } }) .then((r) => { if (!r || r.outcome !== 'handled') done(); }) .catch(() => done()); }); } finally { restoreOnboarding(); } } function show(profile, opts) { opts = opts || {}; const editing = !!opts.editing; document.getElementById('v3-onboarding')?.remove(); // The amp-sim opt-in step (step 5) only exists in the desktop app — the // pure-web build has no native amp sims to monitor through, so the step // is skipped there (calibration is the last step at index 5 on web, 6 on // desktop). See feedBack-desktop#46. const isDesktop = !!window.feedBackDesktop; const lastStep = isDesktop ? 6 : 5; const stepDots = editing ? '' : '
' + Array.from({ length: lastStep }, (_, i) => i + 1).map((n) => '').join('') + '
'; const overlay = document.createElement('div'); overlay.id = 'v3-onboarding'; overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4'; overlay.innerHTML = '
' + '
' + '
' + (window.fbBrand ? window.fbBrand.wordmarkHTML({ size: 'text-2xl' }) : 'fee[dB]ack') + '
' + '

' + (editing ? 'Edit your player profile' : 'Set up your player profile') + '

' + stepDots + '
' + // Step 1 — name + avatar (the original form, unchanged DOM). '
' + '
' + '
' + '
' + '
' + '
' + '' + '' + '
' + // Step 2 — song directory (where the user's songs live). '' + // Step 3 — Achievements wall opt-in (first-run only; default OFF). '' + // Step 4 — instrument paths (first-run only; tiles filled on entry). '' + // Step 5 — amp-sim opt-in (DESKTOP ONLY; default OFF / own-rig first). // Hidden div is always present in the DOM; setStep only navigates to // it on desktop. See feedBack-desktop#46. '' + // Step 6 — calibration offer (first-run only). '' + '' + '
' + (editing ? '' : '') + '' + '
'; document.body.appendChild(overlay); const nameEl = overlay.querySelector('#v3-ob-name'); const grid = overlay.querySelector('#v3-ob-avatars'); const submit = overlay.querySelector('#v3-ob-submit'); const errEl = overlay.querySelector('#v3-ob-error'); const fileEl = overlay.querySelector('#v3-ob-upload'); const preview = overlay.querySelector('#v3-ob-preview'); const skipBtn = overlay.querySelector('#v3-ob-skip'); let selected = null; // { type:'default', value } | { type:'upload', value:url } let step = 1; // first-run wizard step (editing stays on 1) let songDir = ''; // step-2 song directory pick let selectedPaths = []; // step-3 picks let pathsAvailable = false; // any tiles rendered? (false → don't strand the user) let diagnosticFilename = null; // from /api/progression (step-4 "Play it now") const songDirEl = overlay.querySelector('#v3-ob-songdir'); const songDirBrowse = overlay.querySelector('#v3-ob-songdir-browse'); if (editing && profile) { nameEl.value = profile.display_name || ''; } function refreshSubmit() { if (editing || step === 1) { // First-run onboarding requires picking an avatar. When editing an // existing profile, a name-only change is allowed: leaving `selected` // null omits `avatar` from the POST, and the server keeps the current // one — including a custom upload that isn't in the bundled grid (so // Save no longer stays disabled for those). const haveAvatar = !!selected || (editing && profile && !!profile.avatar_url); submit.disabled = !(nameEl.value.trim().length >= 1 && haveAvatar); } else if (step === 2) { // Song directory — require a non-empty path to proceed; "Skip // for now" is available for users who'll set it later. submit.disabled = !songDir.trim(); } else if (step === 3) { // Achievements opt-in — either choice is valid; always enabled. submit.disabled = false; } else if (step === 4) { // ≥1 path required — unless none could be offered (offline / // empty content), where blocking would strand onboarding. submit.disabled = pathsAvailable && selectedPaths.length < 1; } else { submit.disabled = false; } } function setStep(n) { step = n; errEl.classList.add('hidden'); for (let i = 1; i <= 6; i++) { overlay.querySelector('#v3-ob-step' + i).classList.toggle('hidden', i !== n); } overlay.querySelectorAll('#v3-ob-dots [data-dot]').forEach((d) => { d.classList.toggle('bg-fb-primary', Number(d.getAttribute('data-dot')) <= n); d.classList.toggle('bg-fb-border', Number(d.getAttribute('data-dot')) > n); }); const subtitle = overlay.querySelector('#v3-ob-subtitle'); if (subtitle) { subtitle.textContent = n === 1 ? 'Set up your player profile' : n === 2 ? 'Point us at your songs' : n === 3 ? 'Feats of Power (optional)' : n === 4 ? 'Choose your instrument paths' : n === 5 ? 'How do you want to monitor?' : 'One last thing — calibrate your setup'; } submit.textContent = n === 6 ? 'Play it now' : 'Next'; // Skip is offered on the song-directory step (configure later) and // the calibration challenge (the last step). skipBtn.classList.toggle('hidden', !(n === 2 || n === 6)); refreshSubmit(); } async function loadPathTiles() { const host = overlay.querySelector('#v3-ob-paths'); let available = []; try { const r = await fetch('/api/progression'); if (r.ok) { const data = await r.json(); diagnosticFilename = (data.onboarding || {}).diagnostic_filename || null; available = (data.available_paths || []).concat( (data.paths || []).map((p) => ({ id: p.id, name: p.name }))); } } catch (e) { /* offline — leave empty, Next stays disabled */ } pathsAvailable = available.length > 0; host.innerHTML = available.map((p) => '').join('') || '

Couldn’t load instrument paths — you can pick them later on the Progress screen.

'; refreshSubmit(); host.querySelectorAll('[data-ob-path]').forEach((b) => { b.addEventListener('click', () => { const id = b.getAttribute('data-ob-path'); const idx = selectedPaths.indexOf(id); if (idx >= 0) selectedPaths.splice(idx, 1); else selectedPaths.push(id); b.classList.toggle('ring-2', idx < 0); b.classList.toggle('ring-fb-primary', idx < 0); refreshSubmit(); }); }); } nameEl.addEventListener('input', refreshSubmit); // Reflect the pre-filled name + existing avatar immediately so an edit // with no changes can still Save (button doesn't stay disabled until // the user touches the form). refreshSubmit(); function selectTile(el, choice) { selected = choice; grid.querySelectorAll('[data-av]').forEach((t) => t.classList.remove('ring-2', 'ring-fb-primary')); if (el) el.classList.add('ring-2', 'ring-fb-primary'); refreshSubmit(); } // Load bundled defaults. The default avatar's stored value is its // bundled FILENAME (e.g. "pick.svg"), which the server validates // against the bundled set. fetch('/api/profile/avatars').then((r) => r.ok ? r.json() : []).then((list) => { grid.innerHTML = (list || []).map((a) => '').join(''); grid.querySelectorAll('[data-av]').forEach((b) => { b.addEventListener('click', () => selectTile(b, { type: 'default', value: b.dataset.name })); }); // Pre-select the current avatar when editing. if (editing && profile && profile.avatar_url) { const match = Array.from(grid.querySelectorAll('[data-av]')).find((b) => b.dataset.url === profile.avatar_url); if (match) selectTile(match, { type: 'default', value: match.dataset.name }); } }).catch(() => { /* offline / server restart — leave the avatar grid empty rather than throw */ }); // Upload handler — base64 to /api/profile/avatar (mirrors art upload). overlay.querySelector('#v3-ob-upload-btn').addEventListener('click', () => fileEl.click()); fileEl.addEventListener('change', () => { const f = fileEl.files && fileEl.files[0]; if (!f) return; if (f.size > 6 * 1024 * 1024) { showErr('Image too large (max 6 MB).'); return; } const reader = new FileReader(); reader.onload = async () => { try { const res = await fetch('/api/profile/avatar', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: reader.result }), }); const body = await res.json(); if (!res.ok) { showErr(body.error || 'Upload failed.'); return; } preview.innerHTML = ''; grid.querySelectorAll('[data-av]').forEach((t) => t.classList.remove('ring-2', 'ring-fb-primary')); selected = { type: 'upload', value: body.url }; refreshSubmit(); } catch (e) { showErr('Upload failed.'); } }; reader.readAsDataURL(f); }); function showErr(msg) { errEl.textContent = msg; errEl.classList.remove('hidden'); } if (editing) overlay.querySelector('#v3-ob-cancel')?.addEventListener('click', () => overlay.remove()); // ── Song directory (step 2) ────────────────────────────────────────── if (songDirEl) { songDirEl.addEventListener('input', () => { songDir = songDirEl.value.trim(); refreshSubmit(); }); } // Native folder picker on desktop; web users type/paste the path. const _desktop = window.feedBackDesktop; if (songDirBrowse && _desktop && typeof _desktop.pickDirectory === 'function') { songDirBrowse.classList.remove('hidden'); songDirBrowse.addEventListener('click', async () => { try { const picked = await _desktop.pickDirectory(); if (picked) { songDirEl.value = picked; songDir = picked; refreshSubmit(); } } catch (e) { /* user cancelled / unavailable */ } }); } // Save the song directory to settings + kick a library scan so the // user's songs appear. Throws (with a message) on an invalid folder. async function saveSongDir() { const dir = ((songDirEl && songDirEl.value) || '').trim(); if (!dir) return; // skipped — leave unconfigured (settable later) const res = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dlc_dir: dir }), }); // /api/settings reports an invalid folder as a 200 with an `error` // field (a bare dict return, not a non-2xx status), so a res.ok-only // check would treat the failure as success and advance without saving. // Inspect the body too. let data = null; try { data = await res.json(); } catch (e) { /* non-JSON body */ } if (!res.ok || (data && data.error)) { throw new Error((data && data.error) || 'That folder couldn’t be set — check the path and try again.'); } // Non-fatal: scan kicks off the library build in the background. try { await fetch('/api/rescan', { method: 'POST' }); } catch (e) { /* best-effort */ } } async function postProfile() { const res = await fetch('/api/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: nameEl.value.trim(), avatar: selected || undefined }), }); const body = await res.json(); if (!res.ok) throw new Error(body.error || 'Could not save profile.'); return body; } async function finish(finishOpts) { finishOpts = finishOpts || {}; overlay.remove(); await fetchProgress(); if (window.v3Progression && typeof window.v3Progression.refresh === 'function') { await window.v3Progression.refresh(); } renderBadge(); renderProfileScreen(); if (window.feedBack && window.feedBack.emit) window.feedBack.emit('v3:profile-updated', _profile); // First-run only: after a genuine onboarding completion (not a // profile edit), kick off the one-time home tour — but NOT when we're // about to launch the diagnostic ("Play it now"), which navigates to // the player; the tour would otherwise spotlight hidden home elements // and steal focus. The Skip path stays on home, so it runs there. // The engine's seen/dismissed state keeps it once; replayable from "?". if (!editing && !finishOpts.launchingSong && window.v3OnboardingTour && typeof window.v3OnboardingTour.startFirstRun === 'function') { try { window.v3OnboardingTour.startFirstRun(); } catch (e) { /* never block onboarding */ } } else if (!editing && finishOpts.launchingSong && window.v3OnboardingTour && typeof window.v3OnboardingTour.armPendingFirstRun === 'function') { // "Play it now" navigates straight to the player, so the home // tour can't run now — arm it to fire the first time the user // returns to home, instead of silently never showing. try { window.v3OnboardingTour.armPendingFirstRun(); } catch (e) { /* never block onboarding */ } } } submit.addEventListener('click', async () => { errEl.classList.add('hidden'); if (editing) { submit.disabled = true; try { _profile = await postProfile(); await finish(); } catch (e) { showErr(e.message || 'Could not save profile.'); submit.disabled = false; } return; } if (step === 1) { setStep(2); setTimeout(() => { try { songDirEl && songDirEl.focus(); } catch (e) { /* noop */ } }, 50); return; } if (step === 2) { // Save the song directory + kick a library scan, then continue // to the achievements opt-in. "Skip for now" leaves it unconfigured. submit.disabled = true; try { await saveSongDir(); setStep(3); } catch (e) { showErr(e.message || 'Could not set the song directory.'); refreshSubmit(); } return; } if (step === 3) { // Persist the wall opt-in choice (default OFF) then continue to // instrument paths. Best-effort — a failed write must not block // onboarding; the user can still set it later in Settings. submit.disabled = true; try { const optEl = overlay.querySelector('#v3-ob-optin'); const optedIn = !!(optEl && optEl.checked); try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ achievements_enabled: optedIn }), }); try { localStorage.setItem('achievementsEnabled', optedIn ? '1' : '0'); } catch (_) { /* noop */ } } catch (e) { /* best-effort — settable later */ } setStep(4); loadPathTiles(); } finally { refreshSubmit(); } return; } if (step === 4) { // Create the profile (onboarded=1) BEFORE the calibration choice // so closing the overlay at the challenge can never lose the profile. submit.disabled = true; try { _profile = await postProfile(); if (selectedPaths.length) { // A failed path save must NOT advance — step 4's skip // requires ≥1 selected path (spec invariant) and would // otherwise leave a pathless rank-1 profile. const res = await fetch('/api/progression/paths', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ add: selectedPaths }), }); if (!res.ok) { let msg = 'Could not save your instrument paths — try again.'; try { msg = (await res.json()).error || msg; } catch (e) { /* keep default */ } throw new Error(msg); } } // New step: input-device selection + calibration, between // path selection and the note-detect calibration challenge. await runInputSetup(selectedPaths); setStep(isDesktop ? 5 : 6); } catch (e) { showErr(e.message || 'Could not save profile.'); refreshSubmit(); } return; } if (step === 5) { // Step 5 (desktop only) — persist the amp-sim opt-in (default OFF // / own-rig). Best-effort: a failed write must not block onboarding; // it's settable later from the desktop Audio settings. submit.disabled = true; try { const ampEl = overlay.querySelector('#v3-ob-ampsims'); const useAmpSims = !!(ampEl && ampEl.checked); try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ use_amp_sims: useAmpSims }), }); } catch (e) { /* best-effort — settable later */ } setStep(6); } finally { refreshSubmit(); } return; } // Step 6 — "Play it now": leave calibration pending (it completes // through the normal scored-stats path) and launch the diagnostic. const target = diagnosticFilename; await finish({ launchingSong: !!target }); if (target && typeof window.playSong === 'function') window.playSong(target); }); skipBtn.addEventListener('click', async () => { // Step 2 — skip the song directory (the user can set it later in // Settings). Proceed to the achievements opt-in. if (step === 2) { setStep(3); return; } // Calibration step (last) — skip: Mastery Rank 1 immediately, // calibration stays replayable from the Progress screen. skipBtn.disabled = true; try { const res = await fetch('/api/progression/onboarding', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'skip' }), }); if (!res.ok) { skipBtn.disabled = false; return; } } catch (e) { /* offline — skippable later from Progress */ } await finish(); }); if (!editing) setStep(1); setTimeout(() => nameEl.focus(), 50); } window.v3Onboarding = { show: show }; window.v3Profile = { refresh: async function () { await fetchProfile(); await fetchProgress(); renderBadge(); renderProfileScreen(); }, get: () => _profile, }; async function boot() { await fetchProfile(); await fetchProgress(); renderBadge(); renderProfileScreen(); // Rank / dB / frames re-render whenever progression state or equipped // cosmetics move (progression-core.js / theme-core.js own the fetches). if (window.feedBack && typeof window.feedBack.on === 'function') { window.feedBack.on('progression:updated', () => { renderBadge(); renderProfileScreen(); }); window.feedBack.on('v3:cosmetics-applied', () => { renderBadge(); renderProfileScreen(); }); // Re-render on every Profile entry so the Feats shelf + Achievements // catalogue refresh (and `v3:profile-rendered` re-fires for the // plugin) — the plugin may have loaded after the initial boot render. window.feedBack.on('screen:changed', (e) => { if (e && e.detail && e.detail.id === 'v3-profile') renderProfileScreen(); }); } } // `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(); } })();