feedBack/static/v3/profile.js
2026-06-16 18:47:13 +02:00

459 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
function avatarImg(url, sizeCls, shape) {
shape = shape || 'rounded-full';
if (url) {
return '<img src="' + esc(url) + '" alt="" class="' + sizeCls + ' ' + shape +
' object-cover bg-fb-card border border-fb-border/50">';
}
// Neutral fallback glyph.
return '<span class="' + sizeCls + ' ' + shape + ' bg-fb-card border border-fb-border/50 ' +
'inline-flex items-center justify-center text-fb-textDim">' +
'<svg class="w-1/2 h-1/2" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24">' +
'<path stroke-linecap="round" stroke-linejoin="round" d="M12 12a4 4 0 100-8 4 4 0 000 8zm-7 8a7 7 0 0114 0"/></svg></span>';
}
// ── 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 = '<svg class="w-5 h-5 text-[#FF4B4B]" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">' +
'<path d="M17.557 9.414c.18-.73.242-1.501.183-2.29-.325-4.374-4.507-7.124-7.592-7.124-3.085 0-4.27 2.112-3.288 3.876.983 1.764 1.446 3.828.623 5.462-.76.712-1.503.73-2.146 1.054-1.288.65-2.112 1.954-2.112 3.414 0 4.418 3.582 8 8 8s8-3.582 8-8c0-1.85-.63-3.551-1.668-4.892zm-7.411 11.586c-2.209 0-4-1.791-4-4 0-1.398 1.065-2.201 1.065-2.201.272.775 1.015 1.327 1.885 1.327 1.105 0 2-.895 2-2s-.895-2-2-2c-.372 0-.719.102-1.018.277.29-.533.72-1.002 1.258-1.332 1.303-.801 2.94-.8 4.243.001 1.316.809 1.972 2.33 1.972 3.927 0 3.314-2.686 6-6 6z"/></svg>';
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) =>
'<div class="w-2 ' + h + ' rounded-sm" style="background-color:' + (i < filled ? FILL[i] : '#3f3f46') + '"></div>').join('');
host.innerHTML =
'<button type="button" data-v3-open-profile class="bg-fb-card border border-fb-border/50 text-white rounded-2xl flex items-center gap-3 p-2 pr-3 shadow-lg ' +
'hover:ring-1 hover:ring-fb-primary/40 transition" title="Profile">' +
'<div data-v3-avatar-tile class="bg-white w-12 h-12 rounded-xl overflow-hidden flex items-center justify-center shrink-0">' +
(_profile.avatar_url
? '<img alt="User Avatar" src="' + esc(_profile.avatar_url) + '" class="w-full h-full object-cover object-center" onerror="this.style.visibility=\'hidden\'">'
: '<svg class="w-2/3 h-2/3 text-gray-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 12a4 4 0 100-8 4 4 0 000 8zm-7 8a7 7 0 0114 0"/></svg>') +
'</div>' +
'<div class="flex flex-col justify-between py-0.5">' +
'<div class="flex items-center gap-1.5 mb-1">' + flame +
'<span class="text-base font-extrabold tracking-tight leading-none">' + (p.current_streak || 0) + ' DAYS</span></div>' +
'<div class="flex items-end gap-2">' +
'<div class="flex flex-col leading-none">' +
'<span class="text-gray-400 text-[10px] font-medium">Rank:</span>' +
'<span class="text-xl font-bold leading-none">' + rank + '</span></div>' +
'<div class="flex items-end gap-1">' + bars + '</div>' +
'<span class="text-[10px] font-semibold text-fb-gold leading-none">' + Number(balance).toLocaleString() + ' dB</span>' +
'</div></div></button>';
// 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) =>
'<span class="inline-flex items-center gap-1 bg-fb-bg/40 border border-fb-border/50 rounded-full px-3 py-1 text-xs text-fb-text">' +
esc(path.name) + ' <span class="text-fb-primary font-semibold">Lv ' + path.level + '</span></span>').join(' ');
root.innerHTML =
'<div class="max-w-4xl mx-auto p-6 md:p-8 space-y-6">' +
// Header card
'<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50 flex flex-col sm:flex-row items-center gap-6">' +
'<span data-v3-avatar-frame class="inline-block rounded-full">' +
avatarImg(_profile && _profile.avatar_url, 'w-24 h-24') + '</span>' +
'<div class="flex-1 w-full text-center sm:text-left">' +
'<h2 class="text-3xl font-bold text-fb-text">' + esc(name) + '</h2>' +
'<div class="mt-2 flex items-center justify-center sm:justify-start gap-4 text-sm text-fb-textDim flex-wrap">' +
'<span class="text-fb-text font-semibold">Mastery Rank ' + rank + '</span>' +
'<span class="text-fb-gold font-semibold">' + Number(wallet.balance).toLocaleString() + ' dB</span>' +
'<span>' + Number(wallet.lifetime_db).toLocaleString() + ' dB lifetime</span>' +
'<span class="text-fb-accent">🔥 ' + (p.current_streak || 0) + '-day streak</span>' +
'<span>Best: ' + (p.best_streak || 0) + '</span></div>' +
(pathChips ? '<div class="mt-3 flex items-center justify-center sm:justify-start gap-2 flex-wrap">' + pathChips + '</div>' : '') +
'<div class="mt-4 flex items-center justify-center sm:justify-start gap-4">' +
'<button type="button" data-v3-edit-profile class="text-sm text-fb-primary hover:text-fb-primaryHi">Edit name &amp; avatar</button>' +
'<button type="button" data-v3-open-progress class="text-sm text-fb-primary hover:text-fb-primaryHi">View challenges &amp; quests →</button>' +
'</div></div></div>' +
// Per-song bests (filled by prompt 14's song_stats; placeholder here)
'<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50">' +
'<h3 class="text-lg font-bold text-fb-text mb-2">Your best scores</h3>' +
'<p id="v3-profile-bests" class="text-sm text-fb-textDim">Play a song to start tracking your accuracy and best scores.</p>' +
'</div>' +
(_profile && _profile.player_hash
? '<p class="text-center text-[10px] uppercase tracking-wider text-fb-textDim/60">player id ' + esc(_profile.player_hash.slice(0, 12)) + '</p>'
: '') +
'</div>';
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.
function show(profile, opts) {
opts = opts || {};
const editing = !!opts.editing;
document.getElementById('v3-onboarding')?.remove();
const stepDots = editing ? '' :
'<div class="flex justify-center gap-1.5 mt-3" id="v3-ob-dots">' +
[1, 2, 3].map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
'</div>';
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 =
'<div class="bg-fb-card rounded-xl border border-fb-border/50 w-full max-w-lg p-6 space-y-5">' +
'<div class="text-center">' +
'<div class="text-2xl">' + (window.fbBrand ? window.fbBrand.wordmarkHTML({ size: 'text-2xl' }) : 'fee[dB]ack') + '</div>' +
'<p id="v3-ob-subtitle" class="text-sm text-fb-textDim mt-1">' + (editing ? 'Edit your player profile' : 'Set up your player profile') + '</p>' +
stepDots + '</div>' +
// Step 1 — name + avatar (the original form, unchanged DOM).
'<div id="v3-ob-step1">' +
'<div><label class="block text-xs uppercase tracking-wider text-fb-textDim mb-1">Display name</label>' +
'<input id="v3-ob-name" type="text" maxlength="32" placeholder="Your name" ' +
'class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-3 py-2 text-sm text-fb-text ' +
'focus:border-fb-primary focus:ring-1 focus:ring-fb-primary outline-none"></div>' +
'<div class="mt-4"><label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Avatar</label>' +
'<div id="v3-ob-avatars" class="grid grid-cols-4 sm:grid-cols-6 gap-2"></div>' +
'<div class="mt-2 flex items-center gap-3">' +
'<button type="button" id="v3-ob-upload-btn" class="text-sm text-fb-primary hover:text-fb-primaryHi">Upload your own</button>' +
'<input type="file" id="v3-ob-upload" accept="image/*" class="hidden">' +
'<span id="v3-ob-preview"></span></div></div></div>' +
// Step 2 — instrument paths (first-run only; tiles filled on entry).
'<div id="v3-ob-step2" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Pick your instrument path(s)</label>' +
'<p class="text-sm text-fb-textDim mb-3">Each path levels up by completing challenges — together they make up your Mastery Rank. You can add more later.</p>' +
'<div id="v3-ob-paths" class="grid grid-cols-3 gap-2"></div></div>' +
// Step 3 — calibration offer (first-run only).
'<div id="v3-ob-step3" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Calibration challenge</label>' +
'<p class="text-sm text-fb-textDim">Prove your setup: play the <span class="text-fb-text">Slopsmith Diagnostic</span> with note detection and finish at <span class="text-fb-text font-semibold">100% accuracy</span> to reach <span class="text-fb-text font-semibold">Mastery Rank 1</span>.</p>' +
'<p class="text-sm text-fb-textDim mt-2">Not ready? Skip it and youll start at Rank 1 anyway — you can still play it later from the Progress screen.</p></div>' +
'<p id="v3-ob-error" class="text-sm text-fb-accent hidden"></p>' +
'<div class="flex justify-end gap-3">' +
(editing ? '<button type="button" id="v3-ob-cancel" class="px-4 py-2 rounded-md text-sm text-fb-textDim hover:text-fb-text">Cancel</button>' : '') +
'<button type="button" id="v3-ob-skip" class="hidden px-4 py-2 rounded-md text-sm text-fb-textDim hover:text-fb-text">Skip for now</button>' +
'<button type="button" id="v3-ob-submit" disabled class="bg-fb-primary hover:bg-fb-primaryHi disabled:opacity-40 disabled:cursor-not-allowed text-white px-6 py-2 rounded-md font-medium shadow-lg shadow-fb-primary/20 transition-colors">' +
(editing ? 'Save' : 'Next') + '</button></div></div>';
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 selectedPaths = []; // step-2 picks
let pathsAvailable = false; // any tiles rendered? (false → don't strand the user)
let diagnosticFilename = null; // from /api/progression (step-3 "Play it now")
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) {
// ≥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 <= 3; 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 ? 'Choose your instrument paths'
: 'One last thing — calibrate your setup';
}
submit.textContent = n === 3 ? 'Play it now' : 'Next';
skipBtn.classList.toggle('hidden', n !== 3);
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) =>
'<button type="button" data-ob-path="' + esc(p.id) + '" ' +
'class="rounded-lg border border-fb-border/50 bg-fb-bg/40 px-3 py-4 text-sm font-medium text-fb-text ' +
'hover:border-fb-primary/50 transition">' + esc(p.name) + '</button>').join('') ||
'<p class="text-sm text-fb-textDim col-span-3">Couldnt load instrument paths — you can pick them later on the Progress screen.</p>';
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) =>
'<button type="button" data-av data-name="' + esc(a.name) + '" data-url="' + esc(a.url) + '" ' +
'class="aspect-square rounded-lg overflow-hidden bg-fb-bg/40 hover:ring-2 hover:ring-fb-primary/50 transition">' +
'<img src="' + esc(a.url) + '" alt="' + esc(a.name) + '" class="w-full h-full object-cover"></button>').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 = '<img src="' + esc(body.url) + '" class="w-10 h-10 rounded-full object-cover inline-block align-middle">';
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());
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() {
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);
}
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);
loadPathTiles();
return;
}
if (step === 2) {
// Create the profile (onboarded=1) BEFORE the calibration choice
// so closing the overlay at step 3 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);
}
}
setStep(3);
} catch (e) { showErr(e.message || 'Could not save profile.'); refreshSubmit(); }
return;
}
// Step 3 — "Play it now": leave calibration pending (it completes
// through the normal scored-stats path) and launch the diagnostic.
const target = diagnosticFilename;
await finish();
if (target && typeof window.playSong === 'function') window.playSong(target);
});
skipBtn.addEventListener('click', async () => {
// Step 3 — 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();
}
})();