/* * 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) ────────────────────────────────────────- 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(' '); root.innerHTML = '
' + // Header card '
' + '' + 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 (filled by prompt 14's song_stats; placeholder here) '
' + '

Your best scores

' + '

Play a song to start tracking your accuracy and best scores.

' + '
' + (_profile && _profile.player_hash ? '

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

' : '') + '
'; 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]')); } } // ── 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.slopsmithInputSetup && typeof window.slopsmithInputSetup.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; const caps = window.slopsmith && window.slopsmith.capabilities; if (!caps || typeof caps.command !== 'function') { try { await window.slopsmithInputSetup.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()); }); } function show(profile, opts) { opts = opts || {}; const editing = !!opts.editing; document.getElementById('v3-onboarding')?.remove(); const stepDots = editing ? '' : '
' + [1, 2, 3, 4].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 — instrument paths (first-run only; tiles filled on entry). '' + // Step 4 — 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) { // ≥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 <= 4; 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 ? 'Choose your instrument paths' : 'One last thing — calibrate your setup'; } submit.textContent = n === 4 ? 'Play it now' : 'Next'; // Skip is offered on the song-directory step (configure later) and // the calibration challenge. skipBtn.classList.toggle('hidden', !(n === 2 || n === 4)); 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.slopsmithDesktop; 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.slopsmith && window.slopsmith.emit) window.slopsmith.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 */ } } } 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 instrument paths. "Skip for now" leaves it unconfigured. submit.disabled = true; try { await saveSongDir(); setStep(3); loadPathTiles(); } catch (e) { showErr(e.message || 'Could not set the song directory.'); refreshSubmit(); } return; } if (step === 3) { // 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 3'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(4); } catch (e) { showErr(e.message || 'Could not save profile.'); refreshSubmit(); } return; } // Step 4 — "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 straight to instrument paths. if (step === 2) { setStep(3); loadPathTiles(); return; } // Step 4 — 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.slopsmith && typeof window.slopsmith.on === 'function') { window.slopsmith.on('progression:updated', () => { renderBadge(); renderProfileScreen(); }); window.slopsmith.on('v3:cosmetics-applied', () => { renderBadge(); renderProfileScreen(); }); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot, { once: true }); } else { boot(); } })();